Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8503ff1f3d | |||
| ae3dfd8de6 | |||
| c96fafc4ad | |||
| 2f8c5d9a98 |
@@ -463,7 +463,6 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
|||||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, `apps/server` (#345), and `apps/client` (#347) — do NOT reintroduce a per-package copy. The client uses the package's `browser` entry (`@docmost/prosemirror-markdown/browser`): markdown paste (`markdown-clipboard.ts`), copy-as-markdown, and AI-chat rendering now all go through the canonical converter, so the hand-written `marked`/`turndown` markdown layer that used to live in `editor-ext` was deleted (#347). The browser entry runs the HTML→DOM stage on the native `DOMParser`, so jsdom stays out of the client bundle. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence. For the converter's property-testing and counterexample→fixture process (P1–P4 invariants, the `PROPERTY_SEED`/`PROPERTY_NUM_RUNS` knobs, and the nightly fuzz workflow), see `packages/prosemirror-markdown/README.md`.
|
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, `apps/server` (#345), and `apps/client` (#347) — do NOT reintroduce a per-package copy. The client uses the package's `browser` entry (`@docmost/prosemirror-markdown/browser`): markdown paste (`markdown-clipboard.ts`), copy-as-markdown, and AI-chat rendering now all go through the canonical converter, so the hand-written `marked`/`turndown` markdown layer that used to live in `editor-ext` was deleted (#347). The browser entry runs the HTML→DOM stage on the native `DOMParser`, so jsdom stays out of the client bundle. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence. For the converter's property-testing and counterexample→fixture process (P1–P4 invariants, the `PROPERTY_SEED`/`PROPERTY_NUM_RUNS` knobs, and the nightly fuzz workflow), see `packages/prosemirror-markdown/README.md`.
|
||||||
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
||||||
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
||||||
- The build also emits `client/dist/version.json` (`{"version": …}`) from a small `vite.config.ts` plugin using the **same** `appVersion` that feeds `define.APP_VERSION`, so the file and the baked-in bundle version are identical by construction. The server reads it at startup (`ws.gateway.ts` via `readClientBuildVersion`/`resolveClientDistPath`) and announces it to each socket on connect (`app-version` event) so a tab left open across a redeploy can guard-reload before hitting a stale chunk (version-coherence). No runtime env / Dockerfile change — the file already ships in `client/dist`; missing/empty file ⇒ feature inert.
|
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -142,18 +142,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
snapshots switched from a fixed interval to a trailing idle-flush with a
|
snapshots switched from a fixed interval to a trailing idle-flush with a
|
||||||
max-wait ceiling, and a boundary snapshot is pinned whenever the editing source
|
max-wait ceiling, and a boundary snapshot is pinned whenever the editing source
|
||||||
changes (e.g. a person's edits followed by the AI agent). (#370)
|
changes (e.g. a person's edits followed by the AI agent). (#370)
|
||||||
- **Open tabs pick up a new deploy on their own.** After the server is
|
|
||||||
redeployed while a tab is left open for hours, the tab now learns the new
|
|
||||||
build version over the existing WebSocket (announced per-connect, so a natural
|
|
||||||
reconnect delivers it) and shows a "A new version is available" banner with an
|
|
||||||
Update button. To avoid dropping a half-written comment or form, the tab is
|
|
||||||
not reloaded when you merely switch away from it; instead it auto-reloads at
|
|
||||||
the next safe point — the next in-app navigation (or immediately if you click
|
|
||||||
Update) — before it can hit a stale lazy-loaded chunk. At most one automatic
|
|
||||||
reload happens per 5-minute window, shared with the existing chunk-load
|
|
||||||
recovery, so a permanent version skew degrades to the banner rather than a
|
|
||||||
reload loop while a second deploy in the same tab still recovers. When the
|
|
||||||
build carries no version info the feature stays inert. (#481)
|
|
||||||
|
|
||||||
- **Place several images side by side in a row.** A new "Inline (side by
|
- **Place several images side by side in a row.** A new "Inline (side by
|
||||||
side)" alignment mode in the image bubble menu renders consecutive inline
|
side)" alignment mode in the image bubble menu renders consecutive inline
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"A new version is available": "A new version is available",
|
|
||||||
"Account": "Account",
|
"Account": "Account",
|
||||||
"Active": "Active",
|
"Active": "Active",
|
||||||
"Add": "Add",
|
"Add": "Add",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"A new version is available": "Доступна новая версия",
|
|
||||||
"Account": "Аккаунт",
|
"Account": "Аккаунт",
|
||||||
"Active": "Активный",
|
"Active": "Активный",
|
||||||
"Add": "Добавить",
|
"Add": "Добавить",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { isChunkLoadError } from "./chunk-load-error-boundary";
|
import { isChunkLoadError, shouldAutoReload } from "./chunk-load-error-boundary";
|
||||||
|
|
||||||
// The detector decides whether a caught render error is a stale-deploy chunk-404
|
// The detector decides whether a caught render error is a stale-deploy chunk-404
|
||||||
// (→ auto-reload to fetch the new manifest) vs a genuine app error (→ generic
|
// (→ auto-reload to fetch the new manifest) vs a genuine app error (→ generic
|
||||||
@@ -35,3 +35,31 @@ describe("isChunkLoadError", () => {
|
|||||||
expect(isChunkLoadError(err)).toBe(false);
|
expect(isChunkLoadError(err)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The window gate replaces the old one-shot flag: it must permit recovery across
|
||||||
|
// several deploys in one tab (each > window apart) while still stopping an infinite
|
||||||
|
// reload loop when a lazy chunk is permanently broken (a second failure < window).
|
||||||
|
describe("shouldAutoReload", () => {
|
||||||
|
const WINDOW = 5 * 60 * 1000;
|
||||||
|
const NOW = 1_000_000_000_000;
|
||||||
|
|
||||||
|
it("allows a reload when we have never auto-reloaded", () => {
|
||||||
|
expect(shouldAutoReload(NOW, null, WINDOW)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a reload when the last one was 6 minutes ago (outside the window)", () => {
|
||||||
|
expect(shouldAutoReload(NOW, NOW - 6 * 60 * 1000, WINDOW)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks a reload when the last one was 1 minute ago (inside the window)", () => {
|
||||||
|
expect(shouldAutoReload(NOW, NOW - 1 * 60 * 1000, WINDOW)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks a reload exactly at the window boundary (not strictly older)", () => {
|
||||||
|
expect(shouldAutoReload(NOW, NOW - WINDOW, WINDOW)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a reload when the stored timestamp is unparseable (NaN)", () => {
|
||||||
|
expect(shouldAutoReload(NOW, NaN, WINDOW)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,11 +1,26 @@
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
import { Button, Center, Stack, Text } from "@mantine/core";
|
import { Button, Center, Stack, Text } from "@mantine/core";
|
||||||
import {
|
|
||||||
hasAutoReloaded,
|
// sessionStorage key holding the epoch-ms timestamp of the last automatic reload.
|
||||||
markAutoReloaded,
|
const RELOAD_AT_KEY = "chunk-reload-at";
|
||||||
recordReloadBreadcrumb,
|
// Allow at most one automatic reload per this window. A stale-deploy 404 is cured
|
||||||
} from "@/lib/reload-guard";
|
// by a single reload, so anything inside the window is treated as a reload loop
|
||||||
|
// (permanently-broken chunk) and falls through to the manual UI. A window (rather
|
||||||
|
// than a one-shot flag) lets a SECOND deploy in the same tab's lifetime recover too.
|
||||||
|
const RELOAD_WINDOW_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// Pure window decision, unit-tested in isolation: auto-reload only if we have never
|
||||||
|
// auto-reloaded (lastReloadAt null/NaN) or the last one was strictly older than the
|
||||||
|
// window. Anything inside the window is suppressed to break an infinite reload loop.
|
||||||
|
export function shouldAutoReload(
|
||||||
|
now: number,
|
||||||
|
lastReloadAt: number | null,
|
||||||
|
windowMs: number,
|
||||||
|
): boolean {
|
||||||
|
if (lastReloadAt === null || Number.isNaN(lastReloadAt)) return true;
|
||||||
|
return now - lastReloadAt > windowMs;
|
||||||
|
}
|
||||||
|
|
||||||
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
|
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
|
||||||
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
|
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
|
||||||
@@ -24,26 +39,24 @@ export function isChunkLoadError(error: unknown): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exported for tests: the reactive chunk-load reload decision, so the shared
|
function handleError(error: unknown) {
|
||||||
// window budget (invariant: ≤1 auto-reload per window across this path AND the
|
|
||||||
// proactive version-coherence path) can be exercised against the real guard.
|
|
||||||
export function handleError(error: unknown) {
|
|
||||||
if (!isChunkLoadError(error)) return;
|
if (!isChunkLoadError(error)) return;
|
||||||
// A stale-chunk 404 is cured by a full reload that re-fetches index.html and
|
// A stale-chunk 404 is cured by a full reload that re-fetches index.html and
|
||||||
// the new chunk manifest. Auto-reload at most once per window via the SHARED
|
// the new chunk manifest. Auto-reload at most once per RELOAD_WINDOW_MS: this
|
||||||
// window-based reload guard (see @/lib/reload-guard — the same budget the
|
// recovers across multiple deploys in a single tab's lifetime, yet a
|
||||||
// proactive version-coherence path consumes, so a mismatch that arrives on
|
// permanently-broken lazy chunk (which would loop) is stopped after the first
|
||||||
// both paths reloads at most once per window across BOTH). This recovers
|
// reload and falls through to the manual recovery UI below.
|
||||||
// across multiple deploys in a single tab's lifetime, yet a permanently-broken
|
try {
|
||||||
// lazy chunk (which would loop) is stopped after the first reload and falls
|
const raw = sessionStorage.getItem(RELOAD_AT_KEY);
|
||||||
// through to the manual recovery UI below. If the shared budget is already
|
const lastReloadAt = raw === null ? null : Number.parseInt(raw, 10);
|
||||||
// spent this window, or the stamp write fails (storage unavailable), we return
|
const now = Date.now();
|
||||||
// without reloading rather than risk a loop.
|
if (!shouldAutoReload(now, lastReloadAt, RELOAD_WINDOW_MS)) return;
|
||||||
if (hasAutoReloaded()) return;
|
sessionStorage.setItem(RELOAD_AT_KEY, String(now));
|
||||||
if (!markAutoReloaded()) return;
|
} catch {
|
||||||
// Trace before the reload clears the console (same diagnostic breadcrumb the
|
// sessionStorage unavailable (private mode / disabled): skip the automatic
|
||||||
// proactive version-coherence path writes, tagged with this path).
|
// reload rather than risk an unguarded loop; the fallback UI still recovers.
|
||||||
recordReloadBreadcrumb({ path: "chunk-boundary" });
|
return;
|
||||||
|
}
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
|
|||||||
|
|
||||||
import MessageItem from "./message-item";
|
import MessageItem from "./message-item";
|
||||||
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||||
|
import { splitPlainChunks } from "./streaming-plain-text";
|
||||||
|
|
||||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
@@ -114,3 +115,89 @@ describe("MessageItem markdown memoization", () => {
|
|||||||
expect(queryByText("streamed answer")).not.toBeNull();
|
expect(queryByText("streamed answer")).not.toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PERF SMOKE (#492): the whole point of the incremental streaming render is that
|
||||||
|
// the ANSWER path costs O(number of markdown blocks), NOT O(number of throttled
|
||||||
|
// ~20Hz ticks). Pre-#492 the finalized MarkdownPart re-parsed the WHOLE growing
|
||||||
|
// answer on every delta — a synthetic ~100 KB stream measured 394 renderChatMarkdown
|
||||||
|
// calls (one per tick). With the incremental render each STABILIZED block is parsed
|
||||||
|
// exactly once (memoized in MarkdownChunk) and the live tail is cheap plain text, so
|
||||||
|
// the call count collapses to ~= the block count regardless of tick granularity.
|
||||||
|
describe("MessageItem streaming answer render is O(blocks), not O(ticks)", () => {
|
||||||
|
// ~100 KB answer. Each section is a heading + a paragraph — TWO blank-line
|
||||||
|
// delimited markdown blocks — so the safe-cut block count is ~2× the section
|
||||||
|
// count. The perf claim is about the BLOCK count (the memoization granularity),
|
||||||
|
// measured directly with splitPlainChunks below, not the section count.
|
||||||
|
const buildAnswer = () => {
|
||||||
|
const SECTIONS = 100;
|
||||||
|
const paragraphs: string[] = [];
|
||||||
|
for (let i = 0; i < SECTIONS; i++) {
|
||||||
|
paragraphs.push(`## Section ${i}\n\n` + "lorem ipsum dolor ".repeat(55));
|
||||||
|
}
|
||||||
|
const full = paragraphs.join("\n\n");
|
||||||
|
// The number of memoized markdown blocks the incremental render splits into
|
||||||
|
// (all but the live tail are parsed once each).
|
||||||
|
return { full, blocks: splitPlainChunks(full).length };
|
||||||
|
};
|
||||||
|
|
||||||
|
const streamMsg = (text: string, state: "streaming" | "done"): UIMessage =>
|
||||||
|
({
|
||||||
|
id: "m1",
|
||||||
|
role: "assistant",
|
||||||
|
parts: [{ type: "text", text, state }],
|
||||||
|
}) as UIMessage;
|
||||||
|
|
||||||
|
it("parses each block ~once over a 100KB stream (≈blocks, ≪ ticks)", () => {
|
||||||
|
renderChatMarkdownSpy.mockClear();
|
||||||
|
const { full, blocks } = buildAnswer();
|
||||||
|
const CHUNK = 128; // a realistic ~20Hz throttled delta size
|
||||||
|
const ticks = Math.ceil(full.length / CHUNK);
|
||||||
|
|
||||||
|
let msg = streamMsg(full.slice(0, CHUNK), "streaming");
|
||||||
|
const { rerender } = render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageItem
|
||||||
|
message={msg}
|
||||||
|
signature={messageSignature(msg)}
|
||||||
|
turnStreaming
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
for (let end = 2 * CHUNK; end < full.length; end += CHUNK) {
|
||||||
|
msg = streamMsg(full.slice(0, end), "streaming");
|
||||||
|
rerender(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageItem
|
||||||
|
message={msg}
|
||||||
|
signature={messageSignature(msg)}
|
||||||
|
turnStreaming
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Finalize: the streaming→done flip renders the whole answer through ONE
|
||||||
|
// canonical pass (visual parity), so the finished DOM matches the pre-#492
|
||||||
|
// output. This is the single extra parse on top of the per-block ones.
|
||||||
|
const done = streamMsg(full, "done");
|
||||||
|
rerender(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageItem message={done} signature={messageSignature(done)} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const calls = renderChatMarkdownSpy.mock.calls.length;
|
||||||
|
// Sanity: the stream really had far more ticks than blocks (else the test is
|
||||||
|
// vacuous — the point is that calls scale with blocks, not ticks).
|
||||||
|
expect(ticks).toBeGreaterThan(blocks * 3);
|
||||||
|
// O(blocks): each stabilized block parsed once + the single final whole-text
|
||||||
|
// parse. A small constant absorbs the finalize render and the live-tail block;
|
||||||
|
// the load-bearing claim is the bound below.
|
||||||
|
expect(calls).toBeLessThanOrEqual(blocks + 2);
|
||||||
|
// ≪ ticks — and, non-vacuously, the blocks WERE parsed (not skipped entirely).
|
||||||
|
expect(calls).toBeLessThan(ticks / 3);
|
||||||
|
expect(calls).toBeGreaterThan(blocks / 2);
|
||||||
|
// MUTATION-VERIFY (documented, not run here): dropping the `memo()` wrapper on
|
||||||
|
// MarkdownChunk (so every stable block re-parses each tick) drives `calls`
|
||||||
|
// toward `ticks` (~394), reddening both upper-bound assertions above.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
|
||||||
|
// Stub react-i18next (the component reads `useTranslation`). Mirrors the other
|
||||||
|
// message-item specs.
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import MessageItem from "./message-item";
|
||||||
|
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||||
|
// The REAL canonical renderer (NOT the spy the memo test installs): this file
|
||||||
|
// exercises the actual markdown output so the visual-regression assertions below
|
||||||
|
// compare against genuine HTML (incl. the schema's `<li><p>` wrappers).
|
||||||
|
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||||
|
import classes from "./ai-chat.module.css";
|
||||||
|
|
||||||
|
const msg = (
|
||||||
|
parts: UIMessage["parts"],
|
||||||
|
extra?: Partial<UIMessage>,
|
||||||
|
): UIMessage =>
|
||||||
|
({ id: "m1", role: "assistant", parts, ...extra }) as UIMessage;
|
||||||
|
|
||||||
|
const renderRow = (message: UIMessage, turnStreaming = false) =>
|
||||||
|
render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageItem
|
||||||
|
message={message}
|
||||||
|
signature={messageSignature(message)}
|
||||||
|
turnStreaming={turnStreaming}
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// A rich multi-block answer that exercises headings, a list (the `<li><p>` case
|
||||||
|
// the scoped CSS tightens), inline emphasis, and multiple paragraphs.
|
||||||
|
const ANSWER = [
|
||||||
|
"# Заголовок",
|
||||||
|
"",
|
||||||
|
"Первый абзац с **жирным** и `кодом`.",
|
||||||
|
"",
|
||||||
|
"- пункт один",
|
||||||
|
"- пункт два",
|
||||||
|
"",
|
||||||
|
"Второй абзац.",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
describe("MessageItem final render — visual parity with the canonical pipeline", () => {
|
||||||
|
it("a finalized text part renders exactly renderChatMarkdown(text)", () => {
|
||||||
|
const { container } = renderRow(
|
||||||
|
msg([{ type: "text", text: ANSWER, state: "done" }]),
|
||||||
|
);
|
||||||
|
const block = container.querySelector(`.${classes.markdown}`);
|
||||||
|
expect(block).not.toBeNull();
|
||||||
|
// Byte-for-byte the canonical output (the SAME whole-text pass the pre-#492
|
||||||
|
// MarkdownPart produced), including `<li><p>…</p></li>` wrappers.
|
||||||
|
expect(block!.innerHTML).toBe(renderChatMarkdown(ANSWER, {}));
|
||||||
|
// The list wrapper is really present (guards against a vacuous empty render).
|
||||||
|
expect(container.querySelectorAll("li p").length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the streaming incremental view CONVERGES to the canonical render on finish", () => {
|
||||||
|
// Mount mid-stream (live tail) — the DOM here is the incremental view.
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageItem
|
||||||
|
message={msg([{ type: "text", text: ANSWER, state: "streaming" }])}
|
||||||
|
signature={messageSignature(
|
||||||
|
msg([{ type: "text", text: ANSWER, state: "streaming" }]),
|
||||||
|
)}
|
||||||
|
turnStreaming
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
// Finish the turn: state flips to done AND the turn is no longer streaming.
|
||||||
|
const done = msg([{ type: "text", text: ANSWER, state: "done" }]);
|
||||||
|
rerender(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageItem message={done} signature={messageSignature(done)} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
// After finish there is exactly ONE canonical markdown container whose HTML is
|
||||||
|
// the whole-text render — identical to the non-streaming path above.
|
||||||
|
const blocks = container.querySelectorAll(`.${classes.markdown}`);
|
||||||
|
expect(blocks.length).toBe(1);
|
||||||
|
expect(blocks[0].innerHTML).toBe(renderChatMarkdown(ANSWER, {}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizeInternalLinks is honored on the finalized render", () => {
|
||||||
|
const linkAnswer = "См. [страницу](/p/abc).";
|
||||||
|
const { container } = render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageItem
|
||||||
|
message={msg([{ type: "text", text: linkAnswer, state: "done" }])}
|
||||||
|
signature={messageSignature(
|
||||||
|
msg([{ type: "text", text: linkAnswer, state: "done" }]),
|
||||||
|
)}
|
||||||
|
neutralizeInternalLinks
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
const block = container.querySelector(`.${classes.markdown}`);
|
||||||
|
expect(block!.innerHTML).toBe(
|
||||||
|
renderChatMarkdown(linkAnswer, { neutralizeInternalLinks: true }),
|
||||||
|
);
|
||||||
|
// The internal link was made inert (no href) by the neutralization flag.
|
||||||
|
const a = container.querySelector("a");
|
||||||
|
expect(a?.hasAttribute("href")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import type { UIMessage } from "@ai-sdk/react";
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx";
|
import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx";
|
||||||
import ReasoningBlock from "@/features/ai-chat/components/reasoning-block.tsx";
|
import ReasoningBlock from "@/features/ai-chat/components/reasoning-block.tsx";
|
||||||
|
import { StreamingMarkdownText } from "@/features/ai-chat/components/streaming-markdown-text.tsx";
|
||||||
import ChatErrorAlert from "@/features/ai-chat/components/chat-error-alert.tsx";
|
import ChatErrorAlert from "@/features/ai-chat/components/chat-error-alert.tsx";
|
||||||
import ChatStoppedNotice from "@/features/ai-chat/components/chat-stopped-notice.tsx";
|
import ChatStoppedNotice from "@/features/ai-chat/components/chat-stopped-notice.tsx";
|
||||||
import { ToolUiPart, isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
import { ToolUiPart, isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||||
@@ -86,17 +87,39 @@ interface MessageItemProps {
|
|||||||
* One assistant text part rendered as sanitized markdown. Memoized on its inputs
|
* One assistant text part rendered as sanitized markdown. Memoized on its inputs
|
||||||
* so a finalized text part is NOT re-parsed on every streamed delta: during a
|
* so a finalized text part is NOT re-parsed on every streamed delta: during a
|
||||||
* turn only the actively-growing tail part changes its `text`, so every earlier
|
* turn only the actively-growing tail part changes its `text`, so every earlier
|
||||||
* part hits the memo and skips the expensive marked + DOMPurify pass. Props are
|
* part hits the memo and skips the expensive canonical parse + DOMPurify pass.
|
||||||
* primitives, so React.memo's default shallow compare is exactly right (the
|
* Props are primitives, so React.memo's default shallow compare is exactly right
|
||||||
* `text` string is compared by value).
|
* (the `text` string is compared by value).
|
||||||
|
*
|
||||||
|
* Streaming gate (#492) — mirrors ReasoningBlock:
|
||||||
|
* - `streaming` (this is the live, actively-growing tail part of an in-flight
|
||||||
|
* turn): render incrementally via StreamingMarkdownText — the stabilized blocks
|
||||||
|
* go through the canonical pipeline (each parsed ONCE, memoized) and only the
|
||||||
|
* live tail is cheap plain text. This makes the per-tick cost O(new blocks),
|
||||||
|
* not the pre-#492 O(ticks) whole-answer re-parse on every ~20Hz delta.
|
||||||
|
* - finalized (the common case, and the turn-end flip): render the WHOLE text
|
||||||
|
* through ONE canonical pass — byte-identical to the pre-#492 output (visual
|
||||||
|
* parity). The row re-renders on the streaming→done flip because
|
||||||
|
* `messageSignature` tracks each part's `state` (and `turnStreaming` flips at
|
||||||
|
* turn end), so the incremental view always converges to this single render.
|
||||||
*/
|
*/
|
||||||
const MarkdownPart = memo(function MarkdownPart({
|
const MarkdownPart = memo(function MarkdownPart({
|
||||||
text,
|
text,
|
||||||
neutralizeInternalLinks,
|
neutralizeInternalLinks,
|
||||||
|
streaming,
|
||||||
}: {
|
}: {
|
||||||
text: string;
|
text: string;
|
||||||
neutralizeInternalLinks: boolean;
|
neutralizeInternalLinks: boolean;
|
||||||
|
streaming: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
if (streaming) {
|
||||||
|
return (
|
||||||
|
<StreamingMarkdownText
|
||||||
|
text={text}
|
||||||
|
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
const html = renderChatMarkdown(text, { neutralizeInternalLinks });
|
const html = renderChatMarkdown(text, { neutralizeInternalLinks });
|
||||||
if (html) {
|
if (html) {
|
||||||
return (
|
return (
|
||||||
@@ -179,47 +202,10 @@ function MessageItem({
|
|||||||
{resolveAssistantName(assistantName) ?? t("AI agent")}
|
{resolveAssistantName(assistantName) ?? t("AI agent")}
|
||||||
</Text>
|
</Text>
|
||||||
{message.parts.map((part, index) => {
|
{message.parts.map((part, index) => {
|
||||||
if (part.type === "reasoning") {
|
// Tool parts (`tool-*` / `dynamic-tool`) are template-literal kinds, so
|
||||||
// Reasoning ("thinking") -> a collapsible block with its own token
|
// they cannot be a `switch` case; the runtime guard handles them, and the
|
||||||
// count. Empty/whitespace reasoning with no authoritative count carries
|
// switch below covers every CLOSED (literal-typed) part kind with a
|
||||||
// nothing to show, so skip it (avoids an empty 0-token block).
|
// compile-time exhaustiveness check in its default.
|
||||||
const text = (part as { text?: string }).text ?? "";
|
|
||||||
if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
|
|
||||||
return null;
|
|
||||||
// Absent state (persisted rows) and "done" both mean finalized.
|
|
||||||
// `messageSignature` already includes each part's `state`, so the
|
|
||||||
// streaming→done flip changes the row signature and re-renders this
|
|
||||||
// row — which is what lets ReasoningBlock switch from chunked plain
|
|
||||||
// text to its one-time markdown parse (see reasoning-block.tsx).
|
|
||||||
// ALSO require the turn to be live: a part stranded at
|
|
||||||
// `state:"streaming"` after the turn ended (no `reasoning-end` — see
|
|
||||||
// the `turnStreaming` prop doc) must still finalize and parse.
|
|
||||||
const streaming =
|
|
||||||
turnStreaming && (part as { state?: string }).state === "streaming";
|
|
||||||
return (
|
|
||||||
<ReasoningBlock
|
|
||||||
key={index}
|
|
||||||
text={text}
|
|
||||||
tokens={reasoningTokens}
|
|
||||||
streaming={streaming}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (part.type === "text") {
|
|
||||||
// Skip empty/whitespace-only text parts (a streaming message often
|
|
||||||
// starts with an empty text part before the first token arrives); the
|
|
||||||
// typing indicator covers that gap until real content streams in.
|
|
||||||
if (!part.text.trim()) return null;
|
|
||||||
return (
|
|
||||||
<MarkdownPart
|
|
||||||
key={index}
|
|
||||||
text={part.text}
|
|
||||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isToolPart(part.type)) {
|
if (isToolPart(part.type)) {
|
||||||
return (
|
return (
|
||||||
<ToolCallCard
|
<ToolCallCard
|
||||||
@@ -232,7 +218,76 @@ function MessageItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
switch (part.type) {
|
||||||
|
case "reasoning": {
|
||||||
|
// Reasoning ("thinking") -> a collapsible block with its own token
|
||||||
|
// count. Empty/whitespace reasoning with no authoritative count
|
||||||
|
// carries nothing to show, so skip it (avoids an empty 0-token block).
|
||||||
|
const text = part.text ?? "";
|
||||||
|
if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
|
||||||
|
return null;
|
||||||
|
// Absent state (persisted rows) and "done" both mean finalized.
|
||||||
|
// `messageSignature` already includes each part's `state`, so the
|
||||||
|
// streaming→done flip changes the row signature and re-renders this
|
||||||
|
// row — which is what lets ReasoningBlock switch from chunked plain
|
||||||
|
// text to its one-time markdown parse (see reasoning-block.tsx).
|
||||||
|
// ALSO require the turn to be live: a part stranded at
|
||||||
|
// `state:"streaming"` after the turn ended (no `reasoning-end` — see
|
||||||
|
// the `turnStreaming` prop doc) must still finalize and parse.
|
||||||
|
const streaming = turnStreaming && part.state === "streaming";
|
||||||
|
return (
|
||||||
|
<ReasoningBlock
|
||||||
|
key={index}
|
||||||
|
text={text}
|
||||||
|
tokens={reasoningTokens}
|
||||||
|
streaming={streaming}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "text": {
|
||||||
|
// Skip empty/whitespace-only text parts (a streaming message often
|
||||||
|
// starts with an empty text part before the first token arrives); the
|
||||||
|
// typing indicator covers that gap until real content streams in.
|
||||||
|
if (!part.text.trim()) return null;
|
||||||
|
// The live, actively-growing tail part of the in-flight turn renders
|
||||||
|
// incrementally (see MarkdownPart); a finalized part (persisted, or
|
||||||
|
// the turn-end flip) renders the whole text through one canonical
|
||||||
|
// pass. Same liveness rule as the reasoning branch above.
|
||||||
|
const streaming = turnStreaming && part.state === "streaming";
|
||||||
|
return (
|
||||||
|
<MarkdownPart
|
||||||
|
key={index}
|
||||||
|
text={part.text}
|
||||||
|
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||||
|
streaming={streaming}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "source-url":
|
||||||
|
case "source-document":
|
||||||
|
case "file":
|
||||||
|
case "step-start":
|
||||||
|
// Not surfaced in the chat bubble (v1) — same as the pre-#492 default.
|
||||||
|
return null;
|
||||||
|
|
||||||
|
default: {
|
||||||
|
// Compile-time exhaustiveness over the CLOSED union members: every
|
||||||
|
// literal-typed part kind is handled above, so the only kinds that
|
||||||
|
// can reach here are the OPEN template-literal ones (`tool-*` — caught
|
||||||
|
// by the guard at runtime — and `data-*`) plus `dynamic-tool`. Adding
|
||||||
|
// a NEW closed part kind to UIMessagePart makes this assignment fail
|
||||||
|
// to compile, forcing it to be handled instead of silently ignored
|
||||||
|
// (this replaces the pre-#492 fall-through `return null` + WARNING).
|
||||||
|
const _exhaustive:
|
||||||
|
| `tool-${string}`
|
||||||
|
| "dynamic-tool"
|
||||||
|
| `data-${string}` = part.type;
|
||||||
|
void _exhaustive;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
})}
|
})}
|
||||||
{/* A persisted turn error (server stored it in metadata.error). Rendered
|
{/* A persisted turn error (server stored it in metadata.error). Rendered
|
||||||
here so it survives a thread remount and shows in reopened history. */}
|
here so it survives a thread remount and shows in reopened history. */}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { memo, useMemo } from "react";
|
||||||
|
import { splitPlainChunks } from "@/features/ai-chat/components/streaming-plain-text.tsx";
|
||||||
|
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||||
|
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One STABILIZED markdown block, rendered through the canonical pipeline and
|
||||||
|
* memoized on its string prop. During streaming only the TAIL chunk grows (the
|
||||||
|
* `splitPlainChunks` append-only invariant guarantees every earlier chunk is
|
||||||
|
* byte-identical across deltas), so React skips every stable block and each one
|
||||||
|
* is parsed by `renderChatMarkdown` EXACTLY ONCE — turning the pre-#492
|
||||||
|
* "re-parse the whole accumulated answer on every ~20Hz tick" (O(ticks)) into
|
||||||
|
* O(number of blocks). The markup is DOMPurify-sanitized inside renderChatMarkdown
|
||||||
|
* before it reaches `dangerouslySetInnerHTML`.
|
||||||
|
*
|
||||||
|
* NOTE (transient streaming-only artifact): a safe cut is a blank-line boundary,
|
||||||
|
* so a construct that legitimately contains a blank line (e.g. a fenced code block
|
||||||
|
* with an empty line) can be split across chunks and render oddly WHILE it is still
|
||||||
|
* streaming. This is cosmetic and self-heals: the moment the part finalizes,
|
||||||
|
* MarkdownPart renders the WHOLE text through one canonical pass (visual parity
|
||||||
|
* with the pre-#492 output). The reasoning path makes the same trade (plain text
|
||||||
|
* while streaming, one markdown parse at the end).
|
||||||
|
*/
|
||||||
|
const MarkdownChunk = memo(function MarkdownChunk({
|
||||||
|
text,
|
||||||
|
neutralizeInternalLinks,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
neutralizeInternalLinks: boolean;
|
||||||
|
}) {
|
||||||
|
const html = renderChatMarkdown(text, { neutralizeInternalLinks });
|
||||||
|
if (html) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classes.markdown}
|
||||||
|
// Sanitized by renderChatMarkdown (DOMPurify) before insertion.
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Malformed/unsupported markdown could not render synchronously: raw text.
|
||||||
|
return (
|
||||||
|
<div className={classes.markdown} style={{ whiteSpace: "pre-wrap" }}>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cheap streaming-time stand-in for the finalized answer's one-time markdown
|
||||||
|
* parse (see MarkdownPart in message-item.tsx). Mirrors StreamingPlainText's
|
||||||
|
* chunked-memo pattern but renders the STABILIZED prefix as real markdown (each
|
||||||
|
* block parsed once, memoized) and only the LIVE tail as flat plain text — so the
|
||||||
|
* user sees formatted output for everything up to the last safe cut, and the not-
|
||||||
|
* yet-stable tail (which markdown-parsing every tick would make O(ticks)) stays a
|
||||||
|
* single cheap escaped text node until it stabilizes into a new block.
|
||||||
|
*
|
||||||
|
* `splitPlainChunks` yields chunks where, under append-only growth, every chunk
|
||||||
|
* except the LAST is immutable; the last chunk is the live tail. Index keys are
|
||||||
|
* therefore stable (a given index never changes to a different chunk's content).
|
||||||
|
*/
|
||||||
|
export function StreamingMarkdownText({
|
||||||
|
text,
|
||||||
|
neutralizeInternalLinks,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
neutralizeInternalLinks: boolean;
|
||||||
|
}) {
|
||||||
|
const chunks = useMemo(() => splitPlainChunks(text), [text]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{chunks.map((chunk, index) =>
|
||||||
|
index < chunks.length - 1 ? (
|
||||||
|
<MarkdownChunk
|
||||||
|
key={index}
|
||||||
|
text={chunk}
|
||||||
|
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// The live tail: flat, React-escaped plain text (no markdown parse, no
|
||||||
|
// sanitizer, no innerHTML). `pre-wrap` preserves its newlines; trailing
|
||||||
|
// separator newlines are dropped at display time so the block gap comes
|
||||||
|
// from the markdown margins, not a doubled empty line (mirrors
|
||||||
|
// PlainChunk in streaming-plain-text.tsx).
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={classes.markdown}
|
||||||
|
style={{ whiteSpace: "pre-wrap" }}
|
||||||
|
>
|
||||||
|
{chunk.replace(/\n+$/, "")}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
||||||
import { render, act, cleanup } from "@testing-library/react";
|
|
||||||
import { MemoryRouter, useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
// Mocks for the dirty shell's side-effecting collaborators.
|
|
||||||
vi.mock("@mantine/notifications", () => ({
|
|
||||||
notifications: { show: vi.fn() },
|
|
||||||
}));
|
|
||||||
vi.mock("@/i18n.ts", () => ({ default: { t: (k: string) => k } }));
|
|
||||||
vi.mock("@/lib/reload-guard", () => ({
|
|
||||||
hasAutoReloaded: vi.fn(() => false),
|
|
||||||
markAutoReloaded: vi.fn(() => true),
|
|
||||||
recordReloadBreadcrumb: vi.fn(),
|
|
||||||
takeReloadBreadcrumb: vi.fn(() => null),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { hasAutoReloaded, markAutoReloaded } from "@/lib/reload-guard";
|
|
||||||
import {
|
|
||||||
triggerGuardedReload,
|
|
||||||
useVersionReloadOnNavigation,
|
|
||||||
__resetGuardedReloadForTests,
|
|
||||||
} from "./guarded-reload";
|
|
||||||
|
|
||||||
const show = notifications.show as unknown as ReturnType<typeof vi.fn>;
|
|
||||||
const mockHasAutoReloaded = hasAutoReloaded as unknown as ReturnType<
|
|
||||||
typeof vi.fn
|
|
||||||
>;
|
|
||||||
const mockMarkAutoReloaded = markAutoReloaded as unknown as ReturnType<
|
|
||||||
typeof vi.fn
|
|
||||||
>;
|
|
||||||
|
|
||||||
let reload: ReturnType<typeof vi.fn>;
|
|
||||||
let visibility: DocumentVisibilityState;
|
|
||||||
|
|
||||||
// Test harness mounted inside a router: it installs the navigation hook and
|
|
||||||
// exposes `navigate` so a test can drive an in-app router navigation.
|
|
||||||
let doNavigate: (to: string) => void;
|
|
||||||
function Harness() {
|
|
||||||
useVersionReloadOnNavigation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
doNavigate = navigate;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mountHarness() {
|
|
||||||
render(
|
|
||||||
<MemoryRouter initialEntries={["/start"]}>
|
|
||||||
<Harness />
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateTo(path: string) {
|
|
||||||
act(() => {
|
|
||||||
doNavigate(path);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
__resetGuardedReloadForTests();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockHasAutoReloaded.mockReturnValue(false);
|
|
||||||
mockMarkAutoReloaded.mockReturnValue(true);
|
|
||||||
|
|
||||||
vi.stubGlobal("APP_VERSION", "test-A");
|
|
||||||
|
|
||||||
reload = vi.fn();
|
|
||||||
Object.defineProperty(window, "location", {
|
|
||||||
configurable: true,
|
|
||||||
value: { reload },
|
|
||||||
});
|
|
||||||
|
|
||||||
visibility = "visible";
|
|
||||||
Object.defineProperty(document, "visibilityState", {
|
|
||||||
configurable: true,
|
|
||||||
get: () => visibility,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("triggerGuardedReload (variant C)", () => {
|
|
||||||
it("noop when versions match: no banner, no reload", () => {
|
|
||||||
triggerGuardedReload("test-A");
|
|
||||||
expect(show).not.toHaveBeenCalled();
|
|
||||||
expect(reload).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("noop when the server version is empty (fail-safe)", () => {
|
|
||||||
triggerGuardedReload("");
|
|
||||||
triggerGuardedReload(undefined);
|
|
||||||
expect(show).not.toHaveBeenCalled();
|
|
||||||
expect(reload).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("real mismatch shows the banner but does NOT reload immediately", () => {
|
|
||||||
mountHarness();
|
|
||||||
triggerGuardedReload("test-B");
|
|
||||||
expect(show).toHaveBeenCalledTimes(1);
|
|
||||||
expect(show.mock.calls[0][0]).toMatchObject({
|
|
||||||
id: "app-version-reload",
|
|
||||||
autoClose: false,
|
|
||||||
withCloseButton: true,
|
|
||||||
});
|
|
||||||
expect(reload).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reloads EXACTLY ONCE on the first in-app navigation after a mismatch", () => {
|
|
||||||
mountHarness();
|
|
||||||
triggerGuardedReload("test-B");
|
|
||||||
expect(reload).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
navigateTo("/next");
|
|
||||||
expect(reload).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// A second navigation must NOT reload again (one-shot was consumed).
|
|
||||||
navigateTo("/again");
|
|
||||||
expect(reload).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT reload on a 2nd mismatch after the first was armed/consumed (one-shot)", () => {
|
|
||||||
mountHarness();
|
|
||||||
triggerGuardedReload("test-B");
|
|
||||||
navigateTo("/next");
|
|
||||||
expect(reload).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// Another app-version mismatch arrives (reconnect): must not re-arm.
|
|
||||||
triggerGuardedReload("test-C");
|
|
||||||
navigateTo("/again");
|
|
||||||
expect(reload).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT reload merely from the tab going to the background", () => {
|
|
||||||
mountHarness();
|
|
||||||
triggerGuardedReload("test-B");
|
|
||||||
expect(reload).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
visibility = "hidden";
|
|
||||||
act(() => {
|
|
||||||
document.dispatchEvent(new Event("visibilitychange"));
|
|
||||||
});
|
|
||||||
expect(reload).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a hidden-at-receipt tab is also NOT reloaded immediately (variant C uniform); reloads on next navigation", () => {
|
|
||||||
visibility = "hidden";
|
|
||||||
mountHarness();
|
|
||||||
triggerGuardedReload("test-B");
|
|
||||||
expect(reload).not.toHaveBeenCalled();
|
|
||||||
expect(show).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
navigateTo("/next");
|
|
||||||
expect(reload).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("the banner's Update button reloads immediately", () => {
|
|
||||||
triggerGuardedReload("test-B");
|
|
||||||
const message = show.mock.calls[0][0].message as {
|
|
||||||
props: { onClick: () => void };
|
|
||||||
};
|
|
||||||
message.props.onClick();
|
|
||||||
expect(reload).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("banner-only (auto-reload already spent): banner, never auto-reload on navigation", () => {
|
|
||||||
mockHasAutoReloaded.mockReturnValue(true);
|
|
||||||
mountHarness();
|
|
||||||
triggerGuardedReload("test-B");
|
|
||||||
expect(show).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
navigateTo("/next");
|
|
||||||
expect(reload).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT reload when the flag write fails; falls back to the banner", () => {
|
|
||||||
mockMarkAutoReloaded.mockReturnValue(false);
|
|
||||||
mountHarness();
|
|
||||||
triggerGuardedReload("test-B");
|
|
||||||
navigateTo("/next");
|
|
||||||
expect(reload).not.toHaveBeenCalled();
|
|
||||||
// performAutoReload falls back to showing the banner (initial + fallback).
|
|
||||||
expect(show).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is idempotent within a tab-load: repeated emits do not stack banners", () => {
|
|
||||||
triggerGuardedReload("test-B");
|
|
||||||
triggerGuardedReload("test-B");
|
|
||||||
triggerGuardedReload("test-C");
|
|
||||||
expect(show).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { useLocation } from "react-router-dom";
|
|
||||||
import { Button } from "@mantine/core";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import i18n from "@/i18n.ts";
|
|
||||||
import {
|
|
||||||
hasAutoReloaded,
|
|
||||||
markAutoReloaded,
|
|
||||||
recordReloadBreadcrumb,
|
|
||||||
takeReloadBreadcrumb,
|
|
||||||
} from "@/lib/reload-guard";
|
|
||||||
import { decideVersionAction } from "@/features/user/version-coherence";
|
|
||||||
|
|
||||||
// Dirty shell around the pure `decideVersionAction`: it reads globals
|
|
||||||
// (APP_VERSION), touches sessionStorage via the shared reload-guard, drives the
|
|
||||||
// Mantine notification, and arms the router-navigation reload hook. Kept
|
|
||||||
// separate from the pure module so the decision stays unit-testable without a
|
|
||||||
// DOM.
|
|
||||||
|
|
||||||
// One fixed id so repeated app-version signals (e.g. every reconnect) update a
|
|
||||||
// single banner instead of stacking a new one each time.
|
|
||||||
const BANNER_ID = "app-version-reload";
|
|
||||||
|
|
||||||
// Module-level idempotency for the current tab-load: once a mismatch has been
|
|
||||||
// handled we don't re-arm the navigation reload or re-show the banner on
|
|
||||||
// subsequent app-version emits.
|
|
||||||
let handled = false;
|
|
||||||
|
|
||||||
// Variant C: on a real mismatch we do NOT reload the tab when it merely goes to
|
|
||||||
// the background (that would silently drop a half-written comment/form). Instead
|
|
||||||
// we arm a one-shot reload for the NEXT in-app router navigation — a point where
|
|
||||||
// the user is already leaving the current page, so an in-app navigation would
|
|
||||||
// discard that unsaved component-state anyway and the reload adds no extra loss.
|
|
||||||
let pendingNavReload = false;
|
|
||||||
|
|
||||||
// Remembered from the last detected mismatch for the pre-reload breadcrumb and
|
|
||||||
// the (already-visible) banner.
|
|
||||||
let lastServerVersion = "";
|
|
||||||
let lastClientVersion = "";
|
|
||||||
|
|
||||||
// Read the build version baked into THIS bundle. The `typeof` guard avoids a
|
|
||||||
// ReferenceError where the `APP_VERSION` global is absent (e.g. under vitest,
|
|
||||||
// where Vite's `define` did not run) — an unknown client version makes the
|
|
||||||
// pure decision no-op (fail-safe).
|
|
||||||
function readClientVersion(): string {
|
|
||||||
return (typeof APP_VERSION !== "undefined" ? APP_VERSION : "").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform the actual reload — but only after the shared one-shot flag is
|
|
||||||
// persisted. If the write fails (storage unavailable) we must NOT reload
|
|
||||||
// (mirrors the reactive chunk-load boundary's `catch → return`), and fall back
|
|
||||||
// to the manual banner so the user can still recover.
|
|
||||||
function performAutoReload(): void {
|
|
||||||
if (!markAutoReloaded()) {
|
|
||||||
showReloadBanner();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Trace right before the reload (which clears the console): a persistent
|
|
||||||
// breadcrumb + a log line so the auto-reload is observable in a field report.
|
|
||||||
recordReloadBreadcrumb({
|
|
||||||
path: "proactive",
|
|
||||||
serverVersion: lastServerVersion,
|
|
||||||
clientVersion: lastClientVersion,
|
|
||||||
});
|
|
||||||
console.warn(
|
|
||||||
`[version-coherence] auto-reloading: client=${lastClientVersion} -> server=${lastServerVersion}`,
|
|
||||||
);
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
function showReloadBanner(): void {
|
|
||||||
notifications.show({
|
|
||||||
id: BANNER_ID,
|
|
||||||
title: i18n.t("A new version is available"),
|
|
||||||
message: (
|
|
||||||
<Button size="xs" mt="xs" onClick={() => performAutoReload()}>
|
|
||||||
{i18n.t("Update")}
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
autoClose: false,
|
|
||||||
withCloseButton: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle a server `app-version` announcement: compare it to this bundle's
|
|
||||||
* version and, on a real mismatch, show the banner and arm a guarded reload for
|
|
||||||
* the next in-app navigation (variant C).
|
|
||||||
*
|
|
||||||
* - real mismatch (window budget available) → banner + arm navigation reload.
|
|
||||||
* The banner's "Update" button reloads immediately (same shared window guard).
|
|
||||||
* The tab is NOT reloaded on visibility change.
|
|
||||||
* - auto-reload already used this window / storage error → banner only (no arm),
|
|
||||||
* so there is at most one automatic reload per RELOAD_WINDOW_MS window (loop
|
|
||||||
* safety).
|
|
||||||
* - in sync / unknown version → noop (fail-safe).
|
|
||||||
*/
|
|
||||||
export function triggerGuardedReload(
|
|
||||||
rawServerVersion: string | undefined | null,
|
|
||||||
): void {
|
|
||||||
const serverVersion = (rawServerVersion ?? "").trim();
|
|
||||||
const clientVersion = readClientVersion();
|
|
||||||
|
|
||||||
// A storage read error surfaces as autoReloadUsed=true → fail toward NOT
|
|
||||||
// reloading (banner only).
|
|
||||||
const autoReloadUsed = hasAutoReloaded();
|
|
||||||
|
|
||||||
const action = decideVersionAction({
|
|
||||||
serverVersion,
|
|
||||||
clientVersion,
|
|
||||||
autoReloadUsed,
|
|
||||||
});
|
|
||||||
if (action === "noop") return;
|
|
||||||
|
|
||||||
// Idempotent per tab-load: don't re-arm or re-stack the banner across repeated
|
|
||||||
// emits (reconnects) once we've already acted.
|
|
||||||
if (handled) return;
|
|
||||||
handled = true;
|
|
||||||
|
|
||||||
lastServerVersion = serverVersion;
|
|
||||||
lastClientVersion = clientVersion;
|
|
||||||
|
|
||||||
if (action === "banner") {
|
|
||||||
// Entered banner-only (permanent skew, node oscillation, or the window's
|
|
||||||
// auto-reload budget already spent). Log for diagnosability; show the banner.
|
|
||||||
console.warn(
|
|
||||||
`[version-coherence] server=${serverVersion} client=${clientVersion}: ` +
|
|
||||||
"auto-reload budget already spent this window — showing manual banner",
|
|
||||||
);
|
|
||||||
showReloadBanner();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// action === "reload" (variant C): show the banner and defer the auto-reload
|
|
||||||
// to the next in-app navigation instead of reloading now / on visibility.
|
|
||||||
showReloadBanner();
|
|
||||||
pendingNavReload = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Consume the armed one-shot navigation reload, if any. Called by
|
|
||||||
* `useVersionReloadOnNavigation` on each in-app router navigation.
|
|
||||||
*/
|
|
||||||
export function consumeNavigationReload(): void {
|
|
||||||
if (!pendingNavReload) return;
|
|
||||||
pendingNavReload = false;
|
|
||||||
performAutoReload();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook (mounted inside the Router) that fires the armed one-shot reload on the
|
|
||||||
* NEXT in-app router navigation after a version mismatch. Skips the initial
|
|
||||||
* render so it only reacts to real navigations, not the first location.
|
|
||||||
*/
|
|
||||||
export function useVersionReloadOnNavigation(): void {
|
|
||||||
const location = useLocation();
|
|
||||||
const firstRender = useRef(true);
|
|
||||||
useEffect(() => {
|
|
||||||
if (firstRender.current) {
|
|
||||||
firstRender.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
consumeNavigationReload();
|
|
||||||
}, [location.key]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Surface (log once) the breadcrumb left by an auto-reload in the previous page
|
|
||||||
* load — the reload cleared the console, so this makes a "tab reloaded itself"
|
|
||||||
* report diagnosable. Call once on app startup.
|
|
||||||
*/
|
|
||||||
export function surfacePreviousReloadBreadcrumb(): void {
|
|
||||||
const crumb = takeReloadBreadcrumb();
|
|
||||||
if (!crumb) return;
|
|
||||||
console.info(
|
|
||||||
`[version-coherence] previous auto-reload: path=${crumb.path} ` +
|
|
||||||
`client=${crumb.clientVersion ?? ""} -> server=${crumb.serverVersion ?? ""} ` +
|
|
||||||
`at=${new Date(crumb.at).toISOString()}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test-only: reset module-level latches between cases.
|
|
||||||
export function __resetGuardedReloadForTests(): void {
|
|
||||||
handled = false;
|
|
||||||
pendingNavReload = false;
|
|
||||||
lastServerVersion = "";
|
|
||||||
lastClientVersion = "";
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
||||||
import { render, act, cleanup } from "@testing-library/react";
|
|
||||||
import { MemoryRouter, useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
// Integration test for the SHARED, window-based auto-reload budget (invariant a):
|
|
||||||
// the reactive chunk-load boundary and the proactive version-coherence path both
|
|
||||||
// route through the REAL @/lib/reload-guard, so at most one automatic reload
|
|
||||||
// happens per RELOAD_WINDOW_MS across BOTH paths combined. Only the two paths'
|
|
||||||
// side-effecting collaborators are mocked — the reload guard is intentionally
|
|
||||||
// REAL so this exercises the actual shared sessionStorage budget.
|
|
||||||
vi.mock("@mantine/notifications", () => ({
|
|
||||||
notifications: { show: vi.fn() },
|
|
||||||
}));
|
|
||||||
vi.mock("@/i18n.ts", () => ({ default: { t: (k: string) => k } }));
|
|
||||||
|
|
||||||
import { handleError } from "@/components/chunk-load-error-boundary";
|
|
||||||
import {
|
|
||||||
triggerGuardedReload,
|
|
||||||
useVersionReloadOnNavigation,
|
|
||||||
__resetGuardedReloadForTests,
|
|
||||||
} from "./guarded-reload";
|
|
||||||
import { RELOAD_WINDOW_MS } from "@/lib/reload-guard";
|
|
||||||
|
|
||||||
const CHUNK_ERROR = { name: "ChunkLoadError", message: "boom" };
|
|
||||||
const T0 = 1_000_000_000_000;
|
|
||||||
|
|
||||||
let reload: ReturnType<typeof vi.fn>;
|
|
||||||
let nowMock: ReturnType<typeof vi.spyOn>;
|
|
||||||
|
|
||||||
// Harness mounted inside a router: installs the navigation hook and exposes
|
|
||||||
// `navigate` so a test can drive an in-app router navigation (the point where the
|
|
||||||
// proactive path fires its armed reload).
|
|
||||||
let doNavigate: (to: string) => void;
|
|
||||||
function Harness() {
|
|
||||||
useVersionReloadOnNavigation();
|
|
||||||
doNavigate = useNavigate();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
function mountHarness() {
|
|
||||||
render(
|
|
||||||
<MemoryRouter initialEntries={["/start"]}>
|
|
||||||
<Harness />
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function navigateTo(path: string) {
|
|
||||||
act(() => {
|
|
||||||
doNavigate(path);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function setNow(t: number) {
|
|
||||||
nowMock.mockReturnValue(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
sessionStorage.clear();
|
|
||||||
__resetGuardedReloadForTests();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
nowMock = vi.spyOn(Date, "now").mockReturnValue(T0);
|
|
||||||
vi.stubGlobal("APP_VERSION", "test-A");
|
|
||||||
reload = vi.fn();
|
|
||||||
Object.defineProperty(window, "location", {
|
|
||||||
configurable: true,
|
|
||||||
value: { reload },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
sessionStorage.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("shared window-based reload budget (invariant a)", () => {
|
|
||||||
it("a chunk-load reload spends the budget: a version-coherence mismatch within the window shows the banner but does NOT reload", () => {
|
|
||||||
// Path 1 (reactive): a stale-chunk 404 auto-reloads once and stamps the window.
|
|
||||||
handleError(CHUNK_ERROR);
|
|
||||||
expect(reload).toHaveBeenCalledTimes(1);
|
|
||||||
reload.mockClear();
|
|
||||||
|
|
||||||
// Path 2 (proactive), 1 min later — still inside the window. The shared budget
|
|
||||||
// is spent, so the version mismatch degrades to the banner and never reloads.
|
|
||||||
setNow(T0 + 60_000);
|
|
||||||
mountHarness();
|
|
||||||
triggerGuardedReload("test-B");
|
|
||||||
navigateTo("/next");
|
|
||||||
expect(reload).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a version-coherence reload spends the SAME budget: a chunk-load error within the window does NOT reload", () => {
|
|
||||||
// Path 2 (proactive) first: real mismatch → arm → fire on navigation.
|
|
||||||
mountHarness();
|
|
||||||
triggerGuardedReload("test-B");
|
|
||||||
navigateTo("/next");
|
|
||||||
expect(reload).toHaveBeenCalledTimes(1);
|
|
||||||
reload.mockClear();
|
|
||||||
|
|
||||||
// Path 1 (reactive), 2 min later — inside the window. Budget already spent by
|
|
||||||
// the proactive path, so the stale-chunk error must NOT trigger a second reload.
|
|
||||||
setNow(T0 + 2 * 60_000);
|
|
||||||
handleError(CHUNK_ERROR);
|
|
||||||
expect(reload).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("recovers after the window: a reload strictly older than the window is allowed again (second deploy)", () => {
|
|
||||||
// First auto-reload (reactive) stamps the window.
|
|
||||||
handleError(CHUNK_ERROR);
|
|
||||||
expect(reload).toHaveBeenCalledTimes(1);
|
|
||||||
reload.mockClear();
|
|
||||||
|
|
||||||
// A second deploy arrives after the window has fully elapsed → the proactive
|
|
||||||
// path is allowed to reload again (window, not a permanent one-shot).
|
|
||||||
__resetGuardedReloadForTests();
|
|
||||||
setNow(T0 + RELOAD_WINDOW_MS + 1);
|
|
||||||
mountHarness();
|
|
||||||
triggerGuardedReload("test-B");
|
|
||||||
navigateTo("/next");
|
|
||||||
expect(reload).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reactive path fails closed when storage READS but cannot WRITE (quota / Safari private): no unguarded reload", () => {
|
|
||||||
// getItem→null makes hasAutoReloaded() report the budget as available, so
|
|
||||||
// handleError passes the first guard and reaches `if (!markAutoReloaded())
|
|
||||||
// return;`. setItem throws → the stamp cannot stick, so markAutoReloaded()
|
|
||||||
// returns false and that guard MUST bail — otherwise the reactive path would
|
|
||||||
// reload on every stale-chunk error with no persisted budget (an unguarded
|
|
||||||
// loop). This is the asymmetric gap: the proactive path's equivalent is
|
|
||||||
// covered by guarded-reload.test.tsx "does NOT reload when the flag write
|
|
||||||
// fails".
|
|
||||||
vi.stubGlobal("sessionStorage", {
|
|
||||||
getItem: () => null,
|
|
||||||
setItem: () => {
|
|
||||||
throw new Error("quota exceeded");
|
|
||||||
},
|
|
||||||
removeItem: () => {},
|
|
||||||
clear: () => {},
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
handleError(CHUNK_ERROR);
|
|
||||||
expect(reload).not.toHaveBeenCalled();
|
|
||||||
} finally {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sessionStorage unavailable: neither path performs an unguarded reload", () => {
|
|
||||||
// The real guard fails toward NOT reloading when storage throws.
|
|
||||||
vi.stubGlobal("sessionStorage", {
|
|
||||||
getItem: () => {
|
|
||||||
throw new Error("storage disabled");
|
|
||||||
},
|
|
||||||
setItem: () => {
|
|
||||||
throw new Error("storage disabled");
|
|
||||||
},
|
|
||||||
removeItem: () => {},
|
|
||||||
clear: () => {},
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
handleError(CHUNK_ERROR);
|
|
||||||
expect(reload).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
mountHarness();
|
|
||||||
triggerGuardedReload("test-B");
|
|
||||||
navigateTo("/next");
|
|
||||||
expect(reload).not.toHaveBeenCalled();
|
|
||||||
} finally {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -13,12 +13,6 @@ import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
|||||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
import { makeConnectHandler } from "@/features/user/connect-resync.ts";
|
import { makeConnectHandler } from "@/features/user/connect-resync.ts";
|
||||||
import {
|
|
||||||
triggerGuardedReload,
|
|
||||||
useVersionReloadOnNavigation,
|
|
||||||
surfacePreviousReloadBreadcrumb,
|
|
||||||
} from "@/features/user/guarded-reload.tsx";
|
|
||||||
import type { AppVersionSocketPayload } from "@/features/user/version-coherence.ts";
|
|
||||||
|
|
||||||
export function UserProvider({ children }: React.PropsWithChildren) {
|
export function UserProvider({ children }: React.PropsWithChildren) {
|
||||||
const [, setCurrentUser] = useAtom(currentUserAtom);
|
const [, setCurrentUser] = useAtom(currentUserAtom);
|
||||||
@@ -28,16 +22,6 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
|||||||
// fetch collab token on load
|
// fetch collab token on load
|
||||||
const { data: collab } = useCollabToken();
|
const { data: collab } = useCollabToken();
|
||||||
|
|
||||||
// version-coherence: fire the armed one-shot reload on the next in-app
|
|
||||||
// navigation (variant C — a safe point, not on tab backgrounding).
|
|
||||||
useVersionReloadOnNavigation();
|
|
||||||
|
|
||||||
// Surface any breadcrumb left by an auto-reload in the previous page load
|
|
||||||
// (the reload cleared the console) so a field report stays diagnosable.
|
|
||||||
useEffect(() => {
|
|
||||||
surfacePreviousReloadBreadcrumb();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading || isError) {
|
if (isLoading || isError) {
|
||||||
return;
|
return;
|
||||||
@@ -63,16 +47,6 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
|||||||
handleConnect();
|
handleConnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register the version-coherence listener SYNCHRONOUSLY, before the socket
|
|
||||||
// connects: the server emits `app-version` immediately in handleConnection,
|
|
||||||
// so a listener attached after connect would miss it on a fast localhost
|
|
||||||
// connect. On a version mismatch the client shows a banner and defers the
|
|
||||||
// auto-reload to the next in-app navigation (variant C — avoids reloading a
|
|
||||||
// backgrounded tab that may hold unsaved input) before it hits a stale chunk.
|
|
||||||
newSocket.on("app-version", (payload?: AppVersionSocketPayload) => {
|
|
||||||
triggerGuardedReload(payload?.version);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
console.log("ws disconnected");
|
console.log("ws disconnected");
|
||||||
newSocket.disconnect();
|
newSocket.disconnect();
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { decideVersionAction } from "./version-coherence";
|
|
||||||
|
|
||||||
describe("decideVersionAction", () => {
|
|
||||||
it("noop when the server version is empty (fail-safe)", () => {
|
|
||||||
expect(
|
|
||||||
decideVersionAction({
|
|
||||||
serverVersion: "",
|
|
||||||
clientVersion: "v1",
|
|
||||||
autoReloadUsed: false,
|
|
||||||
}),
|
|
||||||
).toBe("noop");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("noop when the client version is empty (fail-safe)", () => {
|
|
||||||
expect(
|
|
||||||
decideVersionAction({
|
|
||||||
serverVersion: "v1",
|
|
||||||
clientVersion: "",
|
|
||||||
autoReloadUsed: false,
|
|
||||||
}),
|
|
||||||
).toBe("noop");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("noop when versions are equal (in sync)", () => {
|
|
||||||
expect(
|
|
||||||
decideVersionAction({
|
|
||||||
serverVersion: "v1",
|
|
||||||
clientVersion: "v1",
|
|
||||||
autoReloadUsed: false,
|
|
||||||
}),
|
|
||||||
).toBe("noop");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reload on a real mismatch the first time this session", () => {
|
|
||||||
expect(
|
|
||||||
decideVersionAction({
|
|
||||||
serverVersion: "test-B",
|
|
||||||
clientVersion: "test-A",
|
|
||||||
autoReloadUsed: false,
|
|
||||||
}),
|
|
||||||
).toBe("reload");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("banner on a mismatch once the session auto-reload is spent", () => {
|
|
||||||
expect(
|
|
||||||
decideVersionAction({
|
|
||||||
serverVersion: "test-B",
|
|
||||||
clientVersion: "test-A",
|
|
||||||
autoReloadUsed: true,
|
|
||||||
}),
|
|
||||||
).toBe("banner");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("equal versions stay noop even if auto-reload was already used", () => {
|
|
||||||
expect(
|
|
||||||
decideVersionAction({
|
|
||||||
serverVersion: "v1",
|
|
||||||
clientVersion: "v1",
|
|
||||||
autoReloadUsed: true,
|
|
||||||
}),
|
|
||||||
).toBe("noop");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
// Payload of the per-connect `app-version` socket.io event announced by the
|
|
||||||
// server (ws.gateway.ts) after a successful auth. A dedicated event — NOT a
|
|
||||||
// member of the room-scoped `WebSocketEvent` union (which is discriminated by
|
|
||||||
// `operation`), so it never touches use-query-subscription.
|
|
||||||
export type AppVersionSocketPayload = { version: string };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure decision for the version-coherence guard.
|
|
||||||
*
|
|
||||||
* All inputs are injected (no globals, no side effects) so it is unit-testable
|
|
||||||
* without a DOM or the build-time `APP_VERSION` global (undefined under vitest).
|
|
||||||
*
|
|
||||||
* - `autoReloadUsed` = an automatic reload has already happened within the
|
|
||||||
* current ~5-min window, so we must not auto-reload again (loop safety,
|
|
||||||
* shared window budget with the reactive chunk-load boundary).
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - "noop" — do nothing (unknown version on either side, or already in sync).
|
|
||||||
* - "banner" — show the manual "update available" banner only (no auto-reload).
|
|
||||||
* - "reload" — real first-time mismatch: eligible for a guarded auto-reload.
|
|
||||||
*/
|
|
||||||
export function decideVersionAction(args: {
|
|
||||||
serverVersion: string;
|
|
||||||
clientVersion: string;
|
|
||||||
autoReloadUsed: boolean;
|
|
||||||
}): "reload" | "banner" | "noop" {
|
|
||||||
const { serverVersion, clientVersion, autoReloadUsed } = args;
|
|
||||||
if (!serverVersion || !clientVersion) return "noop"; // fail-safe: unknown version → never act
|
|
||||||
if (serverVersion === clientVersion) return "noop"; // in sync
|
|
||||||
if (autoReloadUsed) return "banner"; // one auto-reload per RELOAD_WINDOW_MS window already spent
|
|
||||||
return "reload"; // real mismatch, window budget available
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
||||||
import {
|
|
||||||
hasAutoReloaded,
|
|
||||||
markAutoReloaded,
|
|
||||||
shouldAutoReload,
|
|
||||||
recordReloadBreadcrumb,
|
|
||||||
takeReloadBreadcrumb,
|
|
||||||
RELOAD_WINDOW_MS,
|
|
||||||
} from "./reload-guard";
|
|
||||||
|
|
||||||
// The shared budget is a single sessionStorage timestamp keyed here; both the
|
|
||||||
// reactive chunk-load boundary and the proactive version-coherence path read and
|
|
||||||
// stamp it, so at most one auto-reload happens per RELOAD_WINDOW_MS across BOTH.
|
|
||||||
const RELOAD_AT_KEY = "chunk-reload-at";
|
|
||||||
const NOW = 1_000_000_000_000;
|
|
||||||
|
|
||||||
describe("reload-guard", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
sessionStorage.clear();
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
sessionStorage.clear();
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hasAutoReloaded is false before any reload, true within the window after mark", () => {
|
|
||||||
expect(hasAutoReloaded(NOW)).toBe(false);
|
|
||||||
expect(markAutoReloaded(NOW)).toBe(true);
|
|
||||||
// Same key both paths share; stores the reload timestamp, not a flag.
|
|
||||||
expect(sessionStorage.getItem(RELOAD_AT_KEY)).toBe(String(NOW));
|
|
||||||
// Inside the window → budget spent → true (fall through to manual UI).
|
|
||||||
expect(hasAutoReloaded(NOW)).toBe(true);
|
|
||||||
expect(hasAutoReloaded(NOW + RELOAD_WINDOW_MS)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hasAutoReloaded is false again once the window has elapsed (a later deploy recovers)", () => {
|
|
||||||
markAutoReloaded(NOW);
|
|
||||||
// Strictly older than the window → a new deploy's mismatch may reload again.
|
|
||||||
expect(hasAutoReloaded(NOW + RELOAD_WINDOW_MS + 1)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hasAutoReloaded returns true when reading storage throws (fail toward not reloading)", () => {
|
|
||||||
vi.stubGlobal("sessionStorage", {
|
|
||||||
getItem: () => {
|
|
||||||
throw new Error("storage disabled");
|
|
||||||
},
|
|
||||||
setItem: () => {
|
|
||||||
throw new Error("storage disabled");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
expect(hasAutoReloaded()).toBe(true);
|
|
||||||
} finally {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hasAutoReloaded treats an unparseable stored timestamp as never-reloaded", () => {
|
|
||||||
sessionStorage.setItem(RELOAD_AT_KEY, "not-a-number");
|
|
||||||
expect(hasAutoReloaded(NOW)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("markAutoReloaded returns false when writing storage throws", () => {
|
|
||||||
vi.stubGlobal("sessionStorage", {
|
|
||||||
getItem: () => null,
|
|
||||||
setItem: () => {
|
|
||||||
throw new Error("storage disabled");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
expect(markAutoReloaded()).toBe(false);
|
|
||||||
} finally {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("records and then takes a breadcrumb once (cleared on read)", () => {
|
|
||||||
recordReloadBreadcrumb({
|
|
||||||
path: "proactive",
|
|
||||||
serverVersion: "test-B",
|
|
||||||
clientVersion: "test-A",
|
|
||||||
});
|
|
||||||
const crumb = takeReloadBreadcrumb();
|
|
||||||
expect(crumb).toMatchObject({
|
|
||||||
path: "proactive",
|
|
||||||
serverVersion: "test-B",
|
|
||||||
clientVersion: "test-A",
|
|
||||||
});
|
|
||||||
expect(typeof crumb?.at).toBe("number");
|
|
||||||
// Cleared on read → a second take returns null.
|
|
||||||
expect(takeReloadBreadcrumb()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("takeReloadBreadcrumb returns null when nothing was recorded", () => {
|
|
||||||
expect(takeReloadBreadcrumb()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("recordReloadBreadcrumb swallows a storage-write error (diagnostics only)", () => {
|
|
||||||
vi.stubGlobal("sessionStorage", {
|
|
||||||
getItem: () => null,
|
|
||||||
setItem: () => {
|
|
||||||
throw new Error("storage disabled");
|
|
||||||
},
|
|
||||||
removeItem: () => {},
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
expect(() =>
|
|
||||||
recordReloadBreadcrumb({ path: "chunk-boundary" }),
|
|
||||||
).not.toThrow();
|
|
||||||
} finally {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// The pure window gate replaces the old one-shot flag: it must permit recovery
|
|
||||||
// across several deploys in one tab (each > window apart) while still stopping an
|
|
||||||
// infinite reload loop when a lazy chunk is permanently broken (a second failure
|
|
||||||
// < window). Moved here from the chunk-load boundary now that it is the shared
|
|
||||||
// guard both paths route through.
|
|
||||||
describe("shouldAutoReload", () => {
|
|
||||||
const WINDOW = RELOAD_WINDOW_MS;
|
|
||||||
|
|
||||||
it("allows a reload when we have never auto-reloaded", () => {
|
|
||||||
expect(shouldAutoReload(NOW, null, WINDOW)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows a reload when the last one was 6 minutes ago (outside the window)", () => {
|
|
||||||
expect(shouldAutoReload(NOW, NOW - 6 * 60 * 1000, WINDOW)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("blocks a reload when the last one was 1 minute ago (inside the window)", () => {
|
|
||||||
expect(shouldAutoReload(NOW, NOW - 1 * 60 * 1000, WINDOW)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("blocks a reload exactly at the window boundary (not strictly older)", () => {
|
|
||||||
expect(shouldAutoReload(NOW, NOW - WINDOW, WINDOW)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows a reload when the stored timestamp is unparseable (NaN)", () => {
|
|
||||||
expect(shouldAutoReload(NOW, NaN, WINDOW)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
// Shared, window-based auto-reload budget.
|
|
||||||
//
|
|
||||||
// Both auto-reload paths — the reactive chunk-load-error-boundary (recovers
|
|
||||||
// AFTER a stale lazy chunk 404s) and the proactive version-coherence feature
|
|
||||||
// (reloads BEFORE the tab hits a stale chunk) — go through these functions so
|
|
||||||
// they share ONE window-scoped reload budget: at most a single automatic
|
|
||||||
// reload per RELOAD_WINDOW_MS across BOTH paths. A window (rather than a
|
|
||||||
// permanent one-shot flag) lets a SECOND deploy in the same tab's lifetime
|
|
||||||
// recover too, while a permanent skew, node oscillation, or a genuinely-missing
|
|
||||||
// chunk still degrades to a manual banner/UI after the first reload instead of
|
|
||||||
// looping. When sessionStorage is unavailable every mismatch degrades to the
|
|
||||||
// manual UI — no unguarded reload.
|
|
||||||
|
|
||||||
// sessionStorage key holding the epoch-ms timestamp of the last automatic reload
|
|
||||||
// (shared by both paths).
|
|
||||||
const RELOAD_AT_KEY = "chunk-reload-at";
|
|
||||||
|
|
||||||
// Allow at most one automatic reload per this window. A stale-deploy 404 is cured
|
|
||||||
// by a single reload, so anything inside the window is treated as a reload loop
|
|
||||||
// (permanently-broken chunk / permanent skew) and falls through to the manual UI.
|
|
||||||
export const RELOAD_WINDOW_MS = 5 * 60 * 1000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure window decision, unit-tested in isolation: auto-reload only if we have
|
|
||||||
* never auto-reloaded (lastReloadAt null/NaN) or the last one was strictly older
|
|
||||||
* than the window. Anything inside the window is suppressed to break an infinite
|
|
||||||
* reload loop.
|
|
||||||
*/
|
|
||||||
export function shouldAutoReload(
|
|
||||||
now: number,
|
|
||||||
lastReloadAt: number | null,
|
|
||||||
windowMs: number,
|
|
||||||
): boolean {
|
|
||||||
if (lastReloadAt === null || Number.isNaN(lastReloadAt)) return true;
|
|
||||||
return now - lastReloadAt > windowMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Has an automatic reload already happened within the current window (so the
|
|
||||||
* shared budget is spent right now)? Both paths check this before reloading; a
|
|
||||||
* `true` return means fall through to the manual banner/UI instead of reloading.
|
|
||||||
*
|
|
||||||
* A storage read error (private mode / disabled) is reported as `true` so the
|
|
||||||
* caller fails toward NOT reloading — an unguarded loop is worse than a stale
|
|
||||||
* tab the user can reload manually. Note a window (not a permanent flag): once
|
|
||||||
* the window elapses a later deploy's mismatch is allowed to reload again.
|
|
||||||
*/
|
|
||||||
export function hasAutoReloaded(now: number = Date.now()): boolean {
|
|
||||||
try {
|
|
||||||
const raw = sessionStorage.getItem(RELOAD_AT_KEY);
|
|
||||||
const lastReloadAt = raw === null ? null : Number.parseInt(raw, 10);
|
|
||||||
return !shouldAutoReload(now, lastReloadAt, RELOAD_WINDOW_MS);
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stamp the shared window as consumed now — record that an automatic reload is
|
|
||||||
* being performed within the current RELOAD_WINDOW_MS window.
|
|
||||||
*
|
|
||||||
* Returns whether the write succeeded. A `false` return (storage unavailable)
|
|
||||||
* means the caller MUST NOT reload — otherwise the stamp would never stick and
|
|
||||||
* the reload could loop.
|
|
||||||
*/
|
|
||||||
export function markAutoReloaded(now: number = Date.now()): boolean {
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem(RELOAD_AT_KEY, String(now));
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Diagnostic breadcrumb for an automatic reload. Written right before
|
|
||||||
// window.location.reload() (which clears the console) and read back on the next
|
|
||||||
// page load, so a "the tab reloaded itself / it's looping" field report is
|
|
||||||
// diagnosable: which path fired (proactive version-coherence vs the reactive
|
|
||||||
// chunk-load boundary) and which version pair triggered it. sessionStorage
|
|
||||||
// survives a same-tab reload, unlike the console.
|
|
||||||
const RELOAD_BREADCRUMB_KEY = "reload-breadcrumb";
|
|
||||||
|
|
||||||
export type ReloadBreadcrumb = {
|
|
||||||
path: "proactive" | "chunk-boundary";
|
|
||||||
serverVersion?: string;
|
|
||||||
clientVersion?: string;
|
|
||||||
at: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persist a best-effort breadcrumb just before an automatic reload. Failures
|
|
||||||
* (storage unavailable) are swallowed — this is diagnostics only and must never
|
|
||||||
* block or alter the reload decision.
|
|
||||||
*/
|
|
||||||
export function recordReloadBreadcrumb(
|
|
||||||
entry: Omit<ReloadBreadcrumb, "at">,
|
|
||||||
): void {
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem(
|
|
||||||
RELOAD_BREADCRUMB_KEY,
|
|
||||||
JSON.stringify({ ...entry, at: Date.now() }),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// best-effort diagnostics only
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read and clear the breadcrumb left by an auto-reload in the previous page
|
|
||||||
* load. Cleared on read so it surfaces exactly once per reload.
|
|
||||||
*/
|
|
||||||
export function takeReloadBreadcrumb(): ReloadBreadcrumb | null {
|
|
||||||
try {
|
|
||||||
const raw = sessionStorage.getItem(RELOAD_BREADCRUMB_KEY);
|
|
||||||
if (!raw) return null;
|
|
||||||
sessionStorage.removeItem(RELOAD_BREADCRUMB_KEY);
|
|
||||||
return JSON.parse(raw) as ReloadBreadcrumb;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { defineConfig, loadEnv, type Plugin } from "vite";
|
import { defineConfig, loadEnv } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { compression } from "vite-plugin-compression2";
|
import { compression } from "vite-plugin-compression2";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import * as fs from "node:fs";
|
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
|
|
||||||
const envPath = path.resolve(process.cwd(), "..", "..");
|
const envPath = path.resolve(process.cwd(), "..", "..");
|
||||||
@@ -25,32 +24,7 @@ function resolveAppVersion(cwd: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit <outDir>/version.json = { "version": appVersion } so the server can read
|
|
||||||
// the exact same build id the bundle was compiled with. The value is the SAME
|
|
||||||
// `appVersion` fed into `define.APP_VERSION`, so version.json and the baked-in
|
|
||||||
// global are identical by construction — the single source of truth (no
|
|
||||||
// runtime-env second copy that could drift and cause a false version mismatch).
|
|
||||||
function versionJsonPlugin(version: string): Plugin {
|
|
||||||
let outDir = "dist";
|
|
||||||
return {
|
|
||||||
name: "emit-version-json",
|
|
||||||
apply: "build",
|
|
||||||
configResolved(config) {
|
|
||||||
outDir = config.build.outDir;
|
|
||||||
},
|
|
||||||
writeBundle() {
|
|
||||||
const root = path.resolve(process.cwd(), outDir);
|
|
||||||
fs.mkdirSync(root, { recursive: true });
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(root, "version.json"),
|
|
||||||
JSON.stringify({ version }),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const appVersion = resolveAppVersion(envPath);
|
|
||||||
const {
|
const {
|
||||||
APP_URL,
|
APP_URL,
|
||||||
FILE_UPLOAD_SIZE_LIMIT,
|
FILE_UPLOAD_SIZE_LIMIT,
|
||||||
@@ -78,11 +52,10 @@ export default defineConfig(({ mode }) => {
|
|||||||
POSTHOG_HOST,
|
POSTHOG_HOST,
|
||||||
POSTHOG_KEY,
|
POSTHOG_KEY,
|
||||||
},
|
},
|
||||||
APP_VERSION: JSON.stringify(appVersion),
|
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
versionJsonPlugin(appVersion),
|
|
||||||
// Emit .br and .gz next to every built asset so the server can serve the
|
// Emit .br and .gz next to every built asset so the server can serve the
|
||||||
// precompressed copy (see @fastify/static preCompressed in static.module.ts).
|
// precompressed copy (see @fastify/static preCompressed in static.module.ts).
|
||||||
compression({
|
compression({
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import { join } from 'path';
|
|
||||||
import * as fs from 'node:fs';
|
|
||||||
import * as os from 'node:os';
|
|
||||||
import { readClientBuildVersion } from './client-version';
|
|
||||||
|
|
||||||
describe('readClientBuildVersion', () => {
|
|
||||||
let dir: string;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
dir = fs.mkdtempSync(join(os.tmpdir(), 'client-version-'));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
const writeVersionJson = (content: string) =>
|
|
||||||
fs.writeFileSync(join(dir, 'version.json'), content);
|
|
||||||
|
|
||||||
it('returns the version from a valid version.json', () => {
|
|
||||||
writeVersionJson(JSON.stringify({ version: 'test-A' }));
|
|
||||||
expect(readClientBuildVersion(dir)).toBe('test-A');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('trims surrounding whitespace in the version', () => {
|
|
||||||
writeVersionJson(JSON.stringify({ version: ' v1.2.3 ' }));
|
|
||||||
expect(readClientBuildVersion(dir)).toBe('v1.2.3');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns "" when version.json is missing', () => {
|
|
||||||
expect(readClientBuildVersion(dir)).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns "" on malformed JSON', () => {
|
|
||||||
writeVersionJson('{ not json');
|
|
||||||
expect(readClientBuildVersion(dir)).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns "" when the version field is absent', () => {
|
|
||||||
writeVersionJson(JSON.stringify({ notVersion: 'x' }));
|
|
||||||
expect(readClientBuildVersion(dir)).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns "" when the version field is not a string', () => {
|
|
||||||
writeVersionJson(JSON.stringify({ version: 123 }));
|
|
||||||
expect(readClientBuildVersion(dir)).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns "" when the path does not exist at all', () => {
|
|
||||||
expect(readClientBuildVersion(join(dir, 'nope'))).toBe('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { join } from 'path';
|
|
||||||
import * as fs from 'node:fs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the absolute path to the built client bundle directory
|
|
||||||
* (`apps/client/dist`) shipped into the runtime image.
|
|
||||||
*
|
|
||||||
* The `../` depth is anchored on THIS module's compiled location
|
|
||||||
* (`dist/common/helpers`). `integrations/static` sits at the same depth under
|
|
||||||
* the compiled root, so both callers (StaticModule and readClientBuildVersion)
|
|
||||||
* MUST share this single helper rather than duplicating the depth — a copy in a
|
|
||||||
* module at a different depth would silently resolve to the wrong directory.
|
|
||||||
*/
|
|
||||||
export function resolveClientDistPath(): string {
|
|
||||||
return join(__dirname, '..', '..', '..', '..', 'client/dist');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the build version the client bundle was compiled with, from
|
|
||||||
* `<clientDistPath>/version.json` (written by the Vite build — the single
|
|
||||||
* source of truth shared by the baked-in `APP_VERSION` global and this file).
|
|
||||||
*
|
|
||||||
* Fail-safe: any error (missing file, unreadable, bad JSON, non-string
|
|
||||||
* version) yields `''`. The caller treats an empty version as "unknown" and
|
|
||||||
* the whole version-coherence feature stays silently inert — existing deploys
|
|
||||||
* without the file keep working unchanged.
|
|
||||||
*/
|
|
||||||
export function readClientBuildVersion(clientDistPath: string): string {
|
|
||||||
try {
|
|
||||||
const raw = fs.readFileSync(join(clientDistPath, 'version.json'), 'utf8');
|
|
||||||
const version = (JSON.parse(raw) as { version?: unknown }).version;
|
|
||||||
return typeof version === 'string' ? version.trim() : '';
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,4 +3,3 @@ export * from './nanoid.utils';
|
|||||||
export * from './file.helper';
|
export * from './file.helper';
|
||||||
export * from './constants';
|
export * from './constants';
|
||||||
export * from './security-headers';
|
export * from './security-headers';
|
||||||
export * from './client-version';
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
||||||
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||||
|
import { AiChatRunStepRepo } from '@docmost/db/repos/ai-chat/ai-chat-run-step.repo';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard';
|
import { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard';
|
||||||
import { AI_CHAT_THROTTLER } from '../../integrations/throttle/throttler-names';
|
import { AI_CHAT_THROTTLER } from '../../integrations/throttle/throttler-names';
|
||||||
@@ -43,6 +44,8 @@ import {
|
|||||||
AiChatRunHooks,
|
AiChatRunHooks,
|
||||||
AiChatService,
|
AiChatService,
|
||||||
AiChatStreamBody,
|
AiChatStreamBody,
|
||||||
|
rowHasInlineParts,
|
||||||
|
hydrateAssistantParts,
|
||||||
} from './ai-chat.service';
|
} from './ai-chat.service';
|
||||||
import { AiChatRunService } from './ai-chat-run.service';
|
import { AiChatRunService } from './ai-chat-run.service';
|
||||||
import { AiTranscriptionService } from './ai-transcription.service';
|
import { AiTranscriptionService } from './ai-transcription.service';
|
||||||
@@ -129,8 +132,39 @@ export class AiChatController {
|
|||||||
// production. Only touched on the resumable-stream (flag-on) path.
|
// production. Only touched on the resumable-stream (flag-on) path.
|
||||||
private readonly streamRegistry?: AiChatStreamRegistryService,
|
private readonly streamRegistry?: AiChatStreamRegistryService,
|
||||||
private readonly environment?: EnvironmentService,
|
private readonly environment?: EnvironmentService,
|
||||||
|
// #492: reconstruct a #492 mid-run record's parts from the steps table before
|
||||||
|
// returning rows to the client / export. OPTIONAL so positional controller
|
||||||
|
// specs compile unchanged; when absent, hydration is skipped (old-era rows
|
||||||
|
// already carry inline parts, so nothing to reconstruct).
|
||||||
|
private readonly aiChatRunStepRepo?: AiChatRunStepRepo,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstruct parts for any assistant rows that don't carry them INLINE — a
|
||||||
|
* #492 mid-run record whose per-step parts live in `ai_chat_run_steps` (the
|
||||||
|
* append-persist backend). Every FINISHED row (old-era + #492) and every old-era
|
||||||
|
* streaming snapshot already has inline `metadata.parts`, so the common path
|
||||||
|
* fetches NOTHING and returns the rows untouched; only an actively-streaming
|
||||||
|
* new-style row triggers the batch step fetch. Consumers (seed/poll/export) read
|
||||||
|
* `metadata.parts` off the returned rows exactly as before — the era switch is
|
||||||
|
* invisible to them (reconstructRunParts contract).
|
||||||
|
*/
|
||||||
|
private async withReconstructedParts(
|
||||||
|
rows: AiChatMessage[],
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<AiChatMessage[]> {
|
||||||
|
if (!this.aiChatRunStepRepo) return rows;
|
||||||
|
const needy = rows.filter(
|
||||||
|
(r) => r.role === 'assistant' && !rowHasInlineParts(r),
|
||||||
|
);
|
||||||
|
if (needy.length === 0) return rows;
|
||||||
|
const stepsByMessage = await this.aiChatRunStepRepo.findByMessageIds(
|
||||||
|
needy.map((r) => r.id),
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
return hydrateAssistantParts(rows, stepsByMessage);
|
||||||
|
}
|
||||||
|
|
||||||
/** List the requesting user's chats in this workspace (paginated). */
|
/** List the requesting user's chats in this workspace (paginated). */
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('chats')
|
@Post('chats')
|
||||||
@@ -184,11 +218,17 @@ export class AiChatController {
|
|||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
await this.assertOwnedChat(dto.chatId, user, workspace);
|
await this.assertOwnedChat(dto.chatId, user, workspace);
|
||||||
return this.aiChatMessageRepo.findByChat(
|
const page = await this.aiChatMessageRepo.findByChat(
|
||||||
dto.chatId,
|
dto.chatId,
|
||||||
workspace.id,
|
workspace.id,
|
||||||
pagination,
|
pagination,
|
||||||
);
|
);
|
||||||
|
// #492: reconstruct parts for any active new-style row so the client seed sees
|
||||||
|
// `metadata.parts` unchanged (a no-op for the finished rows that fill a page).
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
items: await this.withReconstructedParts(page.items, workspace.id),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -225,7 +265,10 @@ export class AiChatController {
|
|||||||
workspace.id,
|
workspace.id,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
rows,
|
// #492: the delta of an actively-streaming new-style row carries its parts
|
||||||
|
// reconstructed from the steps table, so the degraded poll shows persisted
|
||||||
|
// progress exactly as the pre-#492 full-row snapshot did.
|
||||||
|
rows: await this.withReconstructedParts(rows, workspace.id),
|
||||||
cursor,
|
cursor,
|
||||||
run: run ? { id: run.id, status: run.status } : null,
|
run: run ? { id: run.id, status: run.status } : null,
|
||||||
};
|
};
|
||||||
@@ -247,8 +290,10 @@ export class AiChatController {
|
|||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
): Promise<{ markdown: string }> {
|
): Promise<{ markdown: string }> {
|
||||||
const chat = await this.assertOwnedChat(dto.chatId, user, workspace);
|
const chat = await this.assertOwnedChat(dto.chatId, user, workspace);
|
||||||
const rows = await this.aiChatMessageRepo.findAllByChat(
|
const rows = await this.withReconstructedParts(
|
||||||
dto.chatId,
|
await this.aiChatMessageRepo.findAllByChat(dto.chatId, workspace.id),
|
||||||
|
// #492: an interrupted-but-still-active turn exports its persisted steps
|
||||||
|
// (reconstructed from the steps table) just like the pre-#492 full row did.
|
||||||
workspace.id,
|
workspace.id,
|
||||||
);
|
);
|
||||||
const markdown = buildChatMarkdown({
|
const markdown = buildChatMarkdown({
|
||||||
@@ -288,7 +333,13 @@ export class AiChatController {
|
|||||||
workspace.id,
|
workspace.id,
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
return { run, message: message ?? null };
|
// #492: reconnect to an IN-FLIGHT run reconstructs the projection row's parts
|
||||||
|
// from the steps table (the row itself carries only the step marker mid-run);
|
||||||
|
// a finished run's row already has inline parts, so this is a no-op.
|
||||||
|
const [hydrated] = message
|
||||||
|
? await this.withReconstructedParts([message], workspace.id)
|
||||||
|
: [undefined];
|
||||||
|
return { run, message: hydrated ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { AiSettingsService } from '../../integrations/ai/ai-settings.service';
|
|||||||
import { describeProviderError } from '../../integrations/ai/ai-error.util';
|
import { describeProviderError } from '../../integrations/ai/ai-error.util';
|
||||||
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
||||||
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||||
|
import { AiChatRunStepRepo } from '@docmost/db/repos/ai-chat/ai-chat-run-step.repo';
|
||||||
import { AiChatPageSnapshotRepo } from '@docmost/db/repos/ai-chat/ai-chat-page-snapshot.repo';
|
import { AiChatPageSnapshotRepo } from '@docmost/db/repos/ai-chat/ai-chat-page-snapshot.repo';
|
||||||
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
|
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
@@ -518,6 +519,12 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
|
|||||||
// constructions compile unchanged; Nest always injects the real singleton, so
|
// constructions compile unchanged; Nest always injects the real singleton, so
|
||||||
// reconcile sees the SAME in-memory active/zombie maps the runner mutates.
|
// reconcile sees the SAME in-memory active/zombie maps the runner mutates.
|
||||||
private readonly aiChatRunService?: AiChatRunService,
|
private readonly aiChatRunService?: AiChatRunService,
|
||||||
|
// #492 append-persist: per-step INSERT into the lightweight steps table (the
|
||||||
|
// O(Σ steps) replacement for the O(n²) full-row `metadata.parts` rewrite).
|
||||||
|
// OPTIONAL so existing positional constructions (int-specs) compile unchanged;
|
||||||
|
// Nest injects the real singleton. When ABSENT the per-step path falls back to
|
||||||
|
// the pre-#492 full-row flush (no regression, only no WAL win).
|
||||||
|
private readonly aiChatRunStepRepo?: AiChatRunStepRepo,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// #487: periodic reconcile timer (single-process phase 1). Started in
|
// #487: periodic reconcile timer (single-process phase 1). Started in
|
||||||
@@ -1114,8 +1121,34 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
|
|||||||
chatId,
|
chatId,
|
||||||
workspace.id,
|
workspace.id,
|
||||||
);
|
);
|
||||||
|
// #492: HYDRATE needy assistant rows from the steps table BEFORE the replay
|
||||||
|
// map. A #492 mid-run assistant row carries only a step marker
|
||||||
|
// (metadata.parts:[]); its real per-step parts live in `ai_chat_run_steps`.
|
||||||
|
// The graceful terminal callbacks (onFinish/onError/onAbort -> flushAssistant)
|
||||||
|
// assemble the full inline parts, so a normally-ended turn already has them.
|
||||||
|
// But a HARD crash mid-run (SIGKILL/OOM) fires NO terminal callback, so the
|
||||||
|
// row stays parts:[]; without this, rowToUiMessage falls back to an empty
|
||||||
|
// text part and the partial tool-calls/results/text — durable in the steps
|
||||||
|
// table — would DROP OUT of the model's replay context (regressing #183
|
||||||
|
// step-granular durability for the model consumer). Mirrors the controller's
|
||||||
|
// withReconstructedParts EXACTLY (same needy predicate + hydration helper).
|
||||||
|
// Guarded on the optional repo: absent (positional test builds) degrades to
|
||||||
|
// the current behavior rather than crashing.
|
||||||
|
let replayHistory = oldHistory;
|
||||||
|
if (this.aiChatRunStepRepo) {
|
||||||
|
const needy = oldHistory.filter(
|
||||||
|
(r) => r.role === 'assistant' && !rowHasInlineParts(r),
|
||||||
|
);
|
||||||
|
if (needy.length > 0) {
|
||||||
|
const stepsByMessage = await this.aiChatRunStepRepo.findByMessageIds(
|
||||||
|
needy.map((r) => r.id),
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
replayHistory = hydrateAssistantParts(oldHistory, stepsByMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
const uiMessages: Array<Omit<UIMessage, 'id'> & { id: string }> = [
|
const uiMessages: Array<Omit<UIMessage, 'id'> & { id: string }> = [
|
||||||
...oldHistory.map(rowToUiMessage),
|
...replayHistory.map(rowToUiMessage),
|
||||||
{
|
{
|
||||||
id: 'pending-user',
|
id: 'pending-user',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@@ -1154,7 +1187,9 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
|
|||||||
// hint — confirm it against the persisted history (the preceding assistant
|
// hint — confirm it against the persisted history (the preceding assistant
|
||||||
// turn must really be aborted/streaming) so a spoofed flag cannot inject the
|
// turn must really be aborted/streaming) so a spoofed flag cannot inject the
|
||||||
// interrupt note onto an ordinary turn. The partial output the model needs is
|
// interrupt note onto an ordinary turn. The partial output the model needs is
|
||||||
// already in `messages` (the aborted assistant row replays via findRecent).
|
// already in `messages`: a #492 mid-run row's per-step parts live only in the
|
||||||
|
// `ai_chat_run_steps` table and were hydrated into the replay history above,
|
||||||
|
// so the aborted assistant turn replays WITH its partial parts intact.
|
||||||
// Append the new user turn (shape-only) so index -2 is the prior assistant.
|
// Append the new user turn (shape-only) so index -2 is the prior assistant.
|
||||||
const interrupted = isInterruptResume(
|
const interrupted = isInterruptResume(
|
||||||
[...oldHistory, { role: 'user', status: null, metadata: null }],
|
[...oldHistory, { role: 'user', status: null, metadata: null }],
|
||||||
@@ -1559,17 +1594,57 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
|
|||||||
// connection when finalize runs, so the SQL `WHERE status='streaming'`
|
// connection when finalize runs, so the SQL `WHERE status='streaming'`
|
||||||
// (not this flag) is what prevents it clobbering the terminal row.
|
// (not this flag) is what prevents it clobbering the terminal row.
|
||||||
if (finalized) return null;
|
if (finalized) return null;
|
||||||
// Build the flush ONCE so the returned count is EXACTLY the persisted
|
// The count derives from capturedSteps.length at THIS instant, so the
|
||||||
// `stepsPersisted` (both derive from capturedSteps.length at this instant).
|
// returned value is EXACTLY the persisted `stepsPersisted` the ring rotates
|
||||||
const flushed = flushAssistant(capturedSteps, '', 'streaming', {
|
// on (whether we take the append-persist path or the legacy fallback).
|
||||||
pageChanged,
|
const stepsPersisted = capturedSteps.length;
|
||||||
partsCache,
|
|
||||||
});
|
|
||||||
const stepsPersisted = flushed.metadata.stepsPersisted as number;
|
|
||||||
try {
|
try {
|
||||||
await this.aiChatMessageRepo.update(assistantId, workspace.id, flushed, {
|
if (this.aiChatRunStepRepo) {
|
||||||
onlyIfStreaming: true,
|
// #492 APPEND-PERSIST: write only THIS finished step's parts to the
|
||||||
});
|
// steps table (O(step) WAL), then bump the row's CHEAP step marker —
|
||||||
|
// NO growing `metadata.parts` blob (that O(n²) full-row rewrite is
|
||||||
|
// exactly what this removes). The full `metadata.parts` is assembled
|
||||||
|
// once at finalize; a mid-run resume seed is reconstructed from the
|
||||||
|
// step rows (reconstructRunParts). The INSERT is idempotent
|
||||||
|
// (ON CONFLICT DO NOTHING), so a re-fired step never doubles the parts.
|
||||||
|
const index = stepsPersisted - 1;
|
||||||
|
if (index >= 0) {
|
||||||
|
const stepParts = assistantParts(
|
||||||
|
[capturedSteps[index]],
|
||||||
|
'',
|
||||||
|
partsCache,
|
||||||
|
);
|
||||||
|
await this.aiChatRunStepRepo.insertStep(
|
||||||
|
assistantId,
|
||||||
|
workspace.id,
|
||||||
|
index,
|
||||||
|
stepParts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Marker UPDATE: advance stepsPersisted + keep the toolTrace era marker
|
||||||
|
// (bumps updatedAt so the delta poll observes the step, and carries the
|
||||||
|
// frontier a resuming client attaches from). Scoped onlyIfStreaming so a
|
||||||
|
// late marker never clobbers the terminal finalize.
|
||||||
|
await this.aiChatMessageRepo.update(
|
||||||
|
assistantId,
|
||||||
|
workspace.id,
|
||||||
|
{ metadata: stepMarkerMetadata(stepsPersisted) },
|
||||||
|
{ onlyIfStreaming: true },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Legacy fallback (no steps table wired — positional test builds): the
|
||||||
|
// pre-#492 full-row flush, so parts still land inline on the row.
|
||||||
|
const flushed = flushAssistant(capturedSteps, '', 'streaming', {
|
||||||
|
pageChanged,
|
||||||
|
partsCache,
|
||||||
|
});
|
||||||
|
await this.aiChatMessageRepo.update(
|
||||||
|
assistantId,
|
||||||
|
workspace.id,
|
||||||
|
flushed,
|
||||||
|
{ onlyIfStreaming: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
return stepsPersisted;
|
return stepsPersisted;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -2749,6 +2824,122 @@ export function rowToUiMessage(row: AiChatMessage): Omit<UIMessage, 'id'> & {
|
|||||||
return { id: row.id, role, parts: parts as UIMessage['parts'] };
|
return { id: row.id, role, parts: parts as UIMessage['parts'] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cheap step-marker metadata for the #492 per-step UPDATE. Advances
|
||||||
|
* `stepsPersisted` (the resume attach frontier) and keeps the `toolTraceVersion`
|
||||||
|
* era marker, WITHOUT the growing `parts` blob (those live in the steps table
|
||||||
|
* now; the full `metadata.parts` is assembled once at finalize by flushAssistant).
|
||||||
|
* `parts: []` is kept for shape stability — it reads as an empty inline-parts row,
|
||||||
|
* which is exactly the discriminator that routes reconstruction to the steps table.
|
||||||
|
*/
|
||||||
|
export function stepMarkerMetadata(
|
||||||
|
stepsPersisted: number,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return { parts: [], toolTraceVersion: 2, stepsPersisted };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an assistant row already carries its full UI parts INLINE on the row
|
||||||
|
* (`metadata.parts`). TRUE for every FINISHED row — old-era rows AND #492 rows,
|
||||||
|
* whose full parts are assembled once at finalize — and for old-era streaming
|
||||||
|
* snapshots (the pre-#492 per-step full-row flush). FALSE for a #492 MID-RUN
|
||||||
|
* record, whose per-step parts live in the `ai_chat_run_steps` table. This is the
|
||||||
|
* era discriminator the reconstruct seam branches on — no schema flag needed.
|
||||||
|
*/
|
||||||
|
export function rowHasInlineParts(row: { metadata?: unknown }): boolean {
|
||||||
|
const meta = (row.metadata ?? {}) as { parts?: unknown };
|
||||||
|
return Array.isArray(meta.parts) && meta.parts.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concatenate persisted per-step parts (in `stepIndex` order) into the turn's UI
|
||||||
|
* parts (#492). Reproduces EXACTLY what flushAssistant → assistantParts would have
|
||||||
|
* written to `metadata.parts` for those finished steps, since each step row stored
|
||||||
|
* `assistantParts([step])` at persist time.
|
||||||
|
*/
|
||||||
|
export function assembleStepParts(
|
||||||
|
stepRows: ReadonlyArray<{ stepIndex: number; parts: unknown }>,
|
||||||
|
): UIMessage['parts'] {
|
||||||
|
const parts: Array<Record<string, unknown>> = [];
|
||||||
|
for (const step of [...stepRows].sort((a, b) => a.stepIndex - b.stepIndex)) {
|
||||||
|
if (Array.isArray(step.parts)) {
|
||||||
|
parts.push(...(step.parts as Array<Record<string, unknown>>));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts as UIMessage['parts'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* reconstructRunParts (#492) — the single backend-switch seam. Given an assistant
|
||||||
|
* ROW and its persisted step rows, return the turn's UI `parts` + the persisted
|
||||||
|
* step count, reading from the ROW when it already carries inline parts (old-era
|
||||||
|
* records AND every finished record) and from the STEPS TABLE otherwise (a #492
|
||||||
|
* mid-run record). The higher-level consumers (attach seed, delta poll, export)
|
||||||
|
* route their row→parts through this / {@link hydrateAssistantParts}, so old and
|
||||||
|
* new records reconstruct identically WITHOUT the consumers branching on the era.
|
||||||
|
*/
|
||||||
|
export function reconstructRunParts(
|
||||||
|
row: { metadata?: unknown; content?: string | null },
|
||||||
|
stepRows: ReadonlyArray<{ stepIndex: number; parts: unknown }>,
|
||||||
|
): { parts: UIMessage['parts']; stepsPersisted: number } {
|
||||||
|
if (rowHasInlineParts(row)) {
|
||||||
|
const meta = row.metadata as {
|
||||||
|
parts: UIMessage['parts'];
|
||||||
|
stepsPersisted?: number;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
parts: meta.parts,
|
||||||
|
stepsPersisted:
|
||||||
|
typeof meta.stepsPersisted === 'number'
|
||||||
|
? meta.stepsPersisted
|
||||||
|
: stepRows.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (stepRows.length > 0) {
|
||||||
|
return {
|
||||||
|
parts: assembleStepParts(stepRows),
|
||||||
|
stepsPersisted: stepRows.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// No inline parts and no step rows: an old-era seed / empty streaming row. Fall
|
||||||
|
// back to a single text part from `content` (mirrors rowToUiMessage).
|
||||||
|
return {
|
||||||
|
parts: textPart(row.content ?? '') as UIMessage['parts'],
|
||||||
|
stepsPersisted: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill each assistant row's `metadata.parts` from its step rows when the row does
|
||||||
|
* not already carry them inline (a #492 mid-run record), so a consumer that reads
|
||||||
|
* `metadata.parts` off the RAW row (the client seed/poll, the Markdown export)
|
||||||
|
* sees the reconstructed parts with NO change to itself. Rows that already have
|
||||||
|
* inline parts (old-era + finished) and non-assistant rows pass through untouched.
|
||||||
|
* Pure: returns new row objects, never mutates the inputs.
|
||||||
|
*/
|
||||||
|
export function hydrateAssistantParts<
|
||||||
|
T extends { id: string; role?: string; metadata?: unknown },
|
||||||
|
>(
|
||||||
|
rows: ReadonlyArray<T>,
|
||||||
|
stepsByMessage: Map<
|
||||||
|
string,
|
||||||
|
ReadonlyArray<{ stepIndex: number; parts: unknown }>
|
||||||
|
>,
|
||||||
|
): T[] {
|
||||||
|
return rows.map((row) => {
|
||||||
|
if (row.role !== 'assistant' || rowHasInlineParts(row)) return row;
|
||||||
|
const steps = stepsByMessage.get(row.id);
|
||||||
|
if (!steps || steps.length === 0) return row;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
metadata: {
|
||||||
|
...((row.metadata ?? {}) as Record<string, unknown>),
|
||||||
|
parts: assembleStepParts(steps),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The persisted-row patch shape produced by {@link flushAssistant}. It is the
|
* The persisted-row patch shape produced by {@link flushAssistant}. It is the
|
||||||
* SAME shape the assistant repo insert/update consume (content + toolCalls +
|
* SAME shape the assistant repo insert/update consume (content + toolCalls +
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { TemplateRepo } from '@docmost/db/repos/template/template.repo';
|
|||||||
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
||||||
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||||
import { AiChatRunRepo } from '@docmost/db/repos/ai-chat/ai-chat-run.repo';
|
import { AiChatRunRepo } from '@docmost/db/repos/ai-chat/ai-chat-run.repo';
|
||||||
|
import { AiChatRunStepRepo } from '@docmost/db/repos/ai-chat/ai-chat-run-step.repo';
|
||||||
import { AiChatPageSnapshotRepo } from '@docmost/db/repos/ai-chat/ai-chat-page-snapshot.repo';
|
import { AiChatPageSnapshotRepo } from '@docmost/db/repos/ai-chat/ai-chat-page-snapshot.repo';
|
||||||
import { AiProviderCredentialsRepo } from '@docmost/db/repos/ai-chat/ai-provider-credentials.repo';
|
import { AiProviderCredentialsRepo } from '@docmost/db/repos/ai-chat/ai-provider-credentials.repo';
|
||||||
import { AiMcpServerRepo } from '@docmost/db/repos/ai-chat/ai-mcp-server.repo';
|
import { AiMcpServerRepo } from '@docmost/db/repos/ai-chat/ai-mcp-server.repo';
|
||||||
@@ -125,6 +126,7 @@ import { firstSqlToken } from '../integrations/metrics/metrics.constants';
|
|||||||
AiChatRepo,
|
AiChatRepo,
|
||||||
AiChatMessageRepo,
|
AiChatMessageRepo,
|
||||||
AiChatRunRepo,
|
AiChatRunRepo,
|
||||||
|
AiChatRunStepRepo,
|
||||||
AiChatPageSnapshotRepo,
|
AiChatPageSnapshotRepo,
|
||||||
AiProviderCredentialsRepo,
|
AiProviderCredentialsRepo,
|
||||||
AiMcpServerRepo,
|
AiMcpServerRepo,
|
||||||
@@ -161,6 +163,7 @@ import { firstSqlToken } from '../integrations/metrics/metrics.constants';
|
|||||||
AiChatRepo,
|
AiChatRepo,
|
||||||
AiChatMessageRepo,
|
AiChatMessageRepo,
|
||||||
AiChatRunRepo,
|
AiChatRunRepo,
|
||||||
|
AiChatRunStepRepo,
|
||||||
AiChatPageSnapshotRepo,
|
AiChatPageSnapshotRepo,
|
||||||
AiProviderCredentialsRepo,
|
AiProviderCredentialsRepo,
|
||||||
AiMcpServerRepo,
|
AiMcpServerRepo,
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { type Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ai_chat_run_steps` — append-only per-step persistence for an assistant turn
|
||||||
|
* (#492 wave C). Each finished agent step's UI `parts` (its text part + a part
|
||||||
|
* per tool call, WITH the tool output) is INSERTed as its own lightweight row the
|
||||||
|
* moment the step ends, instead of REWRITING the whole assistant row's growing
|
||||||
|
* `metadata.parts` jsonb on every `onStepFinish`.
|
||||||
|
*
|
||||||
|
* WHY a separate table + INSERT (not a jsonb `||` append on the message row): a
|
||||||
|
* Postgres jsonb UPDATE rewrites the ENTIRE TOASTed row version under MVCC, so
|
||||||
|
* re-persisting a growing `metadata.parts` on every step is O(n²) write volume
|
||||||
|
* (a 50-step run with ~100 KB tool outputs wrote hundreds of MB of WAL / dead
|
||||||
|
* tuples per turn, hammering autovacuum). `||` would only shave the network
|
||||||
|
* payload — the WAL/TOAST rewrite harm remains. An INSERT into a per-step table
|
||||||
|
* writes ONLY that step's bytes, so the per-turn write volume is O(Σ steps).
|
||||||
|
*
|
||||||
|
* The full `metadata.parts` on the message row is assembled ONCE at finalize (the
|
||||||
|
* terminal completed/error/aborted write). Mid-run, a resuming client's seed is
|
||||||
|
* reconstructed by concatenating these step rows in `step_index` order — which
|
||||||
|
* reproduces exactly what the old per-step full-row rewrite persisted. Records
|
||||||
|
* written the OLD way (full `metadata.parts` on the row, no step rows) still
|
||||||
|
* reconstruct from the row unchanged; the two eras are distinguished by whether
|
||||||
|
* the row already carries non-empty `metadata.parts` (see reconstructRunParts /
|
||||||
|
* assembleStepParts in ai-chat.service.ts).
|
||||||
|
*
|
||||||
|
* ON DELETE CASCADE on `message_id`: the step rows are a derived projection of the
|
||||||
|
* assistant message; they must vanish with it (or with its workspace).
|
||||||
|
*/
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.createTable('ai_chat_run_steps')
|
||||||
|
.ifNotExists()
|
||||||
|
.addColumn('id', 'uuid', (col) =>
|
||||||
|
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||||
|
)
|
||||||
|
// The assistant message row this step belongs to (the #183 projection). The
|
||||||
|
// step rows are a derived, per-step slice of that message, so they cascade.
|
||||||
|
.addColumn('message_id', 'uuid', (col) =>
|
||||||
|
col.references('ai_chat_messages.id').onDelete('cascade').notNull(),
|
||||||
|
)
|
||||||
|
.addColumn('workspace_id', 'uuid', (col) =>
|
||||||
|
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||||
|
)
|
||||||
|
// 0-based index of the finished step within the turn. Ordering key for
|
||||||
|
// reconstruction; unique per message (idempotent step re-persist).
|
||||||
|
.addColumn('step_index', 'integer', (col) => col.notNull())
|
||||||
|
// The step's UI parts (text part + a `tool-*` part per call, WITH output).
|
||||||
|
// Concatenated in step order to rebuild the turn's `metadata.parts`.
|
||||||
|
.addColumn('parts', 'jsonb', (col) => col.notNull())
|
||||||
|
.addColumn('created_at', 'timestamptz', (col) =>
|
||||||
|
col.notNull().defaultTo(sql`now()`),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
// Idempotent per-step persist: a retried INSERT of the same (message, step)
|
||||||
|
// is a no-op (the service uses ON CONFLICT DO NOTHING). This also serves the
|
||||||
|
// reconstruction read (WHERE message_id ORDER BY step_index).
|
||||||
|
await db.schema
|
||||||
|
.createIndex('ai_chat_run_steps_message_step_uidx')
|
||||||
|
.ifNotExists()
|
||||||
|
.on('ai_chat_run_steps')
|
||||||
|
.columns(['message_id', 'step_index'])
|
||||||
|
.unique()
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema.dropTable('ai_chat_run_steps').ifExists().execute();
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
||||||
|
import { dbOrTx } from '../../utils';
|
||||||
|
import { AiChatRunStep } from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append-only per-step persistence for an assistant turn (#492). Each finished
|
||||||
|
* agent step's UI `parts` (its text part + a `tool-*` part per call, WITH the
|
||||||
|
* tool output) is INSERTed as its own lightweight row the moment the step ends —
|
||||||
|
* instead of REWRITING the assistant row's growing `metadata.parts` jsonb on every
|
||||||
|
* `onStepFinish` (a Postgres jsonb UPDATE rewrites the whole TOASTed row version
|
||||||
|
* under MVCC, so that was O(n²) WAL/dead-tuple churn per turn).
|
||||||
|
*
|
||||||
|
* The full `metadata.parts` on the message row is assembled ONCE at finalize;
|
||||||
|
* mid-run, a resuming client's seed is rebuilt from these rows in `stepIndex`
|
||||||
|
* order (see `assembleStepParts` / the reconstruct seam in ai-chat.service.ts).
|
||||||
|
* Every method is workspace-scoped as defense-in-depth.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AiChatRunStepRepo {
|
||||||
|
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append one finished step's parts. Idempotent: a retried persist of the SAME
|
||||||
|
* (message, stepIndex) is a no-op via ON CONFLICT DO NOTHING — the per-step
|
||||||
|
* writes are fired fire-and-forget + serialized, and a duplicate must never
|
||||||
|
* throw into the stream or double the parts. Returns whether a NEW row landed
|
||||||
|
* (false = the step was already persisted).
|
||||||
|
*/
|
||||||
|
async insertStep(
|
||||||
|
messageId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
stepIndex: number,
|
||||||
|
parts: unknown,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
const inserted = await db
|
||||||
|
.insertInto('aiChatRunSteps')
|
||||||
|
.values({
|
||||||
|
messageId,
|
||||||
|
workspaceId,
|
||||||
|
stepIndex,
|
||||||
|
// jsonb column: cast through never (same pattern as the message repo).
|
||||||
|
parts: parts as never,
|
||||||
|
})
|
||||||
|
.onConflict((oc) => oc.columns(['messageId', 'stepIndex']).doNothing())
|
||||||
|
.returning('id')
|
||||||
|
.executeTakeFirst();
|
||||||
|
return inserted !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All persisted steps for ONE assistant message, in step order. */
|
||||||
|
async findByMessage(
|
||||||
|
messageId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<AiChatRunStep[]> {
|
||||||
|
return this.db
|
||||||
|
.selectFrom('aiChatRunSteps')
|
||||||
|
.selectAll('aiChatRunSteps')
|
||||||
|
.where('messageId', '=', messageId)
|
||||||
|
.where('workspaceId', '=', workspaceId)
|
||||||
|
.orderBy('stepIndex', 'asc')
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All persisted steps for a SET of assistant messages, grouped by messageId
|
||||||
|
* (each group in step order). One query for the batch — the hydration seam
|
||||||
|
* (getMessages / delta / export) calls this only for the rows that actually
|
||||||
|
* need reconstruction (an active new-style row whose `metadata.parts` is still
|
||||||
|
* empty), which is usually none, so this is skipped on the common path.
|
||||||
|
*/
|
||||||
|
async findByMessageIds(
|
||||||
|
messageIds: string[],
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<Map<string, AiChatRunStep[]>> {
|
||||||
|
const byMessage = new Map<string, AiChatRunStep[]>();
|
||||||
|
if (messageIds.length === 0) return byMessage;
|
||||||
|
const rows = await this.db
|
||||||
|
.selectFrom('aiChatRunSteps')
|
||||||
|
.selectAll('aiChatRunSteps')
|
||||||
|
.where('messageId', 'in', messageIds)
|
||||||
|
.where('workspaceId', '=', workspaceId)
|
||||||
|
.orderBy('stepIndex', 'asc')
|
||||||
|
.execute();
|
||||||
|
for (const row of rows) {
|
||||||
|
const list = byMessage.get(row.messageId);
|
||||||
|
if (list) list.push(row);
|
||||||
|
else byMessage.set(row.messageId, [row]);
|
||||||
|
}
|
||||||
|
return byMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -692,6 +692,22 @@ export interface AiChatRuns {
|
|||||||
updatedAt: Generated<Timestamp>;
|
updatedAt: Generated<Timestamp>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Append-only per-step persistence for an assistant turn (#492). Mirrors
|
||||||
|
// migration 20260708T120000-ai-chat-run-steps.ts. Each finished agent step's UI
|
||||||
|
// `parts` are INSERTed as their own row (instead of rewriting the message row's
|
||||||
|
// growing `metadata.parts` jsonb every step — an O(n²) WAL/TOAST churn). The full
|
||||||
|
// `metadata.parts` is assembled once at finalize; mid-run a resuming client's seed
|
||||||
|
// is rebuilt by concatenating these rows in `stepIndex` order. Cascades with the
|
||||||
|
// assistant message row it projects.
|
||||||
|
export interface AiChatRunSteps {
|
||||||
|
id: Generated<string>;
|
||||||
|
messageId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
stepIndex: number;
|
||||||
|
parts: Json;
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
}
|
||||||
|
|
||||||
// Per-(chat,page) snapshot of the open page's Markdown at the END of the agent's
|
// Per-(chat,page) snapshot of the open page's Markdown at the END of the agent's
|
||||||
// previous turn (#274). Mirrors migration 20260702T120000-ai-chat-page-snapshot.ts.
|
// previous turn (#274). Mirrors migration 20260702T120000-ai-chat-page-snapshot.ts.
|
||||||
// The next turn diffs the CURRENT Markdown against `contentMd` to surface edits a
|
// The next turn diffs the CURRENT Markdown against `contentMd` to surface edits a
|
||||||
@@ -729,6 +745,7 @@ export interface DB {
|
|||||||
aiChats: AiChats;
|
aiChats: AiChats;
|
||||||
aiChatMessages: AiChatMessages;
|
aiChatMessages: AiChatMessages;
|
||||||
aiChatRuns: AiChatRuns;
|
aiChatRuns: AiChatRuns;
|
||||||
|
aiChatRunSteps: AiChatRunSteps;
|
||||||
aiChatPageSnapshots: AiChatPageSnapshots;
|
aiChatPageSnapshots: AiChatPageSnapshots;
|
||||||
apiKeys: ApiKeys;
|
apiKeys: ApiKeys;
|
||||||
attachments: Attachments;
|
attachments: Attachments;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
AiChats,
|
AiChats,
|
||||||
AiChatMessages,
|
AiChatMessages,
|
||||||
AiChatRuns,
|
AiChatRuns,
|
||||||
|
AiChatRunSteps,
|
||||||
AiChatPageSnapshots,
|
AiChatPageSnapshots,
|
||||||
Attachments,
|
Attachments,
|
||||||
Comments,
|
Comments,
|
||||||
@@ -64,6 +65,12 @@ export type InsertableAiChatMessage = Omit<Insertable<AiChatMessages>, 'tsv'>;
|
|||||||
export type AiChatRun = Selectable<AiChatRuns>;
|
export type AiChatRun = Selectable<AiChatRuns>;
|
||||||
export type InsertableAiChatRun = Insertable<AiChatRuns>;
|
export type InsertableAiChatRun = Insertable<AiChatRuns>;
|
||||||
|
|
||||||
|
// AI Chat Run Step (#492): append-only per-step parts persistence. Each finished
|
||||||
|
// agent step's UI parts are stored as their own row; the full turn's parts are
|
||||||
|
// assembled from these (in stepIndex order) for a mid-run resume seed.
|
||||||
|
export type AiChatRunStep = Selectable<AiChatRunSteps>;
|
||||||
|
export type InsertableAiChatRunStep = Insertable<AiChatRunSteps>;
|
||||||
|
|
||||||
// AI Chat Page Snapshot (#274): per-(chat,page) Markdown snapshot taken at the
|
// AI Chat Page Snapshot (#274): per-(chat,page) Markdown snapshot taken at the
|
||||||
// end of the agent's previous turn, diffed against the current page next turn to
|
// end of the agent's previous turn, diffed against the current page next turn to
|
||||||
// detect human edits made between turns.
|
// detect human edits made between turns.
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { join } from 'path';
|
|||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import { EnvironmentService } from '../environment/environment.service';
|
import { EnvironmentService } from '../environment/environment.service';
|
||||||
import { resolveClientDistPath } from '../../common/helpers/client-version';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the response headers for a statically served client asset.
|
* Resolve the response headers for a statically served client asset.
|
||||||
@@ -57,7 +56,14 @@ export class StaticModule implements OnModuleInit {
|
|||||||
const httpAdapter = this.httpAdapterHost.httpAdapter;
|
const httpAdapter = this.httpAdapterHost.httpAdapter;
|
||||||
const app = httpAdapter.getInstance();
|
const app = httpAdapter.getInstance();
|
||||||
|
|
||||||
const clientDistPath = resolveClientDistPath();
|
const clientDistPath = join(
|
||||||
|
__dirname,
|
||||||
|
'..',
|
||||||
|
'..',
|
||||||
|
'..',
|
||||||
|
'..',
|
||||||
|
'client/dist',
|
||||||
|
);
|
||||||
|
|
||||||
const indexFilePath = join(clientDistPath, 'index.html');
|
const indexFilePath = join(clientDistPath, 'index.html');
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,10 @@ import {
|
|||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
import { TokenService } from '../core/auth/services/token.service';
|
import { TokenService } from '../core/auth/services/token.service';
|
||||||
import { JwtPayload, JwtType } from '../core/auth/dto/jwt-payload';
|
import { JwtPayload, JwtType } from '../core/auth/dto/jwt-payload';
|
||||||
import { Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
import { OnModuleDestroy } from '@nestjs/common';
|
||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
import { WsService } from './ws.service';
|
import { WsService } from './ws.service';
|
||||||
import { getSpaceRoomName, getUserRoomName } from './ws.utils';
|
import { getSpaceRoomName, getUserRoomName } from './ws.utils';
|
||||||
import {
|
|
||||||
readClientBuildVersion,
|
|
||||||
resolveClientDistPath,
|
|
||||||
} from '../common/helpers/client-version';
|
|
||||||
import * as cookie from 'cookie';
|
import * as cookie from 'cookie';
|
||||||
|
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({
|
||||||
@@ -24,40 +20,17 @@ import * as cookie from 'cookie';
|
|||||||
transports: ['websocket'],
|
transports: ['websocket'],
|
||||||
})
|
})
|
||||||
export class WsGateway
|
export class WsGateway
|
||||||
implements
|
implements OnGatewayConnection, OnGatewayInit, OnModuleDestroy
|
||||||
OnGatewayConnection,
|
|
||||||
OnGatewayInit,
|
|
||||||
OnModuleInit,
|
|
||||||
OnModuleDestroy
|
|
||||||
{
|
{
|
||||||
@WebSocketServer()
|
@WebSocketServer()
|
||||||
server: Server;
|
server: Server;
|
||||||
|
|
||||||
private readonly logger = new Logger(WsGateway.name);
|
|
||||||
|
|
||||||
// The build version of the client bundle shipped in this image, read once at
|
|
||||||
// startup from client/dist/version.json (single source of truth, same value
|
|
||||||
// baked into the client's APP_VERSION). Empty string => version.json missing
|
|
||||||
// or empty => the proactive version-coherence reload feature stays inert.
|
|
||||||
private appVersion = '';
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private tokenService: TokenService,
|
private tokenService: TokenService,
|
||||||
private spaceMemberRepo: SpaceMemberRepo,
|
private spaceMemberRepo: SpaceMemberRepo,
|
||||||
private wsService: WsService,
|
private wsService: WsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
onModuleInit(): void {
|
|
||||||
this.appVersion = readClientBuildVersion(resolveClientDistPath());
|
|
||||||
if (this.appVersion) {
|
|
||||||
this.logger.log(`app-version reload: ACTIVE (v=${this.appVersion})`);
|
|
||||||
} else {
|
|
||||||
this.logger.log(
|
|
||||||
'app-version reload: DISABLED (version.json missing/empty)',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
afterInit(server: Server): void {
|
afterInit(server: Server): void {
|
||||||
this.wsService.setServer(server);
|
this.wsService.setServer(server);
|
||||||
}
|
}
|
||||||
@@ -82,14 +55,6 @@ export class WsGateway
|
|||||||
const spaceRooms = userSpaceIds.map((id) => getSpaceRoomName(id));
|
const spaceRooms = userSpaceIds.map((id) => getSpaceRoomName(id));
|
||||||
|
|
||||||
client.join([userRoom, workspaceRoom, ...spaceRooms]);
|
client.join([userRoom, workspaceRoom, ...spaceRooms]);
|
||||||
|
|
||||||
// Announce this container's client build version to the freshly
|
|
||||||
// authenticated socket. On a redeploy the client reconnects to the new
|
|
||||||
// container and receives the new version here, letting it guard-reload
|
|
||||||
// before it hits a stale lazy chunk. Per-connect only (no broadcast):
|
|
||||||
// natural reconnect covers both single-container and cluster without a
|
|
||||||
// thundering-herd fleet reload.
|
|
||||||
client.emit('app-version', { version: this.appVersion });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
client.emit('Unauthorized');
|
client.emit('Unauthorized');
|
||||||
client.disconnect();
|
client.disconnect();
|
||||||
|
|||||||
@@ -0,0 +1,412 @@
|
|||||||
|
import * as http from 'node:http';
|
||||||
|
import { Kysely } from 'kysely';
|
||||||
|
import { tool } from 'ai';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { MockLanguageModelV3, convertArrayToReadableStream } from 'ai/test';
|
||||||
|
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
||||||
|
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||||
|
import { AiChatRunStepRepo } from '@docmost/db/repos/ai-chat/ai-chat-run-step.repo';
|
||||||
|
import {
|
||||||
|
AiChatService,
|
||||||
|
assembleStepParts,
|
||||||
|
assistantParts,
|
||||||
|
rowHasInlineParts,
|
||||||
|
stepMarkerMetadata,
|
||||||
|
} from 'src/core/ai-chat/ai-chat.service';
|
||||||
|
import {
|
||||||
|
getTestDb,
|
||||||
|
destroyTestDb,
|
||||||
|
createWorkspace,
|
||||||
|
createUser,
|
||||||
|
createChat,
|
||||||
|
createMessage,
|
||||||
|
} from './db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #492 append-persist — the REAL onStep WRITE path (F2) and the model-REPLAY
|
||||||
|
* hydration path (F1), driven through `AiChatService.stream` against a LIVE
|
||||||
|
* Postgres with a REAL `AiChatRunStepRepo` INJECTED. The existing append-persist
|
||||||
|
* int-specs hand-roll the insert+marker cycle via the repos directly and build
|
||||||
|
* the service with `aiChatRunStepRepo: undefined` (only the legacy-fallback branch
|
||||||
|
* is covered), so an off-by-one on `stepsPersisted-1`, a wrong `capturedSteps`
|
||||||
|
* slice, or a broken marker payload would pass all of them. These tests exercise
|
||||||
|
* the actual `updateStreaming` append-persist branch end to end.
|
||||||
|
*
|
||||||
|
* The seam is the injected `model` (a seeded `MockLanguageModelV3` from `ai/test`)
|
||||||
|
* plus a REAL Node `ServerResponse` as the hijacked socket — mirrors
|
||||||
|
* ai-chat-stream.int-spec.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
async function waitFor(
|
||||||
|
cond: () => Promise<boolean> | boolean,
|
||||||
|
{ timeoutMs = 15_000, stepMs = 25 } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
if (await cond()) return;
|
||||||
|
await sleep(stepMs);
|
||||||
|
}
|
||||||
|
throw new Error('waitFor: condition not met within timeout');
|
||||||
|
}
|
||||||
|
|
||||||
|
// A real Node ServerResponse wired to a live socket (identical helper to the
|
||||||
|
// stream int-spec) so the SDK's pipe/heartbeat writes behave as in prod.
|
||||||
|
function makeRealResponse(): Promise<{
|
||||||
|
res: http.ServerResponse;
|
||||||
|
cleanup: () => Promise<void>;
|
||||||
|
}> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const server = http.createServer((_req, res) => {
|
||||||
|
resolve({
|
||||||
|
res,
|
||||||
|
cleanup: () =>
|
||||||
|
new Promise<void>((done) => {
|
||||||
|
try {
|
||||||
|
if (!res.writableEnded) res.end();
|
||||||
|
} catch {
|
||||||
|
/* socket already gone */
|
||||||
|
}
|
||||||
|
server.close(() => done());
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
server.listen(0, () => {
|
||||||
|
const port = (server.address() as any).port;
|
||||||
|
const creq = http.request({ port, method: 'GET' }, (cres) => {
|
||||||
|
cres.resume();
|
||||||
|
});
|
||||||
|
creq.on('error', () => undefined);
|
||||||
|
creq.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream parts for a normal, successful single-step turn.
|
||||||
|
function successStream() {
|
||||||
|
return convertArrayToReadableStream([
|
||||||
|
{ type: 'stream-start', warnings: [] },
|
||||||
|
{ type: 'text-start', id: 't1' },
|
||||||
|
{ type: 'text-delta', id: 't1', delta: 'Hello' },
|
||||||
|
{ type: 'text-delta', id: 't1', delta: ' there' },
|
||||||
|
{ type: 'text-end', id: 't1' },
|
||||||
|
{
|
||||||
|
type: 'finish',
|
||||||
|
finishReason: 'stop',
|
||||||
|
usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
|
||||||
|
},
|
||||||
|
] as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A THREE-step turn: steps 0 and 1 each emit text + an `echo` tool call (the SDK
|
||||||
|
// runs the tool and continues); step 2 answers and stops. Three steps is
|
||||||
|
// deliberate: the LAST finished step's append-persist write races the terminal
|
||||||
|
// finalize (which writes the full inline parts anyway, so a lost last-step row is
|
||||||
|
// by design), but the NON-final steps 0 and 1 always drain to the steps table
|
||||||
|
// before finalize — so those are what the test asserts on deterministically.
|
||||||
|
function threeStepModel(): MockLanguageModelV3 {
|
||||||
|
let step = 0;
|
||||||
|
const toolStep = (i: number) => ({
|
||||||
|
stream: convertArrayToReadableStream([
|
||||||
|
{ type: 'stream-start', warnings: [] },
|
||||||
|
{ type: 'text-start', id: `s${i}` },
|
||||||
|
{ type: 'text-delta', id: `s${i}`, delta: `step ${i} ` },
|
||||||
|
{ type: 'text-end', id: `s${i}` },
|
||||||
|
{
|
||||||
|
type: 'tool-call',
|
||||||
|
toolCallId: `c${i}`,
|
||||||
|
toolName: 'echo',
|
||||||
|
input: JSON.stringify({ msg: `m${i}` }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'finish',
|
||||||
|
finishReason: 'tool-calls',
|
||||||
|
usage: { inputTokens: 5, outputTokens: 3, totalTokens: 8 },
|
||||||
|
},
|
||||||
|
] as any),
|
||||||
|
});
|
||||||
|
return new MockLanguageModelV3({
|
||||||
|
doStream: async () => {
|
||||||
|
const n = step++;
|
||||||
|
// Realistic inter-step latency. A real model spends seconds per step, so the
|
||||||
|
// fire-and-forget per-step write chain drains to the steps table BETWEEN
|
||||||
|
// steps; the mock otherwise collapses all steps into microseconds and the
|
||||||
|
// terminal finalize wins the race before any but the first step persists.
|
||||||
|
if (n > 0) await sleep(200);
|
||||||
|
if (n < 2) return toolStep(n);
|
||||||
|
return {
|
||||||
|
stream: convertArrayToReadableStream([
|
||||||
|
{ type: 'stream-start', warnings: [] },
|
||||||
|
{ type: 'text-start', id: 's2' },
|
||||||
|
{ type: 'text-delta', id: 's2', delta: 'final answer' },
|
||||||
|
{ type: 'text-end', id: 's2' },
|
||||||
|
{
|
||||||
|
type: 'finish',
|
||||||
|
finishReason: 'stop',
|
||||||
|
usage: { inputTokens: 6, outputTokens: 4, totalTokens: 10 },
|
||||||
|
},
|
||||||
|
] as any),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('#492 append-persist service paths [integration]', () => {
|
||||||
|
let db: Kysely<any>;
|
||||||
|
let aiChatRepo: AiChatRepo;
|
||||||
|
let msgRepo: AiChatMessageRepo;
|
||||||
|
let stepRepo: AiChatRunStepRepo;
|
||||||
|
let workspaceId: string;
|
||||||
|
let userId: string;
|
||||||
|
|
||||||
|
let closeCalls: number;
|
||||||
|
const mcpClients = {
|
||||||
|
toolsFor: async () => ({
|
||||||
|
tools: {},
|
||||||
|
clients: [
|
||||||
|
{
|
||||||
|
close: async () => {
|
||||||
|
closeCalls += 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
outcomes: [],
|
||||||
|
instructions: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build the service WITH a REAL AiChatRunStepRepo injected (the property under
|
||||||
|
// test) — unlike the legacy-fallback harness that passes it as undefined.
|
||||||
|
const echoTool = tool({
|
||||||
|
description: 'echo the message back',
|
||||||
|
inputSchema: z.object({ msg: z.string() }),
|
||||||
|
execute: async ({ msg }) => ({ echoed: msg }),
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildService(): AiChatService {
|
||||||
|
return new AiChatService(
|
||||||
|
{ getChatModel: async () => null } as any,
|
||||||
|
aiChatRepo,
|
||||||
|
msgRepo,
|
||||||
|
{} as any, // aiChatPageSnapshotRepo
|
||||||
|
{ resolve: async () => null } as any, // aiSettings
|
||||||
|
{ forUser: async () => ({ echo: echoTool }) } as any, // tools
|
||||||
|
mcpClients as any,
|
||||||
|
{} as any, // aiAgentRoleRepo
|
||||||
|
{} as any, // pageRepo
|
||||||
|
{} as any, // pageAccess
|
||||||
|
{
|
||||||
|
isAiChatDeferredToolsEnabled: () => false,
|
||||||
|
isAiChatFinalStepLockdownEnabled: () => false,
|
||||||
|
} as any, // environment (deferred OFF -> all tools active every step)
|
||||||
|
undefined, // streamRegistry
|
||||||
|
undefined, // aiChatRunService
|
||||||
|
stepRepo, // #492 aiChatRunStepRepo — the append-persist backend
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function userUiMessage(text: string) {
|
||||||
|
return {
|
||||||
|
id: `u-${Math.random()}`,
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ type: 'text', text }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runStream(opts: {
|
||||||
|
model: MockLanguageModelV3;
|
||||||
|
chatId: string;
|
||||||
|
body: any;
|
||||||
|
}): Promise<void> {
|
||||||
|
closeCalls = 0;
|
||||||
|
const service = buildService();
|
||||||
|
const { res, cleanup } = await makeRealResponse();
|
||||||
|
try {
|
||||||
|
await service.stream({
|
||||||
|
user: { id: userId, workspaceId } as any,
|
||||||
|
workspace: { id: workspaceId, name: 'WS' } as any,
|
||||||
|
sessionId: 'sess-1',
|
||||||
|
body: opts.body,
|
||||||
|
res: { raw: res } as any,
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
model: opts.model as any,
|
||||||
|
role: null,
|
||||||
|
} as any);
|
||||||
|
await waitFor(async () => {
|
||||||
|
const rows = await msgRepo.findAllByChat(opts.chatId, workspaceId);
|
||||||
|
return rows.some(
|
||||||
|
(r) =>
|
||||||
|
r.role === 'assistant' &&
|
||||||
|
['completed', 'error', 'aborted'].includes(r.status as string),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitFor(() => closeCalls > 0, { timeoutMs: 5_000 });
|
||||||
|
} finally {
|
||||||
|
await cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = getTestDb();
|
||||||
|
aiChatRepo = new AiChatRepo(db as any);
|
||||||
|
msgRepo = new AiChatMessageRepo(db as any);
|
||||||
|
stepRepo = new AiChatRunStepRepo(db as any);
|
||||||
|
workspaceId = (await createWorkspace(db)).id;
|
||||||
|
userId = (await createUser(db, workspaceId)).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await destroyTestDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- F2: the real onStep append-persist WRITE branch -----------------------
|
||||||
|
it('drives steps through the real onStep path: per-step rows + marker match a single-row flush', async () => {
|
||||||
|
const chatId = (await createChat(db, { workspaceId, creatorId: userId })).id;
|
||||||
|
const model = threeStepModel();
|
||||||
|
|
||||||
|
// Capture the mid-run step-marker UPDATEs the append-persist branch writes on
|
||||||
|
// the assistant row (a { parts: [], toolTraceVersion, stepsPersisted } patch).
|
||||||
|
const updateSpy = jest.spyOn(msgRepo, 'update');
|
||||||
|
try {
|
||||||
|
await runStream({
|
||||||
|
model,
|
||||||
|
chatId,
|
||||||
|
body: { chatId, messages: [userUiMessage('call the tool then answer')] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await msgRepo.findAllByChat(chatId, workspaceId);
|
||||||
|
const assistant = rows.find((r) => r.role === 'assistant')!;
|
||||||
|
expect(assistant).toBeDefined();
|
||||||
|
expect(assistant.status).toBe('completed');
|
||||||
|
// The turn finalizes with the FULL inline parts assembled by a single-row
|
||||||
|
// flush (assistantParts over every step) — the baseline the per-step slices
|
||||||
|
// must reproduce.
|
||||||
|
expect(rowHasInlineParts(assistant)).toBe(true);
|
||||||
|
const finalParts = (assistant.metadata as { parts: any[] }).parts;
|
||||||
|
|
||||||
|
// The two NON-final finished steps each landed their own row, in stepIndex
|
||||||
|
// order. (The fire-and-forget write chain drains before the next step, so
|
||||||
|
// poll until both are on disk; the LAST step's write may lose the finalize
|
||||||
|
// race, which is by design — its parts are already in `finalParts`.)
|
||||||
|
await waitFor(async () => {
|
||||||
|
const s = await stepRepo.findByMessage(assistant.id, workspaceId);
|
||||||
|
return s.length >= 2;
|
||||||
|
});
|
||||||
|
const steps = await stepRepo.findByMessage(assistant.id, workspaceId);
|
||||||
|
expect(steps[0].stepIndex).toBe(0);
|
||||||
|
expect(steps[1].stepIndex).toBe(1);
|
||||||
|
|
||||||
|
// Each per-step row carries a NON-trivial slice: this step's text part + its
|
||||||
|
// paired tool part (guards a mutation that persists empty/whole-turn parts).
|
||||||
|
const s0 = steps[0].parts as any[];
|
||||||
|
expect(s0).toContainEqual({ type: 'text', text: 'step 0 ' });
|
||||||
|
expect(s0.some((p) => p.type === 'tool-echo')).toBe(true);
|
||||||
|
|
||||||
|
// The per-step slices are EXACTLY the corresponding prefix of the single-row
|
||||||
|
// flush: assembleStepParts([step0, step1]) === finalParts[0 .. len0+len1].
|
||||||
|
// This is what an off-by-one on `stepsPersisted-1` (a wrong `capturedSteps`
|
||||||
|
// slice) or a shifted stepIndex breaks — the prefix no longer aligns.
|
||||||
|
const prefixLen =
|
||||||
|
(steps[0].parts as any[]).length + (steps[1].parts as any[]).length;
|
||||||
|
expect(assembleStepParts([steps[0], steps[1]] as any)).toEqual(
|
||||||
|
finalParts.slice(0, prefixLen),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The mid-run step markers advanced 1 -> 2 -> ... (the resume frontier), each
|
||||||
|
// a shape-stable empty-parts marker equal to a single-row flush's marker.
|
||||||
|
const markerCounts = updateSpy.mock.calls
|
||||||
|
.map((c) => (c[2] as any)?.metadata)
|
||||||
|
.filter(
|
||||||
|
(m) =>
|
||||||
|
m &&
|
||||||
|
Array.isArray(m.parts) &&
|
||||||
|
m.parts.length === 0 &&
|
||||||
|
typeof m.stepsPersisted === 'number',
|
||||||
|
)
|
||||||
|
.map((m) => m.stepsPersisted);
|
||||||
|
// Monotonic from 1, covering at least the two non-final steps.
|
||||||
|
expect(markerCounts.slice(0, 2)).toEqual([1, 2]);
|
||||||
|
expect(
|
||||||
|
updateSpy.mock.calls
|
||||||
|
.map((c) => (c[2] as any)?.metadata)
|
||||||
|
.find((m) => m && m.stepsPersisted === 2),
|
||||||
|
).toEqual(stepMarkerMetadata(2));
|
||||||
|
} finally {
|
||||||
|
updateSpy.mockRestore();
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
// --- F1: model-REPLAY hydrates a hard-crashed mid-run turn from the steps table
|
||||||
|
it('replays a hard-crashed mid-run turn WITH its partial steps hydrated from the steps table', async () => {
|
||||||
|
const chatId = (await createChat(db, { workspaceId, creatorId: userId })).id;
|
||||||
|
|
||||||
|
// Prior turn: a genuine user question...
|
||||||
|
await createMessage(db, {
|
||||||
|
workspaceId,
|
||||||
|
chatId,
|
||||||
|
userId,
|
||||||
|
role: 'user',
|
||||||
|
content: 'What is in the design doc?',
|
||||||
|
createdAt: new Date(Date.now() - 3000),
|
||||||
|
});
|
||||||
|
// ...and an assistant row that a HARD crash (SIGKILL/OOM) left mid-run: only a
|
||||||
|
// step marker on the row (metadata.parts:[] , content:''), NO terminal
|
||||||
|
// callback ever fired, so its real parts live ONLY in ai_chat_run_steps.
|
||||||
|
const crashed = await createMessage(db, {
|
||||||
|
workspaceId,
|
||||||
|
chatId,
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
status: 'aborted',
|
||||||
|
metadata: stepMarkerMetadata(1),
|
||||||
|
createdAt: new Date(Date.now() - 2000),
|
||||||
|
});
|
||||||
|
// The durable partial step: some reasoning text + a completed getPage tool
|
||||||
|
// call (input + output), exactly what #183 step-granular durability preserves.
|
||||||
|
await stepRepo.insertStep(
|
||||||
|
crashed.id,
|
||||||
|
workspaceId,
|
||||||
|
0,
|
||||||
|
assistantParts(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: 'HYDRATED_PARTIAL_STEP the doc says',
|
||||||
|
toolCalls: [
|
||||||
|
{ toolCallId: 'g1', toolName: 'getPage', input: { id: 'p1' } },
|
||||||
|
],
|
||||||
|
toolResults: [
|
||||||
|
{
|
||||||
|
toolCallId: 'g1',
|
||||||
|
toolName: 'getPage',
|
||||||
|
output: { id: 'p1', body: 'PARTIAL_TOOL_OUTPUT budget section' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as any,
|
||||||
|
],
|
||||||
|
'',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The NEXT turn: the model just answers. The service must REPLAY the crashed
|
||||||
|
// assistant turn with its partial parts hydrated from the steps table.
|
||||||
|
const model = new MockLanguageModelV3({
|
||||||
|
doStream: async () => ({ stream: successStream() }),
|
||||||
|
} as any);
|
||||||
|
await runStream({
|
||||||
|
model,
|
||||||
|
chatId,
|
||||||
|
body: { chatId, messages: [userUiMessage('Continue please')] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.doStreamCalls.length).toBeGreaterThan(0);
|
||||||
|
const prompt = JSON.stringify(model.doStreamCalls[0].prompt);
|
||||||
|
// The partial step's TEXT reached the model context (it would be an empty text
|
||||||
|
// part without hydration — rowToUiMessage falls back to `content:''`).
|
||||||
|
expect(prompt).toContain('HYDRATED_PARTIAL_STEP');
|
||||||
|
// The partial TOOL RESULT survived too (durable in the steps table, replayed).
|
||||||
|
expect(prompt).toContain('PARTIAL_TOOL_OUTPUT');
|
||||||
|
// The genuine prior user turn is present as well (sanity: real history replay).
|
||||||
|
expect(prompt).toContain('What is in the design doc?');
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import { Kysely, sql } from 'kysely';
|
||||||
|
import { AiChatRunStepRepo } from '@docmost/db/repos/ai-chat/ai-chat-run-step.repo';
|
||||||
|
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||||
|
import {
|
||||||
|
assistantParts,
|
||||||
|
flushAssistant,
|
||||||
|
stepMarkerMetadata,
|
||||||
|
} from '../../src/core/ai-chat/ai-chat.service';
|
||||||
|
import {
|
||||||
|
getTestDb,
|
||||||
|
destroyTestDb,
|
||||||
|
createWorkspace,
|
||||||
|
createUser,
|
||||||
|
createChat,
|
||||||
|
} from './db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #492 append-persist — WRITE-VOLUME regression on a LIVE Postgres, measured via
|
||||||
|
* the `pg_current_wal_lsn()` delta around a realistic multi-step run driven through
|
||||||
|
* the REAL repos (not a mock — a mock cannot observe MVCC/TOAST rewrite volume, the
|
||||||
|
* whole point). Proves the core claim:
|
||||||
|
*
|
||||||
|
* NEW (per-step INSERT into ai_chat_run_steps + a CHEAP step-marker UPDATE on the
|
||||||
|
* message row) writes O(Σ steps) of WAL — each step writes only its own bytes.
|
||||||
|
*
|
||||||
|
* OLD (the pre-#492 full-row rewrite: re-persist the GROWING metadata.parts on
|
||||||
|
* every onStepFinish) writes O(n²) — step k rewrites the whole TOASTed jsonb of
|
||||||
|
* all k prior outputs.
|
||||||
|
*
|
||||||
|
* The OLD path here IS the reverted behavior, so this doubles as the mutation
|
||||||
|
* check: swapping the new path back to `flushAssistant` full-row UPDATEs reddens
|
||||||
|
* the assertion (OLD is many times larger).
|
||||||
|
*/
|
||||||
|
type Step = {
|
||||||
|
text: string;
|
||||||
|
toolCalls: Array<{ toolCallId: string; toolName: string; input: unknown }>;
|
||||||
|
toolResults: Array<{ toolCallId: string; toolName: string; output: unknown }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ~100 KB INCOMPRESSIBLE output per step (a page read). Random base64 so TOAST
|
||||||
|
// cannot compress it away and hide the real write volume.
|
||||||
|
function makeStep(i: number, outputBytes = 100_000): Step {
|
||||||
|
const body = randomBytes(Math.ceil(outputBytes * 0.75)).toString('base64');
|
||||||
|
return {
|
||||||
|
text: `step ${i} reasoning`,
|
||||||
|
toolCalls: [
|
||||||
|
{ toolCallId: `c${i}`, toolName: 'getPage', input: { id: `p${i}` } },
|
||||||
|
],
|
||||||
|
toolResults: [
|
||||||
|
{
|
||||||
|
toolCallId: `c${i}`,
|
||||||
|
toolName: 'getPage',
|
||||||
|
output: { id: `p${i}`, title: `Page ${i}`, body },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function walDelta(
|
||||||
|
db: Kysely<any>,
|
||||||
|
fn: () => Promise<void>,
|
||||||
|
): Promise<number> {
|
||||||
|
const before = (
|
||||||
|
await sql<{ l: string }>`select pg_current_wal_lsn() as l`.execute(db)
|
||||||
|
).rows[0].l;
|
||||||
|
await fn();
|
||||||
|
// NOTE: no pg_switch_wal() — a segment switch pads the LSN to the next 16 MB
|
||||||
|
// boundary and would swamp the delta. The raw LSN advances by the WAL bytes.
|
||||||
|
const after = (
|
||||||
|
await sql<{ l: string }>`select pg_current_wal_lsn() as l`.execute(db)
|
||||||
|
).rows[0].l;
|
||||||
|
return Number(
|
||||||
|
(
|
||||||
|
await sql<{
|
||||||
|
d: string;
|
||||||
|
}>`select pg_wal_lsn_diff(${after}::pg_lsn, ${before}::pg_lsn) as d`.execute(
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
).rows[0].d,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('#492 append-persist write volume (pg_current_wal_lsn delta) [integration]', () => {
|
||||||
|
let db: Kysely<any>;
|
||||||
|
let stepRepo: AiChatRunStepRepo;
|
||||||
|
let msgRepo: AiChatMessageRepo;
|
||||||
|
let workspaceId: string;
|
||||||
|
let userId: string;
|
||||||
|
let chatId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = getTestDb();
|
||||||
|
stepRepo = new AiChatRunStepRepo(db as any);
|
||||||
|
msgRepo = new AiChatMessageRepo(db as any);
|
||||||
|
workspaceId = (await createWorkspace(db)).id;
|
||||||
|
userId = (await createUser(db, workspaceId)).id;
|
||||||
|
chatId = (await createChat(db, { workspaceId, creatorId: userId })).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await destroyTestDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
const seedRow = () =>
|
||||||
|
msgRepo.insert({
|
||||||
|
chatId,
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
status: 'streaming',
|
||||||
|
metadata: stepMarkerMetadata(0) as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const STEPS = 40;
|
||||||
|
|
||||||
|
it('NEW per-step INSERT is O(Σ steps); OLD full-row rewrite is O(n²)', async () => {
|
||||||
|
const steps: Step[] = [];
|
||||||
|
for (let i = 0; i < STEPS; i++) steps.push(makeStep(i));
|
||||||
|
|
||||||
|
// NEW: per-step INSERT of THIS step's parts + a cheap marker UPDATE.
|
||||||
|
const newRow = await seedRow();
|
||||||
|
const newWal = await walDelta(db, async () => {
|
||||||
|
for (let i = 0; i < STEPS; i++) {
|
||||||
|
await stepRepo.insertStep(
|
||||||
|
newRow.id,
|
||||||
|
workspaceId,
|
||||||
|
i,
|
||||||
|
assistantParts([steps[i]], ''),
|
||||||
|
);
|
||||||
|
await msgRepo.update(
|
||||||
|
newRow.id,
|
||||||
|
workspaceId,
|
||||||
|
{ metadata: stepMarkerMetadata(i + 1) },
|
||||||
|
{ onlyIfStreaming: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// OLD (the pre-#492 revert): re-persist the GROWING metadata.parts on the
|
||||||
|
// message row on every step.
|
||||||
|
const oldRow = await seedRow();
|
||||||
|
const oldWal = await walDelta(db, async () => {
|
||||||
|
const acc: Step[] = [];
|
||||||
|
for (let i = 0; i < STEPS; i++) {
|
||||||
|
acc.push(steps[i]);
|
||||||
|
await msgRepo.update(
|
||||||
|
oldRow.id,
|
||||||
|
workspaceId,
|
||||||
|
flushAssistant(acc as never, '', 'streaming'),
|
||||||
|
{ onlyIfStreaming: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
`[#492 WAL] ${STEPS} steps ×100KB: new=${(newWal / 1e6).toFixed(1)}MB ` +
|
||||||
|
`old=${(oldWal / 1e6).toFixed(1)}MB (${(oldWal / newWal).toFixed(
|
||||||
|
1,
|
||||||
|
)}x smaller)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// O(Σ steps): ~STEPS × (100KB output + marker) of WAL. 40 × ~100KB parts plus
|
||||||
|
// 40 tiny markers is a few tens of MB at most — bounded, linear in step count.
|
||||||
|
expect(newWal).toBeLessThan(30_000_000);
|
||||||
|
// O(n²): step k rewrites ~k × 100KB. Σ over 40 steps ≈ 80+ MB — far larger.
|
||||||
|
expect(oldWal).toBeGreaterThan(30_000_000);
|
||||||
|
// The load-bearing claim: the new path writes a small FRACTION of the old.
|
||||||
|
expect(newWal).toBeLessThan(oldWal * 0.35);
|
||||||
|
}, 120_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { Kysely } from 'kysely';
|
||||||
|
import { AiChatController } from 'src/core/ai-chat/ai-chat.controller';
|
||||||
|
import {
|
||||||
|
assembleStepParts,
|
||||||
|
assistantParts,
|
||||||
|
stepMarkerMetadata,
|
||||||
|
} from 'src/core/ai-chat/ai-chat.service';
|
||||||
|
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
||||||
|
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||||
|
import { AiChatRunStepRepo } from '@docmost/db/repos/ai-chat/ai-chat-run-step.repo';
|
||||||
|
import type { User, Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
import {
|
||||||
|
getTestDb,
|
||||||
|
destroyTestDb,
|
||||||
|
createWorkspace,
|
||||||
|
createUser,
|
||||||
|
createChat,
|
||||||
|
createMessage,
|
||||||
|
} from './db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #492 controller hydration (crash-before-finalize RESUME) on a LIVE Postgres.
|
||||||
|
* `AiChatController.withReconstructedParts` is wired into getMessages/delta/export/
|
||||||
|
* run, but `aiChatRunStepRepo` is OPTIONAL and every controller unit spec passes it
|
||||||
|
* as `undefined`, so the hydration branch early-returns and NEVER executes in those
|
||||||
|
* tests. This drives the real read path — a mid-run streaming row (marker only,
|
||||||
|
* empty inline parts) PLUS its `ai_chat_run_steps` rows — through getMessages WITH
|
||||||
|
* the repo present, exercising the `role==='assistant' && !rowHasInlineParts`
|
||||||
|
* needy predicate, the workspace-scoped batch step fetch, and the endpoint binding.
|
||||||
|
*/
|
||||||
|
describe('#492 controller hydration read path [integration]', () => {
|
||||||
|
let db: Kysely<any>;
|
||||||
|
let aiChatRepo: AiChatRepo;
|
||||||
|
let msgRepo: AiChatMessageRepo;
|
||||||
|
let stepRepo: AiChatRunStepRepo;
|
||||||
|
let workspaceId: string;
|
||||||
|
let otherWorkspaceId: string;
|
||||||
|
let userId: string;
|
||||||
|
|
||||||
|
// Build the controller WITH a real AiChatRunStepRepo injected (position 9), the
|
||||||
|
// seam the unit specs leave undefined. Only the read-path deps are real.
|
||||||
|
function buildController(): AiChatController {
|
||||||
|
return new AiChatController(
|
||||||
|
{} as any, // aiChatService
|
||||||
|
{} as any, // aiChatRunService
|
||||||
|
aiChatRepo,
|
||||||
|
msgRepo,
|
||||||
|
{} as any, // aiTranscription
|
||||||
|
{} as any, // pageRepo
|
||||||
|
undefined, // streamRegistry
|
||||||
|
undefined, // environment
|
||||||
|
stepRepo, // #492 aiChatRunStepRepo
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = getTestDb();
|
||||||
|
aiChatRepo = new AiChatRepo(db as any);
|
||||||
|
msgRepo = new AiChatMessageRepo(db as any);
|
||||||
|
stepRepo = new AiChatRunStepRepo(db as any);
|
||||||
|
workspaceId = (await createWorkspace(db)).id;
|
||||||
|
otherWorkspaceId = (await createWorkspace(db)).id;
|
||||||
|
userId = (await createUser(db, workspaceId)).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await destroyTestDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getMessages reconstructs a mid-run row from the steps table (finished rows untouched)', async () => {
|
||||||
|
const chatId = (
|
||||||
|
await createChat(db, { workspaceId, creatorId: userId })
|
||||||
|
).id;
|
||||||
|
const user = { id: userId } as User;
|
||||||
|
const workspace = { id: workspaceId } as Workspace;
|
||||||
|
|
||||||
|
// A prior FINISHED assistant row that already carries inline parts — the needy
|
||||||
|
// predicate must SKIP it (no step fetch), returned untouched.
|
||||||
|
const finishedParts = assistantParts(
|
||||||
|
[{ text: 'done earlier', toolCalls: [], toolResults: [] } as any],
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
await createMessage(db, {
|
||||||
|
workspaceId,
|
||||||
|
chatId,
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'done earlier',
|
||||||
|
status: 'completed',
|
||||||
|
metadata: { parts: finishedParts, toolTraceVersion: 2, stepsPersisted: 1 },
|
||||||
|
createdAt: new Date(Date.now() - 3000),
|
||||||
|
});
|
||||||
|
|
||||||
|
// The mid-run row a crash-before-finalize left behind: a step marker only
|
||||||
|
// (parts:[] , content:''), status 'streaming'. Its real parts live ONLY in the
|
||||||
|
// steps table.
|
||||||
|
const midRun = await createMessage(db, {
|
||||||
|
workspaceId,
|
||||||
|
chatId,
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
status: 'streaming',
|
||||||
|
metadata: stepMarkerMetadata(2),
|
||||||
|
createdAt: new Date(Date.now() - 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
const step0 = assistantParts(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: 'reasoning about the page',
|
||||||
|
toolCalls: [
|
||||||
|
{ toolCallId: 'g1', toolName: 'getPage', input: { id: 'p1' } },
|
||||||
|
],
|
||||||
|
toolResults: [
|
||||||
|
{ toolCallId: 'g1', toolName: 'getPage', output: { id: 'p1', body: 'B' } },
|
||||||
|
],
|
||||||
|
} as any,
|
||||||
|
],
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
const step1 = assistantParts(
|
||||||
|
[{ text: 'partial synthesis so far', toolCalls: [], toolResults: [] } as any],
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
await stepRepo.insertStep(midRun.id, workspaceId, 0, step0);
|
||||||
|
await stepRepo.insertStep(midRun.id, workspaceId, 1, step1);
|
||||||
|
|
||||||
|
// Workspace-scoping guard: a step row for the SAME message id under a DIFFERENT
|
||||||
|
// workspace must NEVER leak into this workspace's reconstruction.
|
||||||
|
await stepRepo.insertStep(midRun.id, otherWorkspaceId, 99, [
|
||||||
|
{ type: 'text', text: 'FOREIGN_WORKSPACE_LEAK' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await buildController().getMessages(
|
||||||
|
{ chatId } as any,
|
||||||
|
{ limit: 50 } as any,
|
||||||
|
user,
|
||||||
|
workspace,
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = res.items as any[];
|
||||||
|
const finished = items.find((r) => r.status === 'completed');
|
||||||
|
const reconstructed = items.find((r) => r.id === midRun.id);
|
||||||
|
|
||||||
|
// The finished row passed through with its inline parts unchanged.
|
||||||
|
expect(finished.metadata.parts).toEqual(finishedParts);
|
||||||
|
|
||||||
|
// The mid-run row's parts were reconstructed from the two step rows, in order,
|
||||||
|
// exactly as assembleStepParts concatenates them — the client seed sees the
|
||||||
|
// persisted progress with no change to itself.
|
||||||
|
const expected = assembleStepParts([
|
||||||
|
{ stepIndex: 0, parts: step0 },
|
||||||
|
{ stepIndex: 1, parts: step1 },
|
||||||
|
] as any);
|
||||||
|
expect(reconstructed.metadata.parts).toEqual(expected);
|
||||||
|
// The foreign-workspace step row did NOT leak in.
|
||||||
|
expect(JSON.stringify(reconstructed.metadata.parts)).not.toContain(
|
||||||
|
'FOREIGN_WORKSPACE_LEAK',
|
||||||
|
);
|
||||||
|
// Sanity: reconstruction produced real content (text + the paired tool part +
|
||||||
|
// the second step's text), not an empty fallback.
|
||||||
|
expect(reconstructed.metadata.parts).toContainEqual({
|
||||||
|
type: 'text',
|
||||||
|
text: 'reasoning about the page',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(reconstructed.metadata.parts as any[]).some((p) => p.type === 'tool-getPage'),
|
||||||
|
).toBe(true);
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import { Kysely } from 'kysely';
|
||||||
|
import { AiChatRunStepRepo } from '@docmost/db/repos/ai-chat/ai-chat-run-step.repo';
|
||||||
|
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||||
|
import {
|
||||||
|
assistantParts,
|
||||||
|
reconstructRunParts,
|
||||||
|
hydrateAssistantParts,
|
||||||
|
stepMarkerMetadata,
|
||||||
|
rowHasInlineParts,
|
||||||
|
} from '../../src/core/ai-chat/ai-chat.service';
|
||||||
|
import {
|
||||||
|
getTestDb,
|
||||||
|
destroyTestDb,
|
||||||
|
createWorkspace,
|
||||||
|
createUser,
|
||||||
|
createChat,
|
||||||
|
} from './db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #492 append-persist — the reconstruct CONTRACT on a live Postgres. Proves that a
|
||||||
|
* turn persisted the NEW way (per-step rows in `ai_chat_run_steps`, only a step
|
||||||
|
* marker on the message row) reconstructs to the SAME UI parts as a turn persisted
|
||||||
|
* the OLD way (full `metadata.parts` inline on the row, no step rows) — so the
|
||||||
|
* era-switch is invisible to attach / delta-poll / export. Real repos + real jsonb
|
||||||
|
* roundtrip, not a mock (a mock cannot prove the parts survive the jsonb column
|
||||||
|
* byte-identical).
|
||||||
|
*/
|
||||||
|
type Step = {
|
||||||
|
text: string;
|
||||||
|
toolCalls: Array<{ toolCallId: string; toolName: string; input: unknown }>;
|
||||||
|
toolResults: Array<{ toolCallId: string; toolName: string; output: unknown }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A realistic step: some text + a getPage tool call whose ~100 KB body is
|
||||||
|
// INCOMPRESSIBLE random base64 (a 'x'.repeat filler would TOAST away and hide the
|
||||||
|
// real bytes). Under MAX_TOOL_OUTPUT_BYTES (200 KB) it is stored uncompacted.
|
||||||
|
function makeStep(i: number, outputBytes = 4_000): Step {
|
||||||
|
const body = randomBytes(Math.ceil(outputBytes * 0.75)).toString('base64');
|
||||||
|
return {
|
||||||
|
text: `step ${i} text`,
|
||||||
|
toolCalls: [
|
||||||
|
{ toolCallId: `c${i}`, toolName: 'getPage', input: { id: `p${i}` } },
|
||||||
|
],
|
||||||
|
toolResults: [
|
||||||
|
{
|
||||||
|
toolCallId: `c${i}`,
|
||||||
|
toolName: 'getPage',
|
||||||
|
output: { id: `p${i}`, title: `Page ${i}`, body },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AiChatRunStepRepo + reconstruct contract [integration]', () => {
|
||||||
|
let db: Kysely<any>;
|
||||||
|
let stepRepo: AiChatRunStepRepo;
|
||||||
|
let msgRepo: AiChatMessageRepo;
|
||||||
|
let workspaceId: string;
|
||||||
|
let userId: string;
|
||||||
|
let chatId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = getTestDb();
|
||||||
|
stepRepo = new AiChatRunStepRepo(db as any);
|
||||||
|
msgRepo = new AiChatMessageRepo(db as any);
|
||||||
|
workspaceId = (await createWorkspace(db)).id;
|
||||||
|
userId = (await createUser(db, workspaceId)).id;
|
||||||
|
chatId = (await createChat(db, { workspaceId, creatorId: userId })).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await destroyTestDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
const seedRow = (metadata: unknown, status: string) =>
|
||||||
|
msgRepo.insert({
|
||||||
|
chatId,
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
status,
|
||||||
|
metadata: metadata as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('insertStep is idempotent per (message, stepIndex) and reads back in order', async () => {
|
||||||
|
const row = await seedRow(stepMarkerMetadata(0), 'streaming');
|
||||||
|
const parts0 = assistantParts([makeStep(0)], '');
|
||||||
|
const parts1 = assistantParts([makeStep(1)], '');
|
||||||
|
|
||||||
|
expect(await stepRepo.insertStep(row.id, workspaceId, 0, parts0)).toBe(true);
|
||||||
|
expect(await stepRepo.insertStep(row.id, workspaceId, 1, parts1)).toBe(true);
|
||||||
|
// A retried persist of the SAME step is a no-op (ON CONFLICT DO NOTHING).
|
||||||
|
expect(await stepRepo.insertStep(row.id, workspaceId, 0, parts0)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const steps = await stepRepo.findByMessage(row.id, workspaceId);
|
||||||
|
expect(steps.map((s) => s.stepIndex)).toEqual([0, 1]);
|
||||||
|
|
||||||
|
// Batch fetch groups by message id in step order.
|
||||||
|
const map = await stepRepo.findByMessageIds([row.id], workspaceId);
|
||||||
|
expect(map.get(row.id)!.map((s) => s.stepIndex)).toEqual([0, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a NEW-style (step-table) run reconstructs identically to an OLD-style (inline) run', async () => {
|
||||||
|
const steps = [makeStep(10), makeStep(11)];
|
||||||
|
// The inline parts the OLD full-row flush would have written.
|
||||||
|
const fullParts = assistantParts(steps, '');
|
||||||
|
|
||||||
|
// OLD-style record: full parts inline on the row, NO step rows.
|
||||||
|
const oldRow = await seedRow(
|
||||||
|
{ parts: fullParts, toolTraceVersion: 2, stepsPersisted: 2 },
|
||||||
|
'completed',
|
||||||
|
);
|
||||||
|
|
||||||
|
// NEW-style record: only a step marker on the row + per-step rows.
|
||||||
|
const newRow = await seedRow(stepMarkerMetadata(2), 'streaming');
|
||||||
|
for (let i = 0; i < steps.length; i++) {
|
||||||
|
await stepRepo.insertStep(
|
||||||
|
newRow.id,
|
||||||
|
workspaceId,
|
||||||
|
i,
|
||||||
|
assistantParts([steps[i]], ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-read both from the DB (proves the jsonb roundtrip).
|
||||||
|
const oldFetched = await msgRepo.findById(oldRow.id, workspaceId);
|
||||||
|
const newFetched = await msgRepo.findById(newRow.id, workspaceId);
|
||||||
|
const oldSteps = await stepRepo.findByMessage(oldRow.id, workspaceId);
|
||||||
|
const newSteps = await stepRepo.findByMessage(newRow.id, workspaceId);
|
||||||
|
|
||||||
|
// The discriminator: the old row carries inline parts, the new one does not.
|
||||||
|
expect(rowHasInlineParts(oldFetched!)).toBe(true);
|
||||||
|
expect(rowHasInlineParts(newFetched!)).toBe(false);
|
||||||
|
expect(oldSteps).toHaveLength(0);
|
||||||
|
expect(newSteps).toHaveLength(2);
|
||||||
|
|
||||||
|
const oldRecon = reconstructRunParts(oldFetched!, oldSteps);
|
||||||
|
const newRecon = reconstructRunParts(newFetched!, newSteps);
|
||||||
|
|
||||||
|
// Both reconstruct to the SAME parts + step count — the era is invisible.
|
||||||
|
expect(newRecon.parts).toEqual(fullParts);
|
||||||
|
expect(oldRecon.parts).toEqual(fullParts);
|
||||||
|
expect(newRecon.parts).toEqual(oldRecon.parts);
|
||||||
|
expect(newRecon.stepsPersisted).toBe(2);
|
||||||
|
expect(oldRecon.stepsPersisted).toBe(2);
|
||||||
|
|
||||||
|
// hydrateAssistantParts fills the new row's metadata.parts to match the old
|
||||||
|
// row's inline parts — so a consumer reading `metadata.parts` off the raw row
|
||||||
|
// (the client seed/poll, export) is unchanged across the era.
|
||||||
|
const map = await stepRepo.findByMessageIds([newRow.id], workspaceId);
|
||||||
|
const [hydrated] = hydrateAssistantParts([newFetched!], map);
|
||||||
|
expect((hydrated.metadata as { parts: unknown }).parts).toEqual(fullParts);
|
||||||
|
// A row that already has inline parts passes through untouched (same ref-shape).
|
||||||
|
const [oldPassThrough] = hydrateAssistantParts([oldFetched!], map);
|
||||||
|
expect((oldPassThrough.metadata as { parts: unknown }).parts).toEqual(
|
||||||
|
fullParts,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Kysely, sql } from 'kysely';
|
||||||
|
import {
|
||||||
|
up,
|
||||||
|
down,
|
||||||
|
} from '../../src/database/migrations/20260708T120000-ai-chat-run-steps';
|
||||||
|
import { getTestDb, destroyTestDb } from './db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #492 migration up/down roundtrip on a LIVE Postgres. global-setup already
|
||||||
|
* migrated docmost_test to latest (so the table exists at start); this drives the
|
||||||
|
* migration's own down()/up() and asserts the table presence toggles, then leaves
|
||||||
|
* it PRESENT (up) so the shared test DB is intact for any spec that runs after.
|
||||||
|
*/
|
||||||
|
async function tableExists(db: Kysely<any>): Promise<boolean> {
|
||||||
|
const row = (
|
||||||
|
await sql<{ t: string | null }>`select to_regclass('ai_chat_run_steps') as t`.execute(
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
return row.t !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uniqueIndexExists(db: Kysely<any>): Promise<boolean> {
|
||||||
|
const row = (
|
||||||
|
await sql<{
|
||||||
|
t: string | null;
|
||||||
|
}>`select to_regclass('ai_chat_run_steps_message_step_uidx') as t`.execute(db)
|
||||||
|
).rows[0];
|
||||||
|
return row.t !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('20260708 ai_chat_run_steps migration roundtrip [integration]', () => {
|
||||||
|
let db: Kysely<any>;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
db = getTestDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Belt-and-suspenders: guarantee the table is present for later specs even if
|
||||||
|
// an assertion threw mid-roundtrip.
|
||||||
|
if (!(await tableExists(db))) await up(db);
|
||||||
|
await destroyTestDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('down() drops the table+index and up() recreates them (idempotent)', async () => {
|
||||||
|
// Starts applied (global-setup migrated to latest).
|
||||||
|
expect(await tableExists(db)).toBe(true);
|
||||||
|
expect(await uniqueIndexExists(db)).toBe(true);
|
||||||
|
|
||||||
|
await down(db);
|
||||||
|
expect(await tableExists(db)).toBe(false);
|
||||||
|
expect(await uniqueIndexExists(db)).toBe(false);
|
||||||
|
|
||||||
|
await up(db);
|
||||||
|
expect(await tableExists(db)).toBe(true);
|
||||||
|
expect(await uniqueIndexExists(db)).toBe(true);
|
||||||
|
|
||||||
|
// up() is idempotent (ifNotExists) — a second run is a harmless no-op.
|
||||||
|
await expect(up(db)).resolves.not.toThrow();
|
||||||
|
expect(await tableExists(db)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user