Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 79b2da686b | |||
| 3e81f64415 | |||
| c674db2b2f |
@@ -1427,5 +1427,20 @@
|
||||
"Boundary": "Boundary",
|
||||
"Autosave": "Autosave",
|
||||
"Only versions": "Only versions",
|
||||
"No saved versions yet.": "No saved versions yet."
|
||||
"No saved versions yet.": "No saved versions yet.",
|
||||
"Time worked on this article": "Time worked on this article",
|
||||
"Show time worked on this page": "Show time worked on this page",
|
||||
"Estimated time worked (inactivity gap {{gap}} min)": "Estimated time worked (inactivity gap {{gap}} min)",
|
||||
"Estimate · timezone {{tz}} · inactivity gap {{gap}} min": "Estimate · timezone {{tz}} · inactivity gap {{gap}} min",
|
||||
"No editing activity recorded yet.": "No editing activity recorded yet.",
|
||||
"× {{count}} days without edits": "× {{count}} days without edits",
|
||||
"agent: {{value}}": "agent: {{value}}",
|
||||
"Work": "Work",
|
||||
"Agent": "Agent",
|
||||
"≈ {{hours}}h {{minutes}}m": "≈ {{hours}}h {{minutes}}m",
|
||||
"≈ {{hours}}h": "≈ {{hours}}h",
|
||||
"≈ {{minutes}}m": "≈ {{minutes}}m",
|
||||
"{{hours}}h {{minutes}}m": "{{hours}}h {{minutes}}m",
|
||||
"{{hours}}h": "{{hours}}h",
|
||||
"{{minutes}}m": "{{minutes}}m"
|
||||
}
|
||||
|
||||
@@ -1442,5 +1442,20 @@
|
||||
"Boundary": "Граница",
|
||||
"Autosave": "Автосейв",
|
||||
"Only versions": "Только версии",
|
||||
"No saved versions yet.": "Пока нет сохранённых версий."
|
||||
"No saved versions yet.": "Пока нет сохранённых версий.",
|
||||
"Time worked on this article": "Время работы над статьёй",
|
||||
"Show time worked on this page": "Показать время работы над страницей",
|
||||
"Estimated time worked (inactivity gap {{gap}} min)": "Оценка времени работы (порог паузы {{gap}} мин)",
|
||||
"Estimate · timezone {{tz}} · inactivity gap {{gap}} min": "Оценка · таймзона {{tz}} · порог паузы {{gap}} мин",
|
||||
"No editing activity recorded yet.": "Правок пока нет.",
|
||||
"× {{count}} days without edits": "× {{count}} дн. без правок",
|
||||
"agent: {{value}}": "агент: {{value}}",
|
||||
"Work": "Работа",
|
||||
"Agent": "Агент",
|
||||
"≈ {{hours}}h {{minutes}}m": "≈ {{hours}} ч {{minutes}} мин",
|
||||
"≈ {{hours}}h": "≈ {{hours}} ч",
|
||||
"≈ {{minutes}}m": "≈ {{minutes}} мин",
|
||||
"{{hours}}h {{minutes}}m": "{{hours}} ч {{minutes}} м",
|
||||
"{{hours}}h": "{{hours}} ч",
|
||||
"{{minutes}}m": "{{minutes}} м"
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
|
||||
|
||||
import MessageItem from "./message-item";
|
||||
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.
|
||||
|
||||
@@ -115,89 +114,3 @@ describe("MessageItem markdown memoization", () => {
|
||||
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.
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
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,7 +4,6 @@ import { useTranslation } from "react-i18next";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import ToolCallCard from "@/features/ai-chat/components/tool-call-card.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 ChatStoppedNotice from "@/features/ai-chat/components/chat-stopped-notice.tsx";
|
||||
import { ToolUiPart, isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||
@@ -87,39 +86,17 @@ interface MessageItemProps {
|
||||
* 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
|
||||
* turn only the actively-growing tail part changes its `text`, so every earlier
|
||||
* part hits the memo and skips the expensive canonical parse + DOMPurify pass.
|
||||
* Props are primitives, so React.memo's default shallow compare is exactly right
|
||||
* (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.
|
||||
* part hits the memo and skips the expensive marked + DOMPurify pass. Props are
|
||||
* primitives, so React.memo's default shallow compare is exactly right (the
|
||||
* `text` string is compared by value).
|
||||
*/
|
||||
const MarkdownPart = memo(function MarkdownPart({
|
||||
text,
|
||||
neutralizeInternalLinks,
|
||||
streaming,
|
||||
}: {
|
||||
text: string;
|
||||
neutralizeInternalLinks: boolean;
|
||||
streaming: boolean;
|
||||
}) {
|
||||
if (streaming) {
|
||||
return (
|
||||
<StreamingMarkdownText
|
||||
text={text}
|
||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const html = renderChatMarkdown(text, { neutralizeInternalLinks });
|
||||
if (html) {
|
||||
return (
|
||||
@@ -202,10 +179,47 @@ function MessageItem({
|
||||
{resolveAssistantName(assistantName) ?? t("AI agent")}
|
||||
</Text>
|
||||
{message.parts.map((part, index) => {
|
||||
// Tool parts (`tool-*` / `dynamic-tool`) are template-literal kinds, so
|
||||
// they cannot be a `switch` case; the runtime guard handles them, and the
|
||||
// switch below covers every CLOSED (literal-typed) part kind with a
|
||||
// compile-time exhaustiveness check in its default.
|
||||
if (part.type === "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 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)) {
|
||||
return (
|
||||
<ToolCallCard
|
||||
@@ -218,76 +232,7 @@ function MessageItem({
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
{/* A persisted turn error (server stored it in metadata.error). Rendered
|
||||
here so it survives a thread remount and shows in reopened history. */}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
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>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
formatHeadline,
|
||||
formatDayTotal,
|
||||
formatGapMinutes,
|
||||
} from "./format-work-time";
|
||||
|
||||
const MIN = 60 * 1000;
|
||||
// Fake translator: renders the key with {{tokens}} substituted, so the tests
|
||||
// assert the rounding + branch selection without depending on the i18n catalogue.
|
||||
const t = (key: string, opts?: Record<string, unknown>) =>
|
||||
key.replace(/\{\{(\w+)\}\}/g, (_, k) => String(opts?.[k] ?? ""));
|
||||
|
||||
describe("formatHeadline", () => {
|
||||
it("prefixes ≈ and rounds to a 5-minute step", () => {
|
||||
expect(formatHeadline(4 * 60 * MIN + 27 * MIN, t)).toBe("≈ 4h 25m");
|
||||
expect(formatHeadline(90 * MIN, t)).toBe("≈ 1h 30m");
|
||||
});
|
||||
|
||||
it("shows hours only / minutes only cleanly", () => {
|
||||
expect(formatHeadline(120 * MIN, t)).toBe("≈ 2h");
|
||||
expect(formatHeadline(35 * MIN, t)).toBe("≈ 35m");
|
||||
});
|
||||
|
||||
it("floors a tiny non-zero estimate to 5m, never 0", () => {
|
||||
expect(formatHeadline(2 * MIN, t)).toBe("≈ 5m");
|
||||
});
|
||||
|
||||
it("empty string for zero (widget hidden)", () => {
|
||||
expect(formatHeadline(0, t)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDayTotal", () => {
|
||||
it('renders "h m" and shows — for empty days', () => {
|
||||
expect(formatDayTotal(3 * 60 * MIN + 17 * MIN, t)).toBe("3h 17m");
|
||||
expect(formatDayTotal(0, t)).toBe("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatGapMinutes", () => {
|
||||
it("converts the tGap ms threshold to whole minutes", () => {
|
||||
expect(formatGapMinutes(15 * MIN)).toBe(15);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
// #395 — display formatting for the work-time estimate. Pure functions that take
|
||||
// a translator so ru-RU / en-US wording lives in the i18n catalogue and the
|
||||
// rounding logic stays unit-testable.
|
||||
|
||||
type Translate = (key: string, opts?: Record<string, unknown>) => string;
|
||||
|
||||
const MIN = 60 * 1000;
|
||||
|
||||
function hm(totalMinutes: number): { hours: number; minutes: number } {
|
||||
return {
|
||||
hours: Math.floor(totalMinutes / 60),
|
||||
minutes: totalMinutes % 60,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Headline number (§6.1): an ESTIMATE, so rounded to a coarse 5-minute step and
|
||||
* prefixed with "≈". A non-zero-but-tiny estimate floors to 5m rather than
|
||||
* rounding down to "0" (which would read as "no work"). Zero → empty string
|
||||
* (the caller hides the widget).
|
||||
*/
|
||||
export function formatHeadline(workMs: number, t: Translate): string {
|
||||
if (workMs <= 0) return "";
|
||||
let minutes = Math.round(workMs / MIN / 5) * 5;
|
||||
if (minutes === 0) minutes = 5;
|
||||
const { hours, minutes: m } = hm(minutes);
|
||||
if (hours > 0 && m > 0) return t("≈ {{hours}}h {{minutes}}m", { hours, minutes: m });
|
||||
if (hours > 0) return t("≈ {{hours}}h", { hours });
|
||||
return t("≈ {{minutes}}m", { minutes: m });
|
||||
}
|
||||
|
||||
/** Per-day sum (§6.2), rounded to the minute. Zero → "—". */
|
||||
export function formatDayTotal(activeMs: number, t: Translate): string {
|
||||
if (activeMs <= 0) return "—";
|
||||
const minutes = Math.max(1, Math.round(activeMs / MIN));
|
||||
const { hours, minutes: m } = hm(minutes);
|
||||
if (hours > 0 && m > 0) return t("{{hours}}h {{minutes}}m", { hours, minutes: m });
|
||||
if (hours > 0) return t("{{hours}}h", { hours });
|
||||
return t("{{minutes}}m", { minutes: m });
|
||||
}
|
||||
|
||||
/** The inactivity threshold, for the "estimate · gap = N min" caption. */
|
||||
export function formatGapMinutes(tGapMs: number): number {
|
||||
return Math.round(tGapMs / MIN);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import { IPageWorkTime } from "./work-time.types";
|
||||
import { getPageWorkTime, viewerTimezone } from "./work-time-service";
|
||||
|
||||
const WORK_TIME_STALE_TIME = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* #395 — the "time worked on this article" estimate + per-day punch-card
|
||||
* buckets. The buckets are computed server-side in the viewer's timezone (so a
|
||||
* midnight-crossing session lands on the right calendar day for the reader).
|
||||
* `enabled` is opt-in so the (cheap but non-trivial) projection query only fires
|
||||
* when the number is actually shown.
|
||||
*/
|
||||
export function usePageWorkTime(
|
||||
pageId: string,
|
||||
enabled = true,
|
||||
): UseQueryResult<IPageWorkTime, Error> {
|
||||
const tz = viewerTimezone();
|
||||
return useQuery({
|
||||
queryKey: ["page-work-time", pageId, tz],
|
||||
queryFn: () => getPageWorkTime(pageId, tz),
|
||||
enabled: enabled && !!pageId,
|
||||
staleTime: WORK_TIME_STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
import { IPageWorkTime, IPerDay, IDayWindow } from "./work-time.types";
|
||||
import {
|
||||
formatDayTotal,
|
||||
formatGapMinutes,
|
||||
formatHeadline,
|
||||
} from "./format-work-time";
|
||||
import classes from "./work-time.module.css";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
// Collapse a run of this many (or more) consecutive edit-free days into a single
|
||||
// "× N days" separator (§6.2 long-range) — the row is still always one day.
|
||||
const EMPTY_RUN_COLLAPSE = 8;
|
||||
|
||||
type Row =
|
||||
| { type: "day"; day: IPerDay }
|
||||
| { type: "gap"; count: number };
|
||||
|
||||
function collapseEmptyRuns(perDay: IPerDay[]): Row[] {
|
||||
const rows: Row[] = [];
|
||||
let emptyRun: IPerDay[] = [];
|
||||
const flush = () => {
|
||||
if (emptyRun.length >= EMPTY_RUN_COLLAPSE) {
|
||||
rows.push({ type: "gap", count: emptyRun.length });
|
||||
} else {
|
||||
for (const d of emptyRun) rows.push({ type: "day", day: d });
|
||||
}
|
||||
emptyRun = [];
|
||||
};
|
||||
for (const d of perDay) {
|
||||
if (d.activeMs === 0 && d.agentMs === 0) {
|
||||
emptyRun.push(d);
|
||||
} else {
|
||||
flush();
|
||||
rows.push({ type: "day", day: d });
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return rows;
|
||||
}
|
||||
|
||||
function dayHeading(day: number): string {
|
||||
return new Date(day).toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
}
|
||||
|
||||
function DayTrack({
|
||||
day,
|
||||
pSingle,
|
||||
}: {
|
||||
day: IPerDay;
|
||||
pSingle: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const ticks = [6, 12, 18];
|
||||
return (
|
||||
<div className={classes.row}>
|
||||
<span className={classes.dayLabel}>{dayHeading(day.day)}</span>
|
||||
<div className={classes.track}>
|
||||
{ticks.map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
className={classes.hourTick}
|
||||
style={{ left: `${(h / 24) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
{day.windows.map((w: IDayWindow, i) => {
|
||||
const leftPct = ((w.start - day.day) / DAY_MS) * 100;
|
||||
const widthPct = ((w.end - w.start) / DAY_MS) * 100;
|
||||
const isSingle = w.end - w.start <= pSingle;
|
||||
const cls = [
|
||||
classes.window,
|
||||
w.class === "work" ? classes.windowWork : classes.windowAgent,
|
||||
isSingle ? classes.windowSingle : "",
|
||||
].join(" ");
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cls}
|
||||
style={{
|
||||
left: `${Math.max(0, Math.min(100, leftPct))}%`,
|
||||
width: `${Math.max(0, Math.min(100, widthPct))}%`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className={classes.daySum}>
|
||||
{formatDayTotal(day.activeMs, t)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: IPageWorkTime;
|
||||
}
|
||||
|
||||
export default function WorkTimePunchCard({ data }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const rows = useMemo(() => collapseEmptyRuns(data.perDay), [data.perDay]);
|
||||
const gapMin = formatGapMinutes(data.config.tGap);
|
||||
|
||||
if (data.workMs <= 0 && data.agentOnlyMs <= 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed" py="md">
|
||||
{t("No editing activity recorded yet.")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group gap="lg">
|
||||
<Text size="sm" fw={500}>
|
||||
{formatHeadline(data.workMs, t)}
|
||||
</Text>
|
||||
{data.agentOnlyMs > 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("agent: {{value}}", { value: formatHeadline(data.agentOnlyMs, t) })}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group gap="md">
|
||||
<Text size="xs" c="dimmed">
|
||||
<span
|
||||
className={`${classes.legendSwatch} ${classes.windowWork}`}
|
||||
style={{ marginRight: 4 }}
|
||||
/>
|
||||
{t("Work")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
<span
|
||||
className={`${classes.legendSwatch} ${classes.windowAgent}`}
|
||||
style={{ marginRight: 4 }}
|
||||
/>
|
||||
{t("Agent")}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
{rows.map((row, i) =>
|
||||
row.type === "day" ? (
|
||||
<DayTrack
|
||||
key={row.day.dayISO}
|
||||
day={row.day}
|
||||
pSingle={data.config.pSingle}
|
||||
/>
|
||||
) : (
|
||||
<div key={`gap-${i}`} className={classes.gapRow}>
|
||||
{t("× {{count}} days without edits", { count: row.count })}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
{t("Estimate · timezone {{tz}} · inactivity gap {{gap}} min", {
|
||||
tz: data.tz,
|
||||
gap: gapMin,
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import api from "@/lib/api-client";
|
||||
import { IPageWorkTime } from "./work-time.types";
|
||||
|
||||
/** The viewer's IANA timezone (browser locale) — the punch-card lays days out
|
||||
* in "my evenings", per §6.3/§10. Falls back to UTC if the runtime hides it. */
|
||||
export function viewerTimezone(): string {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||||
} catch {
|
||||
return "UTC";
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPageWorkTime(
|
||||
pageId: string,
|
||||
tz: string,
|
||||
): Promise<IPageWorkTime> {
|
||||
const req = await api.post<IPageWorkTime>("/pages/history/time", {
|
||||
pageId,
|
||||
tz,
|
||||
});
|
||||
return req.data;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Modal, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconClockHour4 } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePageWorkTime } from "./use-page-work-time";
|
||||
import { formatGapMinutes, formatHeadline } from "./format-work-time";
|
||||
import WorkTimePunchCard from "./work-time-punch-card";
|
||||
|
||||
interface Props {
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* #395 — the clickable "time worked on this article" headline (§6.1). Renders
|
||||
* the `work` estimate with a "≈" sign and the inactivity threshold in a tooltip
|
||||
* (it is an estimate, not a stopwatch). Clicking opens the daily punch-card
|
||||
* (§6.2). Renders nothing until there is a non-zero human OR agent estimate, so a
|
||||
* brand-new / never-edited page shows no widget. For an agent-only-edited page
|
||||
* (workMs===0, agentOnlyMs>0) the headline shows the agent estimate (labelled
|
||||
* `agent:`, matching the punch-card) so the punch-card stays reachable (#395:
|
||||
* "how much a HUMAN and separately the AGENT").
|
||||
*/
|
||||
export default function WorkTimeStat({ pageId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const { data } = usePageWorkTime(pageId);
|
||||
|
||||
if (!data || (data.workMs <= 0 && data.agentOnlyMs <= 0)) return null;
|
||||
|
||||
const agentOnly = data.workMs <= 0;
|
||||
const label = agentOnly
|
||||
? t("agent: {{value}}", { value: formatHeadline(data.agentOnlyMs, t) })
|
||||
: formatHeadline(data.workMs, t);
|
||||
const gapMin = formatGapMinutes(data.config.tGap);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
label={t("Estimated time worked (inactivity gap {{gap}} min)", {
|
||||
gap: gapMin,
|
||||
})}
|
||||
position="bottom"
|
||||
>
|
||||
<UnstyledButton
|
||||
onClick={open}
|
||||
aria-label={t("Show time worked on this page")}
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
|
||||
>
|
||||
<IconClockHour4 size={14} />
|
||||
{label}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
</Tooltip>
|
||||
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title={t("Time worked on this article")}
|
||||
size="lg"
|
||||
>
|
||||
<WorkTimePunchCard data={data} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/* #395 — 24h × days punch-card. Custom CSS segments on a fixed 24-hour track
|
||||
(position = offset-in-day / 24h, width = duration / 24h), no chart library. */
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 96px 1fr 64px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.dayLabel {
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
color: var(--mantine-color-dimmed);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Faint hour grid so the eye can read "morning vs evening". */
|
||||
.hourTick {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-3),
|
||||
var(--mantine-color-dark-4)
|
||||
);
|
||||
}
|
||||
|
||||
.window {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
border-radius: 3px;
|
||||
min-width: 3px;
|
||||
}
|
||||
|
||||
.windowWork {
|
||||
background-color: var(--mantine-color-blue-5);
|
||||
}
|
||||
|
||||
.windowAgent {
|
||||
background-color: var(--mantine-color-grape-5);
|
||||
}
|
||||
|
||||
/* A lone single-sample (P_single) window: minimal + dimmed, so it neither
|
||||
vanishes nor fakes dense work (§6.2). */
|
||||
.windowSingle {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.daySum {
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gapRow {
|
||||
padding: 6px 0 6px 108px;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
color: var(--mantine-color-dimmed);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.legendSwatch {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// #395 — client-side mirror of the server work-time payload
|
||||
// (apps/server/src/core/page/work-time). Shapes returned by POST /pages/history/time.
|
||||
|
||||
export type WorkSessionClass = "work" | "agent_only";
|
||||
|
||||
export interface IDayWindow {
|
||||
start: number;
|
||||
end: number;
|
||||
class: WorkSessionClass;
|
||||
}
|
||||
|
||||
export interface IPerDay {
|
||||
day: number;
|
||||
dayISO: string;
|
||||
activeMs: number;
|
||||
agentMs: number;
|
||||
windows: IDayWindow[];
|
||||
}
|
||||
|
||||
export interface IWorkTimeConfig {
|
||||
tGap: number;
|
||||
agentTGap: number;
|
||||
pIn: number;
|
||||
pOut: number;
|
||||
pSingle: number;
|
||||
excludeGit: boolean;
|
||||
burstCapMs?: number;
|
||||
dedupRoundMs: number;
|
||||
}
|
||||
|
||||
export interface IPageWorkTime {
|
||||
workMs: number;
|
||||
agentOnlyMs: number;
|
||||
perDay: IPerDay[];
|
||||
config: IWorkTimeConfig;
|
||||
tz: string;
|
||||
}
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
import { formattedDate } from "@/lib/time.ts";
|
||||
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
|
||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||
import WorkTimeStat from "@/features/page-history/work-time/work-time-stat.tsx";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
import {
|
||||
useFavoriteIds,
|
||||
@@ -265,6 +266,8 @@ function PageActionMenu({ readOnly, onSaveVersion }: PageActionMenuProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{page?.id && <WorkTimeStat pageId={page.id} />}
|
||||
|
||||
<Menu
|
||||
shadow="xl"
|
||||
position="bottom-end"
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
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 { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard';
|
||||
import { AI_CHAT_THROTTLER } from '../../integrations/throttle/throttler-names';
|
||||
@@ -44,8 +43,6 @@ import {
|
||||
AiChatRunHooks,
|
||||
AiChatService,
|
||||
AiChatStreamBody,
|
||||
rowHasInlineParts,
|
||||
hydrateAssistantParts,
|
||||
} from './ai-chat.service';
|
||||
import { AiChatRunService } from './ai-chat-run.service';
|
||||
import { AiTranscriptionService } from './ai-transcription.service';
|
||||
@@ -132,39 +129,8 @@ export class AiChatController {
|
||||
// production. Only touched on the resumable-stream (flag-on) path.
|
||||
private readonly streamRegistry?: AiChatStreamRegistryService,
|
||||
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). */
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('chats')
|
||||
@@ -218,17 +184,11 @@ export class AiChatController {
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
await this.assertOwnedChat(dto.chatId, user, workspace);
|
||||
const page = await this.aiChatMessageRepo.findByChat(
|
||||
return this.aiChatMessageRepo.findByChat(
|
||||
dto.chatId,
|
||||
workspace.id,
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -265,10 +225,7 @@ export class AiChatController {
|
||||
workspace.id,
|
||||
);
|
||||
return {
|
||||
// #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),
|
||||
rows,
|
||||
cursor,
|
||||
run: run ? { id: run.id, status: run.status } : null,
|
||||
};
|
||||
@@ -290,10 +247,8 @@ export class AiChatController {
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<{ markdown: string }> {
|
||||
const chat = await this.assertOwnedChat(dto.chatId, user, workspace);
|
||||
const rows = await this.withReconstructedParts(
|
||||
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.
|
||||
const rows = await this.aiChatMessageRepo.findAllByChat(
|
||||
dto.chatId,
|
||||
workspace.id,
|
||||
);
|
||||
const markdown = buildChatMarkdown({
|
||||
@@ -333,13 +288,7 @@ export class AiChatController {
|
||||
workspace.id,
|
||||
)
|
||||
: undefined;
|
||||
// #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 };
|
||||
return { run, message: message ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,7 +22,6 @@ import { AiSettingsService } from '../../integrations/ai/ai-settings.service';
|
||||
import { describeProviderError } from '../../integrations/ai/ai-error.util';
|
||||
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 { 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 { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
@@ -519,12 +518,6 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
|
||||
// constructions compile unchanged; Nest always injects the real singleton, so
|
||||
// reconcile sees the SAME in-memory active/zombie maps the runner mutates.
|
||||
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
|
||||
@@ -1121,34 +1114,8 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
|
||||
chatId,
|
||||
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 }> = [
|
||||
...replayHistory.map(rowToUiMessage),
|
||||
...oldHistory.map(rowToUiMessage),
|
||||
{
|
||||
id: 'pending-user',
|
||||
role: 'user',
|
||||
@@ -1187,9 +1154,7 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
|
||||
// hint — confirm it against the persisted history (the preceding assistant
|
||||
// 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
|
||||
// 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.
|
||||
// already in `messages` (the aborted assistant row replays via findRecent).
|
||||
// Append the new user turn (shape-only) so index -2 is the prior assistant.
|
||||
const interrupted = isInterruptResume(
|
||||
[...oldHistory, { role: 'user', status: null, metadata: null }],
|
||||
@@ -1594,57 +1559,17 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
|
||||
// connection when finalize runs, so the SQL `WHERE status='streaming'`
|
||||
// (not this flag) is what prevents it clobbering the terminal row.
|
||||
if (finalized) return null;
|
||||
// The count derives from capturedSteps.length at THIS instant, so the
|
||||
// returned value is EXACTLY the persisted `stepsPersisted` the ring rotates
|
||||
// on (whether we take the append-persist path or the legacy fallback).
|
||||
const stepsPersisted = capturedSteps.length;
|
||||
// Build the flush ONCE so the returned count is EXACTLY the persisted
|
||||
// `stepsPersisted` (both derive from capturedSteps.length at this instant).
|
||||
const flushed = flushAssistant(capturedSteps, '', 'streaming', {
|
||||
pageChanged,
|
||||
partsCache,
|
||||
});
|
||||
const stepsPersisted = flushed.metadata.stepsPersisted as number;
|
||||
try {
|
||||
if (this.aiChatRunStepRepo) {
|
||||
// #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 },
|
||||
);
|
||||
}
|
||||
await this.aiChatMessageRepo.update(assistantId, workspace.id, flushed, {
|
||||
onlyIfStreaming: true,
|
||||
});
|
||||
return stepsPersisted;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
@@ -2824,122 +2749,6 @@ export function rowToUiMessage(row: AiChatMessage): Omit<UIMessage, 'id'> & {
|
||||
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
|
||||
* SAME shape the assistant repo insert/update consume (content + toolCalls +
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
@@ -47,6 +48,17 @@ export class PageInfoDto extends PageIdDto {
|
||||
format?: ContentFormat;
|
||||
}
|
||||
|
||||
export class PageWorkTimeDto extends PageIdDto {
|
||||
// Viewer IANA timezone for the per-day punch-card buckets (§6.3). Optional —
|
||||
// falls back to UTC server-side. Length-capped so a bogus value cannot bloat
|
||||
// the request; the value is only ever handed to Intl.DateTimeFormat, which
|
||||
// throws on an unknown zone (caught by the controller → 400).
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
tz?: string;
|
||||
}
|
||||
|
||||
export class DeletePageDto extends PageIdDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PageController } from './page.controller';
|
||||
|
||||
// Direct instantiation with stub deps. The Test.createTestingModule form failed
|
||||
@@ -22,4 +27,88 @@ describe('PageController', () => {
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
// #395 — the work-time endpoint must be gated exactly like /history.
|
||||
describe('getPageWorkTime', () => {
|
||||
const user = { id: 'u1' } as any;
|
||||
|
||||
function build(overrides: {
|
||||
page?: any;
|
||||
validate?: jest.Mock;
|
||||
compute?: jest.Mock;
|
||||
}) {
|
||||
const pageRepo = { findById: jest.fn().mockResolvedValue(overrides.page) };
|
||||
const pageAccessService = {
|
||||
validateCanView: overrides.validate ?? jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const pageHistoryService = {
|
||||
computeWorkTime:
|
||||
overrides.compute ?? jest.fn().mockResolvedValue({ workMs: 0 }),
|
||||
};
|
||||
const c = new PageController(
|
||||
{} as any,
|
||||
pageRepo as any,
|
||||
pageHistoryService as any,
|
||||
{} as any,
|
||||
pageAccessService as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
);
|
||||
return { c, pageRepo, pageAccessService, pageHistoryService };
|
||||
}
|
||||
|
||||
it('404s when the page does not exist', async () => {
|
||||
const { c } = build({ page: null });
|
||||
await expect(
|
||||
c.getPageWorkTime({ pageId: 'p1' } as any, user),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('enforces validateCanView before computing, then delegates with tz', async () => {
|
||||
const validate = jest.fn().mockResolvedValue(undefined);
|
||||
const compute = jest.fn().mockResolvedValue({ workMs: 42 });
|
||||
const { c } = build({ page: { id: 'pg' }, validate, compute });
|
||||
const out = await c.getPageWorkTime(
|
||||
{ pageId: 'pg', tz: 'Europe/Moscow' } as any,
|
||||
user,
|
||||
);
|
||||
expect(validate).toHaveBeenCalledWith({ id: 'pg' }, user);
|
||||
expect(compute).toHaveBeenCalledWith('pg', 'Europe/Moscow');
|
||||
expect(out).toEqual({ workMs: 42 });
|
||||
});
|
||||
|
||||
it('propagates a denied view gate and does NOT reach compute (security)', async () => {
|
||||
// If validateCanView is moved AFTER computeWorkTime, the timeline of a page
|
||||
// the caller may not see would be read/estimated before the gate — this
|
||||
// locks the order: a rejecting gate must short-circuit before any compute.
|
||||
const validate = jest.fn().mockRejectedValue(new ForbiddenException());
|
||||
const compute = jest.fn().mockResolvedValue({ workMs: 1 });
|
||||
const { c, pageHistoryService } = build({
|
||||
page: { id: 'pg' },
|
||||
validate,
|
||||
compute,
|
||||
});
|
||||
await expect(
|
||||
c.getPageWorkTime({ pageId: 'pg' } as any, user),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(pageHistoryService.computeWorkTime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps an unknown-timezone RangeError to a 400', async () => {
|
||||
const compute = jest.fn().mockRejectedValue(new RangeError('bad tz'));
|
||||
const { c } = build({ page: { id: 'pg' }, compute });
|
||||
await expect(
|
||||
c.getPageWorkTime({ pageId: 'pg', tz: 'X/Y' } as any, user),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('does not swallow a non-RangeError from the service', async () => {
|
||||
const compute = jest.fn().mockRejectedValue(new Error('db down'));
|
||||
const { c } = build({ page: { id: 'pg' }, compute });
|
||||
await expect(
|
||||
c.getPageWorkTime({ pageId: 'pg' } as any, user),
|
||||
).rejects.toThrow('db down');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
PageHistoryIdDto,
|
||||
PageIdDto,
|
||||
PageInfoDto,
|
||||
PageWorkTimeDto,
|
||||
} from './dto/page.dto';
|
||||
import { PageHistoryService } from './services/page-history.service';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
@@ -524,6 +525,32 @@ export class PageController {
|
||||
return this.pageHistoryService.findHistoryByPageId(page.id, pagination);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/history/time')
|
||||
async getPageWorkTime(
|
||||
@Body() dto: PageWorkTimeDto,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
// Same view gate as /history and /history/info.
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
try {
|
||||
return await this.pageHistoryService.computeWorkTime(page.id, dto.tz);
|
||||
} catch (e) {
|
||||
// Intl.DateTimeFormat throws RangeError on an unknown IANA zone; surface
|
||||
// it as a 400 rather than a 500.
|
||||
if (e instanceof RangeError) {
|
||||
throw new BadRequestException('Invalid timezone');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/history/info')
|
||||
async getPageHistoryInfo(
|
||||
|
||||
@@ -3,6 +3,23 @@ import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
|
||||
import { PageHistory } from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||
import {
|
||||
computeWorkTime,
|
||||
bucketByDay,
|
||||
DEFAULT_WORK_TIME_CONFIG,
|
||||
WorkTimeConfig,
|
||||
PerDay,
|
||||
} from '../work-time';
|
||||
|
||||
export interface PageWorkTime {
|
||||
workMs: number;
|
||||
agentOnlyMs: number;
|
||||
perDay: PerDay[];
|
||||
/** the config actually used, so the UI can show "≈" + the T_gap threshold. */
|
||||
config: WorkTimeConfig;
|
||||
/** the tz the per-day buckets were computed in (echoed back for the label). */
|
||||
tz: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PageHistoryService {
|
||||
@@ -23,4 +40,33 @@ export class PageHistoryService {
|
||||
paginationOptions,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* #395 — estimate time worked on a page (§5) and bucket it into the viewer's
|
||||
* calendar days for the punch-card (§6.3). Reads only the cheap history
|
||||
* projection (no `content`); the estimate itself is a pure, deterministic
|
||||
* function so it is unit-tested exhaustively without a DB.
|
||||
*
|
||||
* `tz` is the viewer's IANA zone (browser locale) — it moves which day a
|
||||
* session lands in and where its windows sit, but never the total (§10).
|
||||
*/
|
||||
async computeWorkTime(
|
||||
pageId: string,
|
||||
tz = 'UTC',
|
||||
config?: Partial<WorkTimeConfig>,
|
||||
): Promise<PageWorkTime> {
|
||||
const rows = await this.pageHistoryRepo.findTimelineByPageId(pageId);
|
||||
const result = computeWorkTime(rows, config);
|
||||
const usedConfig: WorkTimeConfig = { ...DEFAULT_WORK_TIME_CONFIG, ...config };
|
||||
// `bucketByDay` consumes the pure core's un-bucketed sessions here; the
|
||||
// full session list is NOT shipped on the response (no client reads it).
|
||||
const perDay = bucketByDay(result.sessions, tz);
|
||||
return {
|
||||
workMs: result.workMs,
|
||||
agentOnlyMs: result.agentOnlyMs,
|
||||
perDay,
|
||||
config: usedConfig,
|
||||
tz,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { bucketByDay, zonedDayStart } from './bucket-by-day';
|
||||
import { computeWorkTime } from './compute-work-time';
|
||||
import { WorkSession, TimelineSample } from './work-time.types';
|
||||
|
||||
const MIN = 60 * 1000;
|
||||
const HOUR = 60 * MIN;
|
||||
|
||||
function work(start: number, end: number): WorkSession {
|
||||
return { start, end, class: 'work' };
|
||||
}
|
||||
function agent(start: number, end: number): WorkSession {
|
||||
return { start, end, class: 'agent_only' };
|
||||
}
|
||||
|
||||
function sumActive(perDay: ReturnType<typeof bucketByDay>): number {
|
||||
return perDay.reduce((a, d) => a + d.activeMs, 0);
|
||||
}
|
||||
|
||||
describe('bucketByDay', () => {
|
||||
it('Σ activeMs == workMs — the §6.3 consistency invariant', () => {
|
||||
const rows: TimelineSample[] = [
|
||||
{ createdAt: '2026-07-04T03:40:00Z', lastUpdatedById: 'h', lastUpdatedSource: 'user', lastUpdatedAiChatId: null, kind: null },
|
||||
{ createdAt: '2026-07-04T03:49:00Z', lastUpdatedById: 'h', lastUpdatedSource: 'user', lastUpdatedAiChatId: null, kind: null },
|
||||
{ createdAt: '2026-07-04T18:11:00Z', lastUpdatedById: 'h', lastUpdatedSource: 'user', lastUpdatedAiChatId: null, kind: null },
|
||||
{ createdAt: '2026-07-06T15:34:00Z', lastUpdatedById: 'h', lastUpdatedSource: 'user', lastUpdatedAiChatId: null, kind: null },
|
||||
];
|
||||
const r = computeWorkTime(rows);
|
||||
const perDay = bucketByDay(r.sessions, 'UTC');
|
||||
expect(sumActive(perDay)).toBe(r.workMs);
|
||||
});
|
||||
|
||||
it('empty input → no days', () => {
|
||||
expect(bucketByDay([], 'UTC')).toEqual([]);
|
||||
});
|
||||
|
||||
it('midnight-crossing session splits across two days, sum preserved (§9#9)', () => {
|
||||
const start = Date.UTC(2026, 0, 10, 23, 14);
|
||||
const end = Date.UTC(2026, 0, 11, 0, 40);
|
||||
const perDay = bucketByDay([work(start, end)], 'UTC');
|
||||
expect(perDay).toHaveLength(2);
|
||||
expect(perDay[0].dayISO).toBe('2026-01-10');
|
||||
expect(perDay[1].dayISO).toBe('2026-01-11');
|
||||
expect(perDay[0].activeMs).toBe(46 * MIN); // 23:14 → 24:00
|
||||
expect(perDay[1].activeMs).toBe(40 * MIN); // 00:00 → 00:40
|
||||
expect(sumActive(perDay)).toBe(end - start);
|
||||
});
|
||||
|
||||
it('empty days between active days are emitted, not skipped (§9#12)', () => {
|
||||
const d1 = work(Date.UTC(2026, 0, 10, 10, 0), Date.UTC(2026, 0, 10, 11, 0));
|
||||
const d3 = work(Date.UTC(2026, 0, 12, 10, 0), Date.UTC(2026, 0, 12, 11, 0));
|
||||
const perDay = bucketByDay([d1, d3], 'UTC');
|
||||
expect(perDay.map((d) => d.dayISO)).toEqual([
|
||||
'2026-01-10',
|
||||
'2026-01-11',
|
||||
'2026-01-12',
|
||||
]);
|
||||
expect(perDay[1].activeMs).toBe(0);
|
||||
expect(perDay[1].windows).toEqual([]);
|
||||
});
|
||||
|
||||
it('agent_only windows are drawn but excluded from activeMs', () => {
|
||||
const w = work(Date.UTC(2026, 0, 10, 9, 0), Date.UTC(2026, 0, 10, 10, 0));
|
||||
const a = agent(Date.UTC(2026, 0, 10, 14, 0), Date.UTC(2026, 0, 10, 14, 30));
|
||||
const perDay = bucketByDay([w, a], 'UTC');
|
||||
expect(perDay).toHaveLength(1);
|
||||
expect(perDay[0].activeMs).toBe(1 * HOUR);
|
||||
expect(perDay[0].agentMs).toBe(30 * MIN);
|
||||
expect(perDay[0].windows.map((x) => x.class)).toEqual(['work', 'agent_only']);
|
||||
});
|
||||
|
||||
it('work and agent_only are unioned SEPARATELY (agent does not swallow work)', () => {
|
||||
// Overlapping work + agent windows on the same day.
|
||||
const w = work(Date.UTC(2026, 0, 10, 9, 0), Date.UTC(2026, 0, 10, 11, 0));
|
||||
const a = agent(Date.UTC(2026, 0, 10, 10, 0), Date.UTC(2026, 0, 10, 12, 0));
|
||||
const perDay = bucketByDay([w, a], 'UTC');
|
||||
expect(perDay[0].activeMs).toBe(2 * HOUR);
|
||||
expect(perDay[0].agentMs).toBe(2 * HOUR);
|
||||
});
|
||||
|
||||
it('overlapping same-class sessions are UNIONed, not summed (no double-count)', () => {
|
||||
// Two work sessions that overlap 10:00–10:30 on one day.
|
||||
const a = work(Date.UTC(2026, 0, 10, 9, 0), Date.UTC(2026, 0, 10, 10, 30));
|
||||
const b = work(Date.UTC(2026, 0, 10, 10, 0), Date.UTC(2026, 0, 10, 11, 0));
|
||||
const perDay = bucketByDay([a, b], 'UTC');
|
||||
expect(perDay).toHaveLength(1);
|
||||
// Union 09:00–11:00 = 2h, NOT 90m + 60m = 150m.
|
||||
expect(perDay[0].activeMs).toBe(2 * HOUR);
|
||||
// The drawn windows are also merged to one, so the punch-card cannot render
|
||||
// an overlapping double bar.
|
||||
expect(perDay[0].windows).toHaveLength(1);
|
||||
expect(perDay[0].windows[0].start).toBe(a.start);
|
||||
expect(perDay[0].windows[0].end).toBe(b.end);
|
||||
});
|
||||
|
||||
it('DST fall-back: a full 25-hour day still balances (§9#14)', () => {
|
||||
// America/New_York ends DST 2026-11-01 (25h day).
|
||||
const tz = 'America/New_York';
|
||||
const dayStart = zonedDayStart(Date.UTC(2026, 10, 1, 12, 0), tz);
|
||||
const nextStart = zonedDayStart(dayStart + 26 * HOUR, tz);
|
||||
expect(nextStart - dayStart).toBe(25 * HOUR);
|
||||
const perDay = bucketByDay([work(dayStart, nextStart)], tz);
|
||||
expect(perDay).toHaveLength(1);
|
||||
expect(perDay[0].dayISO).toBe('2026-11-01');
|
||||
expect(perDay[0].activeMs).toBe(25 * HOUR);
|
||||
expect(sumActive(perDay)).toBe(nextStart - dayStart);
|
||||
});
|
||||
|
||||
it('DST spring-forward: a full 23-hour day still balances (§9#14)', () => {
|
||||
// America/New_York starts DST 2026-03-08 (23h day).
|
||||
const tz = 'America/New_York';
|
||||
const dayStart = zonedDayStart(Date.UTC(2026, 2, 8, 12, 0), tz);
|
||||
const nextStart = zonedDayStart(dayStart + 26 * HOUR, tz);
|
||||
expect(nextStart - dayStart).toBe(23 * HOUR);
|
||||
const perDay = bucketByDay([work(dayStart, nextStart)], tz);
|
||||
expect(perDay).toHaveLength(1);
|
||||
expect(perDay[0].activeMs).toBe(23 * HOUR);
|
||||
expect(sumActive(perDay)).toBe(nextStart - dayStart);
|
||||
});
|
||||
|
||||
it('tz changes the day a session lands in but not the total', () => {
|
||||
const start = Date.UTC(2026, 0, 10, 2, 0); // 02:00 UTC
|
||||
const end = Date.UTC(2026, 0, 10, 3, 0);
|
||||
const utc = bucketByDay([work(start, end)], 'UTC');
|
||||
const ny = bucketByDay([work(start, end)], 'America/New_York'); // 21:00 prev day
|
||||
expect(utc[0].dayISO).toBe('2026-01-10');
|
||||
expect(ny[0].dayISO).toBe('2026-01-09');
|
||||
expect(sumActive(utc)).toBe(sumActive(ny));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { WorkSession, PerDay, DayWindow } from './work-time.types';
|
||||
|
||||
/**
|
||||
* Merge intervals into a disjoint, sorted union. Overlapping OR touching
|
||||
* intervals are joined. Empty input → [].
|
||||
*/
|
||||
function union(intervals: Array<[number, number]>): Array<[number, number]> {
|
||||
if (intervals.length === 0) return [];
|
||||
const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
|
||||
const out: Array<[number, number]> = [];
|
||||
let [curStart, curEnd] = sorted[0];
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const [s, e] = sorted[i];
|
||||
if (s <= curEnd) {
|
||||
if (e > curEnd) curEnd = e;
|
||||
} else {
|
||||
out.push([curStart, curEnd]);
|
||||
curStart = s;
|
||||
curEnd = e;
|
||||
}
|
||||
}
|
||||
out.push([curStart, curEnd]);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Cache one Intl formatter per tz — constructing them is comparatively costly.
|
||||
const fmtCache = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
function partsFmt(tz: string): Intl.DateTimeFormat {
|
||||
let fmt = fmtCache.get(tz);
|
||||
if (!fmt) {
|
||||
fmt = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: tz,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
fmtCache.set(tz, fmt);
|
||||
}
|
||||
return fmt;
|
||||
}
|
||||
|
||||
interface WallParts {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hour: number;
|
||||
minute: number;
|
||||
second: number;
|
||||
}
|
||||
|
||||
/** Wall-clock parts of an instant in `tz` (DST-correct, via Intl). */
|
||||
function wallParts(ms: number, tz: string): WallParts {
|
||||
const parts = partsFmt(tz).formatToParts(new Date(ms));
|
||||
const get = (type: string) =>
|
||||
Number(parts.find((p) => p.type === type)?.value ?? '0');
|
||||
let hour = get('hour');
|
||||
// Intl emits "24" for midnight under some engines/locales; normalize to 0.
|
||||
if (hour === 24) hour = 0;
|
||||
return {
|
||||
year: get('year'),
|
||||
month: get('month'),
|
||||
day: get('day'),
|
||||
hour,
|
||||
minute: get('minute'),
|
||||
second: get('second'),
|
||||
};
|
||||
}
|
||||
|
||||
/** tz offset (wall − real) at an instant, in ms. */
|
||||
function offset(ms: number, tz: string): number {
|
||||
const p = wallParts(ms, tz);
|
||||
const asUTC = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
|
||||
return asUTC - ms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Epoch-ms of the local-midnight day start of `ms` in `tz`. DST-correct: takes
|
||||
* the calendar day of the instant, its wall-midnight, then converts back with
|
||||
* the offset that actually applies AT that midnight (refined once). The rare
|
||||
* tz-with-a-DST-transition-exactly-at-midnight case is a documented edge (§9#14).
|
||||
*/
|
||||
export function zonedDayStart(ms: number, tz: string): number {
|
||||
const p = wallParts(ms, tz);
|
||||
const wallMidnightAsUTC = Date.UTC(p.year, p.month - 1, p.day, 0, 0, 0);
|
||||
let start = wallMidnightAsUTC - offset(ms, tz);
|
||||
// Refine with the offset at the computed midnight (DST may differ from `ms`).
|
||||
start = wallMidnightAsUTC - offset(start, tz);
|
||||
return start;
|
||||
}
|
||||
|
||||
/** The next local midnight after `dayStart` (handles 23/25h DST days). */
|
||||
function nextDayStart(dayStart: number, tz: string): number {
|
||||
// +26h always lands inside the NEXT calendar day (day length ∈ [23h,25h]),
|
||||
// never two days ahead; startOf('day') of it is the next midnight.
|
||||
return zonedDayStart(dayStart + 26 * 60 * 60 * 1000, tz);
|
||||
}
|
||||
|
||||
function isoDay(dayStart: number, tz: string): string {
|
||||
const p = wallParts(dayStart, tz);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p.year}-${pad(p.month)}-${pad(p.day)}`;
|
||||
}
|
||||
|
||||
/** Clip a union to [lo, hi) and emit windows of `class`. */
|
||||
function clip(
|
||||
merged: Array<[number, number]>,
|
||||
lo: number,
|
||||
hi: number,
|
||||
cls: DayWindow['class'],
|
||||
): DayWindow[] {
|
||||
const out: DayWindow[] = [];
|
||||
for (const [s, e] of merged) {
|
||||
const start = Math.max(s, lo);
|
||||
const end = Math.min(e, hi);
|
||||
if (end > start) out.push({ start, end, class: cls });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* #395 §6.3 — bucket sessions into calendar days of `tz` for the punch-card.
|
||||
* Pure and deterministic. `work` and `agent_only` are unioned SEPARATELY (else
|
||||
* agent windows would swallow work windows on overlap), then each union is split
|
||||
* at tz midnight boundaries (`startOf('day')` in tz, NOT "+24h" — DST-safe §9#14)
|
||||
* and clipped to each day.
|
||||
*
|
||||
* By construction Σ perDay.activeMs == workMs: the days are a partition of the
|
||||
* `work` union — no loss, no dup, even on 23/25h DST days. `agent_only` windows
|
||||
* are drawn but NOT in activeMs. Empty days between the first and last active day
|
||||
* are emitted (empty track + "—") so the rhythm/pauses stay visible.
|
||||
*/
|
||||
export function bucketByDay(sessions: WorkSession[], tz: string): PerDay[] {
|
||||
const uWork = union(
|
||||
sessions.filter((s) => s.class === 'work').map((s) => [s.start, s.end]),
|
||||
);
|
||||
const uAgent = union(
|
||||
sessions
|
||||
.filter((s) => s.class === 'agent_only')
|
||||
.map((s) => [s.start, s.end]),
|
||||
);
|
||||
|
||||
if (uWork.length === 0 && uAgent.length === 0) return [];
|
||||
|
||||
const minStart = Math.min(
|
||||
uWork.length ? uWork[0][0] : Infinity,
|
||||
uAgent.length ? uAgent[0][0] : Infinity,
|
||||
);
|
||||
const maxEnd = Math.max(
|
||||
uWork.length ? uWork[uWork.length - 1][1] : -Infinity,
|
||||
uAgent.length ? uAgent[uAgent.length - 1][1] : -Infinity,
|
||||
);
|
||||
|
||||
const perDay: PerDay[] = [];
|
||||
let dayStart = zonedDayStart(minStart, tz);
|
||||
// Guard against a pathological non-advancing boundary.
|
||||
let guard = 0;
|
||||
while (dayStart < maxEnd && guard < 100000) {
|
||||
guard++;
|
||||
const dayEnd = nextDayStart(dayStart, tz);
|
||||
const workWin = clip(uWork, dayStart, dayEnd, 'work');
|
||||
const agentWin = clip(uAgent, dayStart, dayEnd, 'agent_only');
|
||||
const activeMs = workWin.reduce((a, w) => a + (w.end - w.start), 0);
|
||||
const agentMs = agentWin.reduce((a, w) => a + (w.end - w.start), 0);
|
||||
const windows = [...workWin, ...agentWin].sort((a, b) => a.start - b.start);
|
||||
perDay.push({
|
||||
day: dayStart,
|
||||
dayISO: isoDay(dayStart, tz),
|
||||
activeMs,
|
||||
agentMs,
|
||||
windows,
|
||||
});
|
||||
dayStart = dayEnd;
|
||||
}
|
||||
return perDay;
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { computeWorkTime } from './compute-work-time';
|
||||
import { bucketByDay } from './bucket-by-day';
|
||||
import { TimelineSample, WorkSession } from './work-time.types';
|
||||
|
||||
const MIN = 60 * 1000;
|
||||
|
||||
/** Union wall-clock of a set of intervals (touching intervals merge). */
|
||||
function unionMs(intervals: Array<[number, number]>): number {
|
||||
if (intervals.length === 0) return 0;
|
||||
const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
|
||||
let total = 0;
|
||||
let [cs, ce] = sorted[0];
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const [s, e] = sorted[i];
|
||||
if (s <= ce) {
|
||||
if (e > ce) ce = e;
|
||||
} else {
|
||||
total += ce - cs;
|
||||
cs = s;
|
||||
ce = e;
|
||||
}
|
||||
}
|
||||
return total + (ce - cs);
|
||||
}
|
||||
|
||||
const ivsOf = (sessions: WorkSession[], cls?: string): Array<[number, number]> =>
|
||||
sessions
|
||||
.filter((x) => cls == null || x.class === cls)
|
||||
.map((x) => [x.start, x.end] as [number, number]);
|
||||
|
||||
function s(
|
||||
iso: string,
|
||||
opts: {
|
||||
source?: string | null;
|
||||
chat?: string | null;
|
||||
kind?: string | null;
|
||||
by?: string | null;
|
||||
} = {},
|
||||
): TimelineSample {
|
||||
return {
|
||||
createdAt: `${iso}Z`,
|
||||
lastUpdatedById: opts.by ?? 'human-1',
|
||||
lastUpdatedSource: opts.source === undefined ? 'user' : opts.source,
|
||||
lastUpdatedAiChatId: opts.chat ?? null,
|
||||
kind: opts.kind ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// §7 config: T_gap=30m, P_in+P_out=10m, P_single=2m.
|
||||
const S7 = { tGap: 30 * MIN, agentTGap: 30 * MIN, pIn: 5 * MIN, pOut: 5 * MIN, pSingle: 2 * MIN };
|
||||
|
||||
describe('computeWorkTime', () => {
|
||||
it('§7 fixture — sessionizes 20-ish samples to ≈1h32m, not the ≈60h naive span', () => {
|
||||
const rows: TimelineSample[] = [
|
||||
// S1: multi-sample morning session
|
||||
s('2026-07-04T03:40:00'),
|
||||
s('2026-07-04T03:45:00'),
|
||||
s('2026-07-04T03:49:00'),
|
||||
// S2: agent burst (one run) then human supervising → class work
|
||||
s('2026-07-04T15:43:00', { source: 'agent', chat: 'c1', kind: 'agent' }),
|
||||
s('2026-07-04T15:47:00', { source: 'agent', chat: 'c1', kind: 'agent' }),
|
||||
s('2026-07-04T15:50:00', { source: 'agent', chat: 'c1', kind: 'agent' }),
|
||||
s('2026-07-04T16:13:00'),
|
||||
// S3: single
|
||||
s('2026-07-04T18:11:00'),
|
||||
// S4: multi-sample evening session
|
||||
s('2026-07-04T19:38:00'),
|
||||
s('2026-07-04T19:44:00'),
|
||||
s('2026-07-04T19:54:00'),
|
||||
// S5 / S6: two singles two days later, 44m apart → two sessions at T_gap=30
|
||||
s('2026-07-06T15:34:00'),
|
||||
s('2026-07-06T16:18:00'),
|
||||
];
|
||||
|
||||
const r = computeWorkTime(rows, S7);
|
||||
|
||||
// 19 + 40 + 2 + 26 + 2 + 2 = 91 minutes.
|
||||
expect(r.workMs).toBe(91 * MIN);
|
||||
expect(r.agentOnlyMs).toBe(0);
|
||||
expect(r.sessions).toHaveLength(6);
|
||||
expect(r.sessions.every((x) => x.class === 'work')).toBe(true);
|
||||
|
||||
const naiveSpan =
|
||||
new Date('2026-07-06T16:18:00Z').getTime() -
|
||||
new Date('2026-07-04T03:40:00Z').getTime();
|
||||
expect(naiveSpan).toBeGreaterThan(60 * 60 * MIN); // ≈60h
|
||||
expect(r.workMs).toBeLessThan(naiveSpan / 30); // dramatically smaller
|
||||
});
|
||||
|
||||
it('n=0 → zero, no sessions', () => {
|
||||
const r = computeWorkTime([]);
|
||||
expect(r).toEqual({ workMs: 0, agentOnlyMs: 0, sessions: [] });
|
||||
});
|
||||
|
||||
it('n=1 human → one P_single work session', () => {
|
||||
const r = computeWorkTime([s('2026-07-04T10:00:00')], S7);
|
||||
expect(r.sessions).toHaveLength(1);
|
||||
expect(r.sessions[0].class).toBe('work');
|
||||
expect(r.workMs).toBe(2 * MIN);
|
||||
expect(r.agentOnlyMs).toBe(0);
|
||||
// pre-roll only: [t − P_single, t]
|
||||
expect(r.sessions[0].end).toBe(new Date('2026-07-04T10:00:00Z').getTime());
|
||||
});
|
||||
|
||||
it('n=1 agent → one P_single agent_only session, work=0 (§9#2)', () => {
|
||||
const r = computeWorkTime(
|
||||
[s('2026-07-04T10:00:00', { source: 'agent', chat: 'c1', kind: 'agent' })],
|
||||
S7,
|
||||
);
|
||||
expect(r.sessions).toHaveLength(1);
|
||||
expect(r.sessions[0].class).toBe('agent_only');
|
||||
expect(r.workMs).toBe(0);
|
||||
expect(r.agentOnlyMs).toBe(2 * MIN);
|
||||
});
|
||||
|
||||
it('MUST close the last session — the newest session is not lost (§9#1)', () => {
|
||||
// Two singles a day apart: without the post-loop close, the 2nd is dropped.
|
||||
const rows = [s('2026-07-04T10:00:00'), s('2026-07-05T10:00:00')];
|
||||
const r = computeWorkTime(rows, S7);
|
||||
expect(r.sessions).toHaveLength(2);
|
||||
const lastStart = Math.max(...r.sessions.map((x) => x.start));
|
||||
expect(lastStart).toBe(
|
||||
new Date('2026-07-05T10:00:00Z').getTime() - 2 * MIN,
|
||||
);
|
||||
expect(r.workMs).toBe(4 * MIN);
|
||||
});
|
||||
|
||||
it('agent-burst collapse: density does not inflate — length = wall-clock', () => {
|
||||
const span = ['00', '01', '02', '03', '04', '05', '06'];
|
||||
const dense: TimelineSample[] = span.map((sec) =>
|
||||
s(`2026-07-04T10:00:${sec}`, { source: 'agent', chat: 'c1', kind: 'agent' }),
|
||||
);
|
||||
const sparse: TimelineSample[] = [
|
||||
s('2026-07-04T10:00:00', { source: 'agent', chat: 'c1', kind: 'agent' }),
|
||||
s('2026-07-04T10:00:06', { source: 'agent', chat: 'c1', kind: 'agent' }),
|
||||
];
|
||||
const rDense = computeWorkTime(dense, S7);
|
||||
const rSparse = computeWorkTime(sparse, S7);
|
||||
// Same 6-second wall-clock span → same estimate regardless of snapshot count.
|
||||
expect(rDense.agentOnlyMs).toBe(rSparse.agentOnlyMs);
|
||||
expect(rDense.sessions).toHaveLength(1);
|
||||
expect(rDense.sessions[0].class).toBe('agent_only');
|
||||
});
|
||||
|
||||
it('supervisory agent time inside a human session counts as work, not agent', () => {
|
||||
const rows = [
|
||||
s('2026-07-04T10:00:00', { source: 'agent', chat: 'c1', kind: 'agent' }),
|
||||
s('2026-07-04T10:05:00', { source: 'agent', chat: 'c1', kind: 'agent' }),
|
||||
s('2026-07-04T10:12:00'), // human within T_gap
|
||||
];
|
||||
const r = computeWorkTime(rows, S7);
|
||||
expect(r.sessions).toHaveLength(1);
|
||||
expect(r.sessions[0].class).toBe('work');
|
||||
expect(r.agentOnlyMs).toBe(0);
|
||||
expect(r.workMs).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('a DIFFERENT aiChatId breaks the burst — two agent runs, idle gap excluded', () => {
|
||||
// Run c1 ends 10:05, run c2 starts 10:20 (15m > agentTGap 7m) → two sessions.
|
||||
const rows = [
|
||||
s('2026-07-04T10:00:00', { source: 'agent', chat: 'c1', kind: 'agent' }),
|
||||
s('2026-07-04T10:05:00', { source: 'agent', chat: 'c1', kind: 'agent' }),
|
||||
s('2026-07-04T10:20:00', { source: 'agent', chat: 'c2', kind: 'agent' }),
|
||||
s('2026-07-04T10:25:00', { source: 'agent', chat: 'c2', kind: 'agent' }),
|
||||
];
|
||||
const r = computeWorkTime(rows); // default agentTGap = 7m
|
||||
expect(r.sessions).toHaveLength(2);
|
||||
expect(r.sessions.every((x) => x.class === 'agent_only')).toBe(true);
|
||||
// The 15m idle gap between the two runs is NOT counted.
|
||||
const run1 = 5 * MIN + 5 * MIN + 5 * MIN; // pIn + span + pOut
|
||||
expect(r.agentOnlyMs).toBe(2 * run1);
|
||||
});
|
||||
|
||||
it('idle pulse (same/null run) is a full activity sample that continues a burst', () => {
|
||||
const rows = [
|
||||
s('2026-07-04T10:00:00', { source: 'agent', chat: 'c1', kind: 'agent' }),
|
||||
// idle flush 4m later, null run id → continues the burst, not a new one
|
||||
s('2026-07-04T10:04:00', { source: 'agent', chat: null, kind: 'idle' }),
|
||||
s('2026-07-04T10:08:00', { source: 'agent', chat: 'c1', kind: 'agent' }),
|
||||
];
|
||||
const r = computeWorkTime(rows);
|
||||
expect(r.sessions).toHaveLength(1);
|
||||
// burst span 10:00→10:08 (+pIn/pOut) = 8 + 10 = 18m
|
||||
expect(r.agentOnlyMs).toBe(18 * MIN);
|
||||
});
|
||||
|
||||
it('a USER-sourced idle breaks an agent burst → session is work, not agent_only', () => {
|
||||
// A human supervision idle inherits source=user (aiChatId:null) and must NOT
|
||||
// be swallowed into the agent burst. Δ=3m is within the default agentTGap so
|
||||
// the two samples stay one session — but its class flips to `work`.
|
||||
const rows = [
|
||||
s('2026-07-04T10:00:00', { source: 'agent', chat: 'c1', kind: 'agent' }),
|
||||
s('2026-07-04T10:03:00', { source: 'user', chat: null, kind: 'idle' }),
|
||||
];
|
||||
const r = computeWorkTime(rows);
|
||||
expect(r.sessions).toHaveLength(1);
|
||||
expect(r.sessions[0].class).toBe('work');
|
||||
expect(r.workMs).toBeGreaterThan(0);
|
||||
// The human idle is NOT captured as agent_only time.
|
||||
expect(r.agentOnlyMs).toBe(0);
|
||||
// Σ over `work` sessions == workMs and Σ over `agent_only` == agentOnlyMs.
|
||||
const sum = (cls: string) =>
|
||||
r.sessions
|
||||
.filter((x) => x.class === cls)
|
||||
.reduce((acc, x) => acc + (x.end - x.start), 0);
|
||||
expect(sum('work')).toBe(r.workMs);
|
||||
expect(sum('agent_only')).toBe(r.agentOnlyMs);
|
||||
});
|
||||
|
||||
it('idle pulse keeps a human writing session visible (not excluded)', () => {
|
||||
const rows = [
|
||||
s('2026-07-04T10:00:00'),
|
||||
s('2026-07-04T10:08:00', { kind: 'idle' }), // pulse within T_gap
|
||||
s('2026-07-04T10:15:00'),
|
||||
];
|
||||
const r = computeWorkTime(rows);
|
||||
expect(r.sessions).toHaveLength(1);
|
||||
expect(r.sessions[0].class).toBe('work');
|
||||
// span 10:00→10:15 + pIn/pOut = 15 + 10 = 25m
|
||||
expect(r.workMs).toBe(25 * MIN);
|
||||
});
|
||||
|
||||
it('git-source samples are excluded (§10 excludeGit)', () => {
|
||||
const rows = [
|
||||
s('2026-07-04T10:00:00', { source: 'git', kind: 'boundary' }),
|
||||
s('2026-07-04T10:01:00', { source: 'git', kind: 'boundary' }),
|
||||
];
|
||||
expect(computeWorkTime(rows).workMs).toBe(0);
|
||||
// ...but honoured off:
|
||||
expect(
|
||||
computeWorkTime(rows, { excludeGit: false }).workMs,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects an invalid config (tGap < pIn + pOut)', () => {
|
||||
expect(() =>
|
||||
computeWorkTime([s('2026-07-04T10:00:00')], {
|
||||
tGap: 5 * MIN,
|
||||
pIn: 5 * MIN,
|
||||
pOut: 5 * MIN,
|
||||
}),
|
||||
).toThrow(/tGap/);
|
||||
});
|
||||
|
||||
it('rejects an invalid config (2·agentTGap < pIn + pOut)', () => {
|
||||
// tGap (default 15m) still ≥ pIn+pOut, so only the 2·agentTGap guard trips.
|
||||
// Without it a short session of one class between two of the other could
|
||||
// produce a NON-adjacent cross-class overlap the adjacent-only clip misses.
|
||||
expect(() =>
|
||||
computeWorkTime([s('2026-07-04T10:00:00')], {
|
||||
agentTGap: 2 * MIN,
|
||||
pIn: 5 * MIN,
|
||||
pOut: 5 * MIN,
|
||||
}),
|
||||
).toThrow(/agentTGap/);
|
||||
});
|
||||
|
||||
// F1 — cross-class double-count. On the DEFAULT config agentTGap (7m) < pIn+pOut
|
||||
// (10m), so a `work` session ending in an agent segment and a nearby separate
|
||||
// `agent_only` run (gap in (7m,10m]) used to produce OVERLAPPING padded
|
||||
// intervals — the same wall-clock counted into BOTH workMs and agentOnlyMs. The
|
||||
// cross-class padding clip must make the two per-class unions disjoint.
|
||||
it('does NOT double-count wall-clock across work/agent_only (§F1)', () => {
|
||||
// user@0s ; agent(chatX)@60s (breaks into a work session with the human) ;
|
||||
// agent(chatY)@560s,590s (a separate agent_only run). Raw gap between the work
|
||||
// session (ends 60s) and the agent run (starts 560s) is 500s ∈ (agentTGap,
|
||||
// pIn+pOut] once padded — the classic overlap window.
|
||||
const rows: TimelineSample[] = [
|
||||
s('2026-07-04T00:00:00'), // user @ 0s
|
||||
s('2026-07-04T00:01:00', { source: 'agent', chat: 'cX', kind: 'agent' }), // @ 60s
|
||||
s('2026-07-04T00:09:20', { source: 'agent', chat: 'cY', kind: 'agent' }), // @ 560s
|
||||
s('2026-07-04T00:09:50', { source: 'agent', chat: 'cY', kind: 'agent' }), // @ 590s
|
||||
];
|
||||
const r = computeWorkTime(rows); // DEFAULT config
|
||||
|
||||
// Both classes present.
|
||||
expect(r.workMs).toBeGreaterThan(0);
|
||||
expect(r.agentOnlyMs).toBeGreaterThan(0);
|
||||
|
||||
// Per-class metrics are exactly their own union (union, not Σ).
|
||||
expect(r.workMs).toBe(unionMs(ivsOf(r.sessions, 'work')));
|
||||
expect(r.agentOnlyMs).toBe(unionMs(ivsOf(r.sessions, 'agent_only')));
|
||||
|
||||
// The F1 invariant: work-union and agent-union are cross-class-disjoint, so
|
||||
// the union of ALL padded intervals equals workMs + agentOnlyMs (no overlap).
|
||||
// With the clip disabled this fails (union < sum by the 100s overlap).
|
||||
expect(unionMs(ivsOf(r.sessions))).toBe(r.workMs + r.agentOnlyMs);
|
||||
});
|
||||
|
||||
// F1 property/fuzz — random timelines across several timezones must uphold the
|
||||
// work-time invariants. Backs the (corrected) PR claim of a real fuzz test.
|
||||
it('property: random timelines uphold union & cross-class-disjoint invariants', () => {
|
||||
// Deterministic LCG (numerical-recipes constants) so a failure is reproducible.
|
||||
let seed = 0x9e3779b9 >>> 0;
|
||||
const rand = () => {
|
||||
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
|
||||
return seed / 0x100000000;
|
||||
};
|
||||
const pick = <T>(arr: T[]): T => arr[Math.floor(rand() * arr.length)];
|
||||
|
||||
const tzs = [
|
||||
'UTC',
|
||||
'America/New_York',
|
||||
'Europe/Moscow',
|
||||
'Australia/Lord_Howe', // 30-min DST offset — a nasty bucket stress
|
||||
];
|
||||
const base = Date.UTC(2026, 5, 1, 0, 0, 0); // 2026-06-01Z
|
||||
const chats = ['c1', 'c2', 'c3'];
|
||||
|
||||
for (let iter = 0; iter < 250; iter++) {
|
||||
const tz = pick(tzs);
|
||||
const n = 2 + Math.floor(rand() * 18); // 2..19 rows
|
||||
const rows: TimelineSample[] = [];
|
||||
// Walk time forward by a random inter-sample gap. The gap distribution is
|
||||
// centred on the DANGEROUS band — a bit under to a bit over pIn+pOut (10m)
|
||||
// AND straddling agentTGap (7m) — so adjacent samples routinely split into
|
||||
// separate sessions whose ±P padding would overlap if a class boundary sits
|
||||
// there. Mixing user/agent classes at these gaps reliably manufactures the
|
||||
// work-ending-in-agent → agent_only cross-class boundary F1 is about, plus
|
||||
// dense within-class runs (occasional 0–2m gaps) that exercise the union.
|
||||
let t = base + Math.floor(rand() * 60 * MIN);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const roll = rand();
|
||||
const gap =
|
||||
roll < 0.25
|
||||
? Math.floor(rand() * 2 * MIN) // dense burst (same-class union)
|
||||
: roll < 0.85
|
||||
? 5 * MIN + Math.floor(rand() * 8 * MIN) // 5–13m: the split band
|
||||
: 20 * MIN + Math.floor(rand() * 40 * MIN); // long idle → new day-ish
|
||||
t += gap;
|
||||
const iso = new Date(t).toISOString().slice(0, 19); // 'YYYY-MM-DDTHH:MM:SS'
|
||||
const isAgent = rand() < 0.5;
|
||||
rows.push(
|
||||
isAgent
|
||||
? s(iso, { source: 'agent', chat: pick(chats), kind: 'agent' })
|
||||
: s(iso, { source: 'user', kind: rand() < 0.3 ? 'idle' : 'manual' }),
|
||||
);
|
||||
}
|
||||
|
||||
const r = computeWorkTime(rows); // DEFAULT config
|
||||
|
||||
const workIvs = ivsOf(r.sessions, 'work');
|
||||
const agentIvs = ivsOf(r.sessions, 'agent_only');
|
||||
|
||||
// (1) each metric is exactly its per-class union (catches a union→Σ regress).
|
||||
expect(r.workMs).toBe(unionMs(workIvs));
|
||||
expect(r.agentOnlyMs).toBe(unionMs(agentIvs));
|
||||
|
||||
// (2) NO cross-class overlap: union(all) == workMs + agentOnlyMs (F1).
|
||||
expect(unionMs(ivsOf(r.sessions))).toBe(r.workMs + r.agentOnlyMs);
|
||||
|
||||
// (3) bucket invariant: Σ per-day activeMs == workMs (§6.3).
|
||||
const perDay = bucketByDay(r.sessions, tz);
|
||||
const sumActive = perDay.reduce((a, d) => a + d.activeMs, 0);
|
||||
expect(sumActive).toBe(r.workMs);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import {
|
||||
TimelineSample,
|
||||
WorkSession,
|
||||
WorkTimeResult,
|
||||
} from './work-time.types';
|
||||
import { WorkTimeConfig, resolveWorkTimeConfig } from './work-time.config';
|
||||
|
||||
/** A normalized activity sample (one history row), createdAt as epoch-ms. */
|
||||
interface NormSample {
|
||||
t: number;
|
||||
isAgent: boolean;
|
||||
aiChatId: string | null;
|
||||
kind: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A collapsed segment: either a scalar sample (t_start == t_end) or an
|
||||
* agent-burst spanning several agent samples of one run (§5.1). It participates
|
||||
* in sessionization as a single "sample".
|
||||
*/
|
||||
interface Segment {
|
||||
tStart: number;
|
||||
tEnd: number;
|
||||
isAgent: boolean;
|
||||
}
|
||||
|
||||
function toMs(v: Date | string | number): number {
|
||||
if (v instanceof Date) return v.getTime();
|
||||
if (typeof v === 'number') return v;
|
||||
return new Date(v).getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize raw rows → sorted, deduped activity samples. `git` is dropped when
|
||||
* configured; every other kind (incl. `idle` — the continuous-work pulse §3) is
|
||||
* a real activity sample. Sort is by createdAt ASC; samples whose timestamps
|
||||
* fall in the same `dedupRoundMs` bucket collapse to one (§9#7: a synchronous
|
||||
* boundary row + the immediate agent snapshot can share a createdAt). A merged
|
||||
* sample is human unless EVERY member is an agent, so supervision never gets
|
||||
* mis-attributed to the agent.
|
||||
*/
|
||||
function normalize(
|
||||
rows: TimelineSample[],
|
||||
config: WorkTimeConfig,
|
||||
): NormSample[] {
|
||||
const samples: NormSample[] = [];
|
||||
for (const row of rows) {
|
||||
const source = row.lastUpdatedSource;
|
||||
if (config.excludeGit && source === 'git') continue;
|
||||
samples.push({
|
||||
t: toMs(row.createdAt),
|
||||
isAgent: source === 'agent',
|
||||
aiChatId: row.lastUpdatedAiChatId ?? null,
|
||||
kind: row.kind ?? null,
|
||||
});
|
||||
}
|
||||
samples.sort((a, b) => a.t - b.t);
|
||||
|
||||
if (config.dedupRoundMs <= 0 || samples.length < 2) return samples;
|
||||
|
||||
const deduped: NormSample[] = [];
|
||||
for (const s of samples) {
|
||||
const prev = deduped[deduped.length - 1];
|
||||
if (prev && s.t - prev.t < config.dedupRoundMs) {
|
||||
// Merge into the previous sample. Human wins the class; keep the earliest
|
||||
// t; keep a non-null aiChatId if either has one (so a bare boundary row
|
||||
// does not erase the run id).
|
||||
prev.isAgent = prev.isAgent && s.isAgent;
|
||||
prev.aiChatId = prev.aiChatId ?? s.aiChatId;
|
||||
// Prefer the more specific kind (a real kind over a null/boundary) only
|
||||
// matters for burst continuation; keep prev.kind (earliest) as-is.
|
||||
continue;
|
||||
}
|
||||
deduped.push({ ...s });
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse consecutive same-run agent samples into one burst segment (§5.1) so a
|
||||
* dense burst (8 snapshots in 7 minutes) contributes its wall-clock, not a count
|
||||
* × block. A burst is broken by any sample NOT continuing the same aiChatId
|
||||
* agent run: a non-agent sample, a `boundary` (actor transition), or a DIFFERENT
|
||||
* aiChatId. Only an AGENT-sourced `idle` pulse with the SAME or a null aiChatId
|
||||
* continues the burst (its label lags the real edit ≤ maxWait, well within
|
||||
* rounding); a user-sourced `idle` (a human supervision pulse) breaks it.
|
||||
*/
|
||||
function collapse(samples: NormSample[], config: WorkTimeConfig): Segment[] {
|
||||
const segments: Segment[] = [];
|
||||
let burst: { chatId: string | null; tStart: number; tEnd: number } | null =
|
||||
null;
|
||||
|
||||
const flush = () => {
|
||||
if (!burst) return;
|
||||
let tEnd = burst.tEnd;
|
||||
if (config.burstCapMs != null && tEnd - burst.tStart > config.burstCapMs) {
|
||||
tEnd = burst.tStart + config.burstCapMs;
|
||||
}
|
||||
segments.push({ tStart: burst.tStart, tEnd, isAgent: true });
|
||||
burst = null;
|
||||
};
|
||||
|
||||
for (const s of samples) {
|
||||
// An agent-sourced idle pulse continues the current agent burst (same or
|
||||
// null run id). A user-sourced idle (human supervision) must NOT be swallowed
|
||||
// here — it falls through to the human branch so the session flips to `work`.
|
||||
if (
|
||||
burst &&
|
||||
s.kind === 'idle' &&
|
||||
s.isAgent &&
|
||||
(s.aiChatId === burst.chatId || s.aiChatId == null)
|
||||
) {
|
||||
burst.tEnd = s.t;
|
||||
continue;
|
||||
}
|
||||
if (s.isAgent && s.kind !== 'boundary') {
|
||||
if (burst && burst.chatId === s.aiChatId) {
|
||||
burst.tEnd = s.t;
|
||||
} else {
|
||||
flush();
|
||||
burst = { chatId: s.aiChatId, tStart: s.t, tEnd: s.t };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A human sample, a boundary, or an agent-boundary: breaks the burst and is
|
||||
// itself a zero-width segment (its class follows its own source).
|
||||
flush();
|
||||
segments.push({ tStart: s.t, tEnd: s.t, isAgent: s.isAgent });
|
||||
}
|
||||
flush();
|
||||
return segments;
|
||||
}
|
||||
|
||||
function gapThreshold(
|
||||
a: Segment,
|
||||
b: Segment,
|
||||
config: WorkTimeConfig,
|
||||
): number {
|
||||
return a.isAgent && b.isAgent ? config.agentTGap : config.tGap;
|
||||
}
|
||||
|
||||
/** Merge intervals; overlapping OR touching intervals are unioned. */
|
||||
function unionDuration(intervals: Array<[number, number]>): number {
|
||||
if (intervals.length === 0) return 0;
|
||||
const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
|
||||
let total = 0;
|
||||
let [curStart, curEnd] = sorted[0];
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const [s, e] = sorted[i];
|
||||
if (s <= curEnd) {
|
||||
if (e > curEnd) curEnd = e;
|
||||
} else {
|
||||
total += curEnd - curStart;
|
||||
curStart = s;
|
||||
curEnd = e;
|
||||
}
|
||||
}
|
||||
total += curEnd - curStart;
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* #395 core — estimate time worked on a page from its history timeline (§5).
|
||||
* Pure and deterministic: no DB, no clock, no I/O.
|
||||
*
|
||||
* Pipeline: normalize+dedup → collapse agent bursts → ONE sessionization pass
|
||||
* over all segments (threshold depends on the pair: both-agent → agentTGap, else
|
||||
* tGap; the last session is ALWAYS closed after the loop) → class per finished
|
||||
* session (all-agent → agent_only, else work) → pad each session (multi-sample
|
||||
* → [first−P_in, last+P_out]; lone scalar → [t−P_single, t]) → clip padding of
|
||||
* adjacent DIFFERENT-class sessions at the raw-gap midpoint (so work/agent_only
|
||||
* never overlap) → metrics are the union wall-clock within each class (union, not
|
||||
* Σ, so overlaps never double, and cross-class-disjoint by the clip above).
|
||||
*/
|
||||
export function computeWorkTime(
|
||||
rows: TimelineSample[],
|
||||
config?: Partial<WorkTimeConfig>,
|
||||
): WorkTimeResult {
|
||||
const cfg = resolveWorkTimeConfig(config);
|
||||
const samples = normalize(rows, cfg);
|
||||
const segments = collapse(samples, cfg);
|
||||
|
||||
// Sessionize — one pass over ALL segments.
|
||||
const rawSessions: Segment[][] = [];
|
||||
let cur: Segment[] | null = null;
|
||||
for (const seg of segments) {
|
||||
if (cur == null) {
|
||||
cur = [seg];
|
||||
} else {
|
||||
const last = cur[cur.length - 1];
|
||||
if (seg.tStart - last.tEnd <= gapThreshold(last, seg, cfg)) {
|
||||
cur.push(seg);
|
||||
} else {
|
||||
rawSessions.push(cur);
|
||||
cur = [seg];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cur != null) rawSessions.push(cur); // MUST close the last session (§5, §9#1)
|
||||
|
||||
// A finished session with BOTH its raw (unpadded) span and its padded bounds.
|
||||
// `rawSessions` are already in ascending time order, so `built` is too.
|
||||
interface BuiltSession {
|
||||
rawStart: number;
|
||||
rawEnd: number;
|
||||
padStart: number;
|
||||
padEnd: number;
|
||||
cls: WorkSession['class'];
|
||||
}
|
||||
const built: BuiltSession[] = [];
|
||||
|
||||
for (const segs of rawSessions) {
|
||||
const first = segs[0];
|
||||
const last = segs[segs.length - 1];
|
||||
const cls = segs.every((s) => s.isAgent) ? 'agent_only' : 'work';
|
||||
|
||||
let padStart: number;
|
||||
let padEnd: number;
|
||||
if (segs.length === 1 && first.tStart === first.tEnd) {
|
||||
// Lone single-instant session (one scalar, or a one-snapshot agent run):
|
||||
// pre-roll only, no invented "future" work (§5).
|
||||
padStart = first.tStart - cfg.pSingle;
|
||||
padEnd = first.tStart;
|
||||
} else {
|
||||
padStart = first.tStart - cfg.pIn;
|
||||
padEnd = last.tEnd + cfg.pOut;
|
||||
}
|
||||
|
||||
built.push({
|
||||
rawStart: first.tStart,
|
||||
rawEnd: last.tEnd,
|
||||
padStart,
|
||||
padEnd,
|
||||
cls,
|
||||
});
|
||||
}
|
||||
|
||||
// Clip cross-class padding so a `work` and an `agent_only` session that abut
|
||||
// never claim the same wall-clock. For each ADJACENT pair of DIFFERENT classes,
|
||||
// cap the earlier session's trailing pad and the later session's leading pad at
|
||||
// the MIDPOINT of the raw (unpadded) inactivity gap between them: the earlier
|
||||
// padded interval then ends ≤ midpoint and the later one starts ≥ midpoint, so
|
||||
// the two are disjoint (they touch at most at the midpoint). This makes the
|
||||
// per-class unions (workMs / agentOnlyMs) cross-class-disjoint BY CONSTRUCTION
|
||||
// — closing the double-count where a work session ending in an agent segment
|
||||
// and a nearby agent_only session (gap in (agentTGap, pIn+pOut]) overlapped and
|
||||
// were counted into both metrics (§5, §9). Within-class adjacency is left
|
||||
// untouched: `unionDuration` already dedups it, and clipping there could perturb
|
||||
// the per-class metric value.
|
||||
for (let i = 1; i < built.length; i++) {
|
||||
const a = built[i - 1];
|
||||
const b = built[i];
|
||||
if (a.cls === b.cls) continue;
|
||||
const midpoint = (a.rawEnd + b.rawStart) / 2;
|
||||
if (a.padEnd > midpoint) a.padEnd = midpoint;
|
||||
if (b.padStart < midpoint) b.padStart = midpoint;
|
||||
}
|
||||
|
||||
const sessions: WorkSession[] = [];
|
||||
const workIvs: Array<[number, number]> = [];
|
||||
const agentIvs: Array<[number, number]> = [];
|
||||
for (const s of built) {
|
||||
sessions.push({ start: s.padStart, end: s.padEnd, class: s.cls });
|
||||
(s.cls === 'work' ? workIvs : agentIvs).push([s.padStart, s.padEnd]);
|
||||
}
|
||||
|
||||
sessions.sort((a, b) => a.start - b.start);
|
||||
|
||||
return {
|
||||
workMs: unionDuration(workIvs),
|
||||
agentOnlyMs: unionDuration(agentIvs),
|
||||
sessions,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export { computeWorkTime } from './compute-work-time';
|
||||
export { bucketByDay, zonedDayStart } from './bucket-by-day';
|
||||
export {
|
||||
DEFAULT_WORK_TIME_CONFIG,
|
||||
resolveWorkTimeConfig,
|
||||
} from './work-time.config';
|
||||
export type { WorkTimeConfig } from './work-time.config';
|
||||
export type {
|
||||
TimelineSample,
|
||||
WorkSession,
|
||||
WorkTimeResult,
|
||||
SessionClass,
|
||||
DayWindow,
|
||||
PerDay,
|
||||
} from './work-time.types';
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
IDLE_MAX_WAIT_USER,
|
||||
IDLE_MAX_WAIT_AGENT,
|
||||
} from '../../../collaboration/constants';
|
||||
|
||||
/**
|
||||
* #395 — tunables for the work-time estimate (§10). Defaults are calibrated off
|
||||
* #374's idle-pulse ceilings: after #374 a continuous editing session leaves a
|
||||
* history row at least every ~IDLE_MAX_WAIT (10m user / 5m agent), so a gap
|
||||
* WIDER than that ceiling contains un-pulsed idle time = (partial) inactivity.
|
||||
* `tGap` therefore sits a little above the user ceiling, `agentTGap` a little
|
||||
* above the agent ceiling — a gap within the threshold is pulse-backed and
|
||||
* counts as work.
|
||||
*/
|
||||
export interface WorkTimeConfig {
|
||||
/** user inactivity timeout: gap ≤ tGap between samples = continuous work. */
|
||||
tGap: number;
|
||||
/** timeout for a pair of consecutive agent samples (tighter than tGap). */
|
||||
agentTGap: number;
|
||||
/** pre-roll padding for a multi-sample session (work began before sample 1). */
|
||||
pIn: number;
|
||||
/** post-roll padding for a multi-sample session (work continued after last). */
|
||||
pOut: number;
|
||||
/** block for a lone single-sample session (pre-roll only, no invented future). */
|
||||
pSingle: number;
|
||||
/** drop `git`-source samples (they are not human/agent article work). */
|
||||
excludeGit: boolean;
|
||||
/** optional cap on one collapsed agent-burst segment's wall-clock (§9#3). */
|
||||
burstCapMs?: number;
|
||||
/** samples whose createdAt round to the same bucket dedup to one (§9#7). */
|
||||
dedupRoundMs: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_WORK_TIME_CONFIG: WorkTimeConfig = {
|
||||
// ~15m: IDLE_MAX_WAIT_USER (10m) + headroom. Empirically backcast on a real
|
||||
// 307-snapshot article (≈24h at 15m matched the owner's estimate; 30/45m
|
||||
// over-counted). See #395 §10.
|
||||
tGap: 15 * 60 * 1000,
|
||||
// ~7m: IDLE_MAX_WAIT_AGENT (5m) + headroom.
|
||||
agentTGap: 7 * 60 * 1000,
|
||||
pIn: 5 * 60 * 1000,
|
||||
pOut: 5 * 60 * 1000,
|
||||
pSingle: 2 * 60 * 1000,
|
||||
excludeGit: true,
|
||||
burstCapMs: undefined,
|
||||
dedupRoundMs: 1000,
|
||||
};
|
||||
|
||||
// Compile-time cross-check that the defaults really are pulse-anchored — if a
|
||||
// future edit moves the #374 ceilings, this reminds us to re-calibrate.
|
||||
void IDLE_MAX_WAIT_USER;
|
||||
void IDLE_MAX_WAIT_AGENT;
|
||||
|
||||
/**
|
||||
* Fill a partial config with defaults and validate it. Cross-class metric
|
||||
* disjointness is guaranteed jointly by `computeWorkTime`'s adjacent-pair padding
|
||||
* clip (it caps the padding of adjacent DIFFERENT-class sessions at the raw-gap
|
||||
* midpoint) AND the two bounds enforced below (§5):
|
||||
* - `tGap ≥ pIn + pOut`: a session's own padding never exceeds its inactivity
|
||||
* window.
|
||||
* - `2·agentTGap ≥ pIn + pOut`: makes the adjacent-only clip provably COMPLETE.
|
||||
* A NON-adjacent (i, i+2) cross-class overlap could only arise from two
|
||||
* same-class sessions separated by a full intervening session of the other
|
||||
* class; that separation spans at least two inter-session gaps, each strictly
|
||||
* `> agentTGap`, so it is `> 2·agentTGap`. Requiring `2·agentTGap ≥ pIn + pOut`
|
||||
* means even the widest padded reach (pIn + pOut) cannot bridge it — so the
|
||||
* only cross-class overlaps possible are between ADJACENT sessions, which the
|
||||
* clip handles. `workMs`/`agentOnlyMs` are therefore disjoint by construction.
|
||||
*/
|
||||
export function resolveWorkTimeConfig(
|
||||
partial?: Partial<WorkTimeConfig>,
|
||||
): WorkTimeConfig {
|
||||
const config = { ...DEFAULT_WORK_TIME_CONFIG, ...(partial ?? {}) };
|
||||
|
||||
for (const key of [
|
||||
'tGap',
|
||||
'agentTGap',
|
||||
'pIn',
|
||||
'pOut',
|
||||
'pSingle',
|
||||
'dedupRoundMs',
|
||||
] as const) {
|
||||
const value = config[key];
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`work-time config: ${key} must be a non-negative number`);
|
||||
}
|
||||
}
|
||||
if (config.burstCapMs != null && config.burstCapMs <= 0) {
|
||||
throw new Error('work-time config: burstCapMs must be > 0 when set');
|
||||
}
|
||||
if (config.tGap < config.pIn + config.pOut) {
|
||||
throw new Error(
|
||||
"work-time config: tGap must be ≥ pIn + pOut (a session's padding may not exceed its inactivity window)",
|
||||
);
|
||||
}
|
||||
if (2 * config.agentTGap < config.pIn + config.pOut) {
|
||||
throw new Error(
|
||||
'work-time config: 2·agentTGap must be ≥ pIn + pOut (so non-adjacent cross-class padding cannot overlap)',
|
||||
);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* #395 — "time worked on an article" domain types.
|
||||
*
|
||||
* The estimate is built by sessionizing a page's `page_history` timeline on
|
||||
* inactivity gaps (WakaTime-style), NOT by taking the span between the first and
|
||||
* last edit (which over-counts sleep / lunch / idle days). See the design doc in
|
||||
* issue #395 §5–§6.3 for the normative algorithm.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A single `page_history` row projected for the work-time computation — the
|
||||
* cheap columns only (no `content`). Produced by
|
||||
* `PageHistoryRepo.findTimelineByPageId`. `createdAt` is whatever the DB driver
|
||||
* hands back (Date); the pure core normalizes it to epoch-ms itself so it stays
|
||||
* deterministic and DB-free.
|
||||
*/
|
||||
export interface TimelineSample {
|
||||
createdAt: Date | string | number;
|
||||
lastUpdatedById: string | null;
|
||||
/** 'user' | 'agent' | 'git' | null (legacy autosave = human). */
|
||||
lastUpdatedSource: string | null;
|
||||
lastUpdatedAiChatId: string | null;
|
||||
/** #370 tier: 'manual' | 'agent' | 'idle' | 'boundary' | null (legacy). */
|
||||
kind: string | null;
|
||||
}
|
||||
|
||||
/** A finished session's class (§5.1). */
|
||||
export type SessionClass = 'work' | 'agent_only';
|
||||
|
||||
/**
|
||||
* A finished session: absolute wall-clock bounds already padded with P_in/P_out
|
||||
* (multi-sample) or P_single (single scalar), plus its class. This is enough for
|
||||
* both the metrics and the per-day punch-card colouring.
|
||||
*/
|
||||
export interface WorkSession {
|
||||
/** epoch-ms, inclusive lower bound (already P-padded). */
|
||||
start: number;
|
||||
/** epoch-ms, exclusive upper bound (already P-padded). */
|
||||
end: number;
|
||||
class: SessionClass;
|
||||
}
|
||||
|
||||
/** Output of {@link computeWorkTime}. */
|
||||
export interface WorkTimeResult {
|
||||
/** union wall-clock of `work` sessions, ms (the headline metric). */
|
||||
workMs: number;
|
||||
/** union wall-clock of `agent_only` sessions, ms (secondary). */
|
||||
agentOnlyMs: number;
|
||||
sessions: WorkSession[];
|
||||
}
|
||||
|
||||
/** One activity window inside a calendar day (already clipped to the day). */
|
||||
export interface DayWindow {
|
||||
/** epoch-ms. */
|
||||
start: number;
|
||||
/** epoch-ms. */
|
||||
end: number;
|
||||
class: SessionClass;
|
||||
}
|
||||
|
||||
/** One calendar day of the punch-card (§6.3). */
|
||||
export interface PerDay {
|
||||
/** epoch-ms of the local-midnight day start in the requested tz. */
|
||||
day: number;
|
||||
/** 'YYYY-MM-DD' in the requested tz — stable, tz-independent label. */
|
||||
dayISO: string;
|
||||
/** Σ of `work` windows this day, ms. Σ over days == workMs (invariant §6.3). */
|
||||
activeMs: number;
|
||||
/** Σ of `agent_only` windows this day, ms (drawn, NOT in activeMs). */
|
||||
agentMs: number;
|
||||
/** both classes, clipped to the day, sorted by start (for drawing). */
|
||||
windows: DayWindow[];
|
||||
}
|
||||
@@ -32,7 +32,6 @@ import { TemplateRepo } from '@docmost/db/repos/template/template.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 { 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 { AiProviderCredentialsRepo } from '@docmost/db/repos/ai-chat/ai-provider-credentials.repo';
|
||||
import { AiMcpServerRepo } from '@docmost/db/repos/ai-chat/ai-mcp-server.repo';
|
||||
@@ -126,7 +125,6 @@ import { firstSqlToken } from '../integrations/metrics/metrics.constants';
|
||||
AiChatRepo,
|
||||
AiChatMessageRepo,
|
||||
AiChatRunRepo,
|
||||
AiChatRunStepRepo,
|
||||
AiChatPageSnapshotRepo,
|
||||
AiProviderCredentialsRepo,
|
||||
AiMcpServerRepo,
|
||||
@@ -163,7 +161,6 @@ import { firstSqlToken } from '../integrations/metrics/metrics.constants';
|
||||
AiChatRepo,
|
||||
AiChatMessageRepo,
|
||||
AiChatRunRepo,
|
||||
AiChatRunStepRepo,
|
||||
AiChatPageSnapshotRepo,
|
||||
AiProviderCredentialsRepo,
|
||||
AiMcpServerRepo,
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -157,6 +157,44 @@ export class PageHistoryRepo {
|
||||
return { ...result, items: result.items.map(attachPageHistoryAgent) };
|
||||
}
|
||||
|
||||
/**
|
||||
* #395 — cheap projection of a page's FULL history timeline for the work-time
|
||||
* estimate: only the columns the sessionizer needs, no heavy `content`, sorted
|
||||
* oldest→newest. The secondary `id` tie-break keeps rows sharing a `createdAt`
|
||||
* (e.g. a synchronous pre-agent boundary row + the immediate agent snapshot)
|
||||
* in a deterministic order.
|
||||
*/
|
||||
async findTimelineByPageId(
|
||||
pageId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<
|
||||
Array<
|
||||
Pick<
|
||||
PageHistory,
|
||||
| 'createdAt'
|
||||
| 'lastUpdatedById'
|
||||
| 'lastUpdatedSource'
|
||||
| 'lastUpdatedAiChatId'
|
||||
| 'kind'
|
||||
>
|
||||
>
|
||||
> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.selectFrom('pageHistory')
|
||||
.select([
|
||||
'createdAt',
|
||||
'lastUpdatedById',
|
||||
'lastUpdatedSource',
|
||||
'lastUpdatedAiChatId',
|
||||
'kind',
|
||||
])
|
||||
.where('pageId', '=', pageId)
|
||||
.orderBy('createdAt', 'asc')
|
||||
.orderBy('id', 'asc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findPageLastHistory(
|
||||
pageId: string,
|
||||
opts?: {
|
||||
|
||||
-17
@@ -692,22 +692,6 @@ export interface AiChatRuns {
|
||||
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
|
||||
// previous turn (#274). Mirrors migration 20260702T120000-ai-chat-page-snapshot.ts.
|
||||
// The next turn diffs the CURRENT Markdown against `contentMd` to surface edits a
|
||||
@@ -745,7 +729,6 @@ export interface DB {
|
||||
aiChats: AiChats;
|
||||
aiChatMessages: AiChatMessages;
|
||||
aiChatRuns: AiChatRuns;
|
||||
aiChatRunSteps: AiChatRunSteps;
|
||||
aiChatPageSnapshots: AiChatPageSnapshots;
|
||||
apiKeys: ApiKeys;
|
||||
attachments: Attachments;
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
AiChats,
|
||||
AiChatMessages,
|
||||
AiChatRuns,
|
||||
AiChatRunSteps,
|
||||
AiChatPageSnapshots,
|
||||
Attachments,
|
||||
Comments,
|
||||
@@ -65,12 +64,6 @@ export type InsertableAiChatMessage = Omit<Insertable<AiChatMessages>, 'tsv'>;
|
||||
export type AiChatRun = Selectable<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
|
||||
// end of the agent's previous turn, diffed against the current page next turn to
|
||||
// detect human edits made between turns.
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,173 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,169 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,163 +0,0 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Kysely } from 'kysely';
|
||||
import { PageHistoryRepo } from '../../src/database/repos/page/page-history.repo';
|
||||
import { PageHistoryService } from '../../src/core/page/services/page-history.service';
|
||||
import { computeWorkTime } from '../../src/core/page/work-time';
|
||||
import {
|
||||
getTestDb,
|
||||
destroyTestDb,
|
||||
createWorkspace,
|
||||
createSpace,
|
||||
createPage,
|
||||
createUser,
|
||||
createChat,
|
||||
} from './db';
|
||||
|
||||
/**
|
||||
* #395 — real-Postgres coverage for the work-time timeline projection and the
|
||||
* service that computes the estimate. The pure sessionizer is unit-tested
|
||||
* exhaustively (compute-work-time.spec.ts); this asserts the SQL projection
|
||||
* (right rows, ASC, no `content`) and that the service's numbers agree with the
|
||||
* pure core over the exact rows the DB returns.
|
||||
*/
|
||||
describe('PageHistory work-time [integration]', () => {
|
||||
let db: Kysely<any>;
|
||||
let repo: PageHistoryRepo;
|
||||
let service: PageHistoryService;
|
||||
let workspaceId: string;
|
||||
let spaceId: string;
|
||||
let pageId: string;
|
||||
let userId: string;
|
||||
let chatId: string;
|
||||
|
||||
const MIN = 60 * 1000;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = getTestDb();
|
||||
repo = new PageHistoryRepo(db as any);
|
||||
service = new PageHistoryService(repo);
|
||||
workspaceId = (await createWorkspace(db)).id;
|
||||
spaceId = (await createSpace(db, workspaceId)).id;
|
||||
pageId = (await createPage(db, { workspaceId, spaceId })).id;
|
||||
userId = (await createUser(db, workspaceId)).id;
|
||||
chatId = (await createChat(db, { workspaceId, creatorId: userId })).id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await destroyTestDb();
|
||||
});
|
||||
|
||||
async function insertHistory(rows: Array<{
|
||||
createdAt: string;
|
||||
source: string | null;
|
||||
chat?: string | null;
|
||||
kind?: string | null;
|
||||
content?: unknown;
|
||||
}>) {
|
||||
for (const r of rows) {
|
||||
await db
|
||||
.insertInto('pageHistory')
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
pageId,
|
||||
spaceId,
|
||||
workspaceId,
|
||||
title: 'x',
|
||||
content: r.content ?? { type: 'doc', content: [] },
|
||||
lastUpdatedById: userId,
|
||||
lastUpdatedSource: r.source,
|
||||
lastUpdatedAiChatId: r.chat ?? null,
|
||||
kind: r.kind ?? null,
|
||||
createdAt: new Date(r.createdAt),
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
it('findTimelineByPageId projects the cheap columns, ASC, without content', async () => {
|
||||
await insertHistory([
|
||||
{ createdAt: '2026-07-04T19:54:00Z', source: 'user', kind: 'manual' },
|
||||
{ createdAt: '2026-07-04T03:40:00Z', source: 'user', kind: null },
|
||||
{ createdAt: '2026-07-04T15:43:00Z', source: 'agent', chat: chatId, kind: 'agent' },
|
||||
]);
|
||||
|
||||
const timeline = await repo.findTimelineByPageId(pageId);
|
||||
expect(timeline).toHaveLength(3);
|
||||
// Sorted oldest → newest.
|
||||
const times = timeline.map((r) => new Date(r.createdAt).getTime());
|
||||
expect(times).toEqual([...times].sort((a, b) => a - b));
|
||||
// Projection carries exactly the sessionizer's inputs, and NO content.
|
||||
for (const row of timeline) {
|
||||
expect(row).toHaveProperty('createdAt');
|
||||
expect(row).toHaveProperty('lastUpdatedById');
|
||||
expect(row).toHaveProperty('lastUpdatedSource');
|
||||
expect(row).toHaveProperty('lastUpdatedAiChatId');
|
||||
expect(row).toHaveProperty('kind');
|
||||
expect(row).not.toHaveProperty('content');
|
||||
}
|
||||
// Agent row keeps its provenance.
|
||||
const agent = timeline.find((r) => r.lastUpdatedSource === 'agent');
|
||||
expect(agent?.lastUpdatedAiChatId).toBe(chatId);
|
||||
});
|
||||
|
||||
it('service estimate matches the pure core and satisfies Σ perDay == workMs', async () => {
|
||||
const rows = await repo.findTimelineByPageId(pageId);
|
||||
const pure = computeWorkTime(rows);
|
||||
|
||||
const result = await service.computeWorkTime(pageId, 'UTC');
|
||||
expect(result.workMs).toBe(pure.workMs);
|
||||
expect(result.agentOnlyMs).toBe(pure.agentOnlyMs);
|
||||
expect(result.tz).toBe('UTC');
|
||||
expect(result.config.tGap).toBe(15 * MIN);
|
||||
|
||||
const sumActive = result.perDay.reduce((a, d) => a + d.activeMs, 0);
|
||||
expect(sumActive).toBe(result.workMs);
|
||||
|
||||
// The 3 seeded rows sessionize into ≤ their span; not the naive span.
|
||||
const naive =
|
||||
new Date('2026-07-04T19:54:00Z').getTime() -
|
||||
new Date('2026-07-04T03:40:00Z').getTime();
|
||||
expect(result.workMs).toBeGreaterThan(0);
|
||||
expect(result.workMs).toBeLessThan(naive);
|
||||
});
|
||||
|
||||
it('an unknown timezone surfaces as a RangeError (controller maps to 400)', async () => {
|
||||
await expect(
|
||||
service.computeWorkTime(pageId, 'Not/AZone'),
|
||||
).rejects.toBeInstanceOf(RangeError);
|
||||
});
|
||||
|
||||
it('a page with no history → zeros, no days', async () => {
|
||||
const emptyPage = (await createPage(db, { workspaceId, spaceId })).id;
|
||||
const result = await service.computeWorkTime(emptyPage, 'UTC');
|
||||
expect(result.workMs).toBe(0);
|
||||
expect(result.agentOnlyMs).toBe(0);
|
||||
expect(result.perDay).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user