Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8503ff1f3d | |||
| ae3dfd8de6 | |||
| 03eafa6c68 | |||
| a42f1ead48 | |||
| b7a3ec227d | |||
| 846341d7d4 | |||
| 32e10ca6d3 | |||
| 74d212cfd3 | |||
| c96fafc4ad | |||
| 9a6389133b | |||
| 0b8497e496 | |||
| 433252bdb1 | |||
| 2f8c5d9a98 | |||
| 6f81067f4d | |||
| 55e8f61b3c | |||
| ab133fa0d0 | |||
| d42ca8dc57 | |||
| 25a31e6c0d | |||
| 0af7eb30c3 | |||
| 9a94c8df18 | |||
| 3550bfa411 | |||
| a0ecf21cb5 | |||
| d81781aa27 | |||
| daca5ce8d6 | |||
| daeeb1f3f2 | |||
| a919c79cb2 | |||
| 798a81abfe | |||
| 8abde99611 | |||
| d608311cae | |||
| 6dad309b51 | |||
| f34542c881 | |||
| b038d96708 | |||
| f5bbfdb2d4 | |||
| 875f6ba9c5 | |||
| 7741821ee3 | |||
| 48a074e0d2 | |||
| 17e3b3d882 | |||
| 801add63e8 | |||
| 7ad072ba2c | |||
| b041944a23 | |||
| 401d62119c | |||
| bb5abf29a6 | |||
| 51260793c0 | |||
| c765483947 | |||
| 45ff922dd4 | |||
| e3ca2dc1d5 | |||
| f919ced8c9 | |||
| 4109b2ef7f | |||
| 6b27ff0652 | |||
| e133b86982 | |||
| 579c82617b | |||
| 173f35e473 | |||
| 696b96ac18 | |||
| a96ca8e26b | |||
| f84386f24a | |||
| c0c44fddb9 | |||
| 91e58e3c9f | |||
| 8d062728e5 | |||
| 0ddeaadeee | |||
| bc632f9d56 | |||
| d3a049d176 | |||
| f1cffc2d0f | |||
| bb9a6fd765 | |||
| 1c083fbd3d | |||
| 52763998d3 | |||
| d738780370 | |||
| bc433d12e6 | |||
| 141ebb4864 | |||
| 045a0afaad | |||
| be433d40f0 | |||
| e56a05926d | |||
| 3a344626db | |||
| 31f51eaa47 | |||
| b66929714f | |||
| a09935aa29 | |||
| 047433595e | |||
| 9e95412695 | |||
| 2fa86e2a33 | |||
| e3eece78c3 | |||
| e1b8ef5b8b | |||
| 5adcd2f08b | |||
| 1e7bd1f9d2 |
@@ -251,6 +251,16 @@ MCP_DOCMOST_PASSWORD=
|
||||
# Default 120000 (2 min).
|
||||
# AI_MCP_CALL_TIMEOUT_MS=120000
|
||||
|
||||
# Kill-switch for the agent API-key feature (#501). Default ON when unset — a
|
||||
# deploy that never sets it must NOT silently kill every agent. STRICT parse:
|
||||
# only the literals `true` / `false` are accepted; a typo like `=0`/`=off`/`=False`
|
||||
# FAILS AT BOOT by design (never silently read as "enabled"), so the switch is
|
||||
# guaranteed to actually flip when an operator flips it during an incident. When
|
||||
# set to `false`: all api-key auth is DENIED (every api-key token is rejected) and
|
||||
# the api-key management endpoints return 404. The resolved state is logged at boot
|
||||
# (`API keys: ENABLED/DISABLED (API_KEYS_ENABLED=...)`) so it is verifiable per deploy.
|
||||
# API_KEYS_ENABLED=true
|
||||
|
||||
# Max JSON/urlencoded request body size (bytes). Fastify's 1 MiB default is too
|
||||
# small for a long AI-chat research turn: the client resends the FULL message
|
||||
# history (every tool call + search result) on each turn, so a deep conversation's
|
||||
@@ -302,6 +312,16 @@ MCP_DOCMOST_PASSWORD=
|
||||
# enabled for a workspace, and the same single-instance constraint applies (the
|
||||
# registry is process-local).
|
||||
# AI_CHAT_RESUMABLE_STREAM=false
|
||||
#
|
||||
# Per-run replay ring cap (#491), in BYTES, for the resumable-stream registry
|
||||
# above. The registry buffers the run's recent SSE tail so a reopened tab can
|
||||
# attach and continue from the step it already persisted; the ring is bounded and
|
||||
# rotates on every confirmed step-persist. This caps the un-persisted tail between
|
||||
# rotations — an overflow evicts the oldest frames and a late attach falls back to
|
||||
# 204 -> degraded poll, so correctness never depends on the size. Default 4194304
|
||||
# (4MB); a 0/invalid value falls back to the default. The per-subscriber backpressure
|
||||
# cap is derived as 2x this value. Only meaningful with AI_CHAT_RESUMABLE_STREAM on.
|
||||
# AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES=4194304
|
||||
|
||||
# --- Run lifecycle tunables (#487) ---
|
||||
# These govern the universal run machinery (every turn is now a first-class run,
|
||||
|
||||
@@ -62,6 +62,38 @@ jobs:
|
||||
needs: [test, e2e-server, e2e-mcp, build]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# Image boot-smoke (issue #476): every other job tests code from the working
|
||||
# tree, but the :develop IMAGE that watchtower pulls was never actually
|
||||
# started anywhere (incident classes #353/#452/#361-boot: startup-migrator
|
||||
# crash-loop, runtime module missing from the image, wrong static-asset
|
||||
# headers). The services below back a smoke boot of the exact image right
|
||||
# before it is pushed; a smoke failure blocks the push.
|
||||
services:
|
||||
postgres:
|
||||
# via mirror.gcr.io (Docker Hub pull-through cache; avoids Hub anonymous
|
||||
# pull rate-limit that randomly fails on shared GitHub runner IPs).
|
||||
image: mirror.gcr.io/pgvector/pgvector:pg18
|
||||
env:
|
||||
POSTGRES_DB: docmost
|
||||
POSTGRES_USER: docmost
|
||||
POSTGRES_PASSWORD: docmost
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U docmost"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
redis:
|
||||
# via mirror.gcr.io (see postgres note above).
|
||||
image: mirror.gcr.io/library/redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -82,6 +114,37 @@ jobs:
|
||||
id: version
|
||||
run: echo "value=$(git describe --tags --always)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Load the image into the local docker daemon so it can be booted (the
|
||||
# push step below exports straight to the registry and leaves nothing
|
||||
# runnable locally). CONVENTION: build-args here must stay TEXTUALLY
|
||||
# IDENTICAL to the push step's build-args — same cache scope + same args
|
||||
# means the layers are reused and the image we smoke IS the image we push.
|
||||
- name: Build image for smoke (load, no push)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
APP_VERSION=${{ steps.version.outputs.value }}
|
||||
AI_AGENT_ROLES_CATALOG_URL=https://raw.githubusercontent.com/vvzvlad/gitmost/develop/agent-roles-catalog
|
||||
load: true
|
||||
push: false
|
||||
tags: gitmost:smoke
|
||||
cache-from: type=gha,scope=develop-amd64
|
||||
|
||||
# Boot-smoke the exact image against the job services (see the comment on
|
||||
# `services:` above): health (startup migrator), auth/setup, client dist
|
||||
# served, immutable + brotli asset headers. Fails the job (and therefore
|
||||
# the push) on any miss.
|
||||
- name: Smoke the built image
|
||||
run: bash scripts/ci/image-smoke.sh gitmost:smoke
|
||||
|
||||
# The smoke script leaves the container running on failure precisely so
|
||||
# the boot error (migration mismatch, stack trace) is diagnosable here.
|
||||
- name: Dump smoke container log on failure
|
||||
if: failure()
|
||||
run: docker logs gitmost-smoke 2>&1 | tail -200 || true
|
||||
|
||||
- name: Build and push develop image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
@@ -163,6 +226,13 @@ jobs:
|
||||
- name: Build mcp
|
||||
run: pnpm --filter @docmost/mcp build
|
||||
|
||||
# apps/server imports @docmost/token-estimate at runtime (history-budget.ts,
|
||||
# #490); its dist/ is gitignored and `test:e2e` type-checks + runs the code,
|
||||
# so build it here or tsc fails with TS2307 Cannot find module
|
||||
# '@docmost/token-estimate' (mirrors the editor-ext / mcp build steps above).
|
||||
- name: Build token-estimate
|
||||
run: pnpm --filter @docmost/token-estimate build
|
||||
|
||||
- name: Run migrations
|
||||
run: pnpm --filter ./apps/server migration:latest
|
||||
|
||||
|
||||
@@ -124,9 +124,17 @@ jobs:
|
||||
exit "$FAILED"
|
||||
|
||||
# A GENUINE counterexample: fast-check printed a shrunk minimal case and its
|
||||
# reproducing seed into property-output.txt. File a dedup-guarded issue whose
|
||||
# title prefix is UNIQUE to counterexamples, so an infra failure (handled by
|
||||
# the next step under a different title) can never poison this dedup.
|
||||
# reproducing seed into property-output.txt. File a dedup-guarded issue.
|
||||
#
|
||||
# Dedup is keyed on a HASH of the SHRUNK COUNTEREXAMPLE (the minimal failing
|
||||
# input), NOT on the issue title prefix. Keying on the prefix would let a
|
||||
# single open issue swallow every OTHER counterexample (a different bug B whose
|
||||
# title shares the prefix would be treated as a duplicate and stay silent until
|
||||
# the first issue is closed). Hashing the shrunk example instead means two
|
||||
# DIFFERENT counterexamples get two DIFFERENT issues, while a re-find of the
|
||||
# SAME counterexample still dedupes onto the existing one. The infra-failure
|
||||
# step (below) still keys on its own distinct title, so it can never poison
|
||||
# this dedup either.
|
||||
- name: File counterexample issue
|
||||
# always() is REQUIRED: the fuzz step exits nonzero on a failing shard,
|
||||
# so a bare `if:` (implicitly success() && ...) would skip this step
|
||||
@@ -146,25 +154,48 @@ jobs:
|
||||
echo "No fast-check counterexample signature — infra failure, handled by the next step."
|
||||
exit 0
|
||||
fi
|
||||
TITLE="${TITLE_PREFIX} (seed=${FAIL_SEED})"
|
||||
# Extract the SHRUNK counterexample block: the "Counterexample:" line(s)
|
||||
# up to (but excluding) the "Shrunk N time(s)" / "Got error" line. This is
|
||||
# the minimal failing INPUT and is STABLE across the different seeds/paths
|
||||
# that reach the same bug — unlike the seed, path, or shrink count (which
|
||||
# precede/follow this block and vary run-to-run) and unlike the whole
|
||||
# output (which embeds those varying parts). Hashing THIS is what makes the
|
||||
# dedup identity the bug itself rather than an incidental run detail.
|
||||
CE_TEXT=$(awk '/Counterexample:/{c=1} /Shrunk [0-9]+ time|Got error/{c=0} c{print}' property-output.txt)
|
||||
if [ -z "$CE_TEXT" ]; then
|
||||
# No parseable shrunk block (unexpected — the signature check above
|
||||
# already confirmed fast-check output). Fall back to the reproducing
|
||||
# seed so we still emit a stable identity instead of silently deduping.
|
||||
CE_TEXT="seed:${FAIL_SEED}"
|
||||
fi
|
||||
# Stable short id: first 12 hex chars of sha256 over the counterexample.
|
||||
CE_HASH=$(printf '%s' "$CE_TEXT" | sha256sum | cut -c1-12)
|
||||
# Machine-readable marker embedded in the issue body; the open-issue search
|
||||
# below matches on it (and on the hash in the title) so identity travels
|
||||
# with the issue regardless of any human title edits.
|
||||
CE_MARKER="<!-- counterexample-hash: ${CE_HASH} -->"
|
||||
export CE_HASH CE_MARKER
|
||||
TITLE="${TITLE_PREFIX} [${CE_HASH}] (seed=${FAIL_SEED})"
|
||||
|
||||
# Best-effort dedup: skip if an open issue with the counterexample title
|
||||
# prefix already exists. A failure of this check must NOT block creation.
|
||||
# Dedup on the counterexample hash: skip only if an OPEN issue already
|
||||
# carries this exact hash (in its title or its body marker). A different
|
||||
# counterexample has a different hash and is NOT deduped. A failure of this
|
||||
# check must NOT block creation.
|
||||
EXISTING=""
|
||||
if EXISTING=$(curl -sS \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues?state=open&limit=100"); then
|
||||
if printf '%s' "$EXISTING" \
|
||||
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let a;try{a=JSON.parse(s)}catch{process.exit(1)}if(!Array.isArray(a))process.exit(1);const p=process.env.TITLE_PREFIX;process.exit(a.some(i=>typeof i.title==="string"&&i.title.startsWith(p))?0:1)})'; then
|
||||
echo "An open '${TITLE_PREFIX}' issue already exists — skipping creation."
|
||||
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let a;try{a=JSON.parse(s)}catch{process.exit(1)}if(!Array.isArray(a))process.exit(1);const h=process.env.CE_HASH,m=process.env.CE_MARKER;process.exit(a.some(i=>(typeof i.title==="string"&&i.title.includes(h))||(typeof i.body==="string"&&i.body.includes(m)))?0:1)})'; then
|
||||
echo "An open issue for counterexample ${CE_HASH} already exists — skipping creation."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Build the JSON body with the test output SAFELY escaped (never hand-
|
||||
# interpolate the counterexample into JSON).
|
||||
BODY_TEXT=$(printf 'A nightly property fuzz SHARD failed with a fast-check counterexample.\n\n- failing shard seed: `%s`\n- NUM_RUNS (per shard): `%s`\n- run: %s\n\nReproduce locally:\n\n```\nPROPERTY_SEED=%s PROPERTY_NUM_RUNS=%s pnpm --filter @docmost/prosemirror-markdown exec vitest run test/generative/\n```\n\nfast-check shrinks the failure to a minimal counterexample. Commit it as a permanent fixture under `packages/prosemirror-markdown/test/fixtures/counterexamples/` + a case in `counterexamples.test.ts`, then FIX the converter (do not weaken a property). See `packages/prosemirror-markdown/README.md`.\n\nTail of the test output (contains the shrunk counterexample):\n\n```\n%s\n```\n' \
|
||||
"$FAIL_SEED" "$NUM_RUNS" "$RUN_URL" "$FAIL_SEED" "$NUM_RUNS" "$(tail -n 120 property-output.txt)")
|
||||
BODY_TEXT=$(printf 'A nightly property fuzz SHARD failed with a fast-check counterexample.\n\n- counterexample hash: `%s`\n- failing shard seed: `%s`\n- NUM_RUNS (per shard): `%s`\n- run: %s\n\nReproduce locally:\n\n```\nPROPERTY_SEED=%s PROPERTY_NUM_RUNS=%s pnpm --filter @docmost/prosemirror-markdown exec vitest run test/generative/\n```\n\nfast-check shrinks the failure to a minimal counterexample. Commit it as a permanent fixture under `packages/prosemirror-markdown/test/fixtures/counterexamples/` + a case in `counterexamples.test.ts`, then FIX the converter (do not weaken a property). See `packages/prosemirror-markdown/README.md`.\n\nTail of the test output (contains the shrunk counterexample):\n\n```\n%s\n```\n\n%s\n' \
|
||||
"$CE_HASH" "$FAIL_SEED" "$NUM_RUNS" "$RUN_URL" "$FAIL_SEED" "$NUM_RUNS" "$(tail -n 120 property-output.txt)" "$CE_MARKER")
|
||||
|
||||
jq -n --arg title "$TITLE" --arg body "$BODY_TEXT" \
|
||||
'{title: $title, body: $body}' > payload.json
|
||||
|
||||
+49
-13
@@ -25,37 +25,65 @@ jobs:
|
||||
# filename sorts BEFORE migrations already applied on the target branch (and
|
||||
# thus in prod). The Kysely startup migrator rejects that as "corrupted
|
||||
# migrations" and crash-loops the app on boot (incident #361). This gate fails
|
||||
# the PR so the migration is renamed to a current timestamp before merge. Only
|
||||
# runs for pull_request events (needs a base branch to diff against).
|
||||
# the PR so the migration is renamed to a current timestamp before merge.
|
||||
# Runs for pull_request (diff against the base branch) AND for push (#476
|
||||
# retrospective: a DIRECT push to develop used to bypass this PR-only gate
|
||||
# entirely — now the push is diffed against its `before` SHA; workflow_call
|
||||
# from develop.yml inherits the caller's push event). workflow_dispatch has
|
||||
# nothing to diff against and still skips the job.
|
||||
migration-order:
|
||||
if: github.event_name == 'pull_request'
|
||||
if: github.event_name == 'pull_request' || github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout (full history for the base-branch diff)
|
||||
- name: Checkout (full history for the base diff)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Added migrations must sort after the newest on the base branch
|
||||
- name: Added migrations must sort after the newest on the base
|
||||
env:
|
||||
TARGET_BRANCH: ${{ github.base_ref }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MIG_DIR="apps/server/src/database/migrations"
|
||||
# checkout above already did fetch-depth:0 (full history). Fetch the base
|
||||
# WITHOUT --depth (a shallow graft would truncate the base history and
|
||||
# break the merge-base when the base has moved ahead of the PR merge —
|
||||
# exactly the long-branch-vs-moving-base case this gate guards, #361).
|
||||
git fetch --no-tags origin "$TARGET_BRANCH"
|
||||
newest_on_target=$(git ls-tree -r --name-only "origin/${TARGET_BRANCH}" "$MIG_DIR" | sort | tail -1)
|
||||
if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then
|
||||
# checkout above already did fetch-depth:0 (full history). Fetch the base
|
||||
# WITHOUT --depth (a shallow graft would truncate the base history and
|
||||
# break the merge-base when the base has moved ahead of the PR merge —
|
||||
# exactly the long-branch-vs-moving-base case this gate guards, #361).
|
||||
git fetch --no-tags origin "$TARGET_BRANCH"
|
||||
BASE="origin/${TARGET_BRANCH}"
|
||||
else
|
||||
# push event: compare against the pre-push tip of the branch.
|
||||
if [ "$BEFORE_SHA" = "0000000000000000000000000000000000000000" ]; then
|
||||
echo "::notice::branch creation push — nothing to compare"
|
||||
exit 0
|
||||
fi
|
||||
if ! git cat-file -e "${BEFORE_SHA}^{commit}" 2>/dev/null; then
|
||||
# The before-SHA is not in the clone (a force-push rewrote history).
|
||||
# One recovery attempt — refresh every remote head (cheap: the
|
||||
# checkout is already fetch-depth:0); a fetch failure aborts via
|
||||
# `set -e`, which is fail-closed too.
|
||||
git fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*'
|
||||
fi
|
||||
if ! git cat-file -e "${BEFORE_SHA}^{commit}" 2>/dev/null; then
|
||||
# FAIL-CLOSED: without the before-SHA there is no base to prove the
|
||||
# ordering against, and a gate whose job is to BLOCK must not guess.
|
||||
echo "::error::force-push detected — verify migration order manually, then re-run via workflow_dispatch"
|
||||
exit 1
|
||||
fi
|
||||
BASE="$BEFORE_SHA"
|
||||
fi
|
||||
newest_on_target=$(git ls-tree -r --name-only "$BASE" "$MIG_DIR" | sort | tail -1)
|
||||
# NO `|| true`: a diff failure (e.g. an unresolved merge-base) must fail
|
||||
# the job CLOSED — a gate whose job is to BLOCK must never pass on error.
|
||||
# `set -e` above already aborts on a non-zero diff exit.
|
||||
added=$(git diff --diff-filter=A --name-only "origin/${TARGET_BRANCH}...HEAD" -- "$MIG_DIR")
|
||||
added=$(git diff --diff-filter=A --name-only "${BASE}...HEAD" -- "$MIG_DIR")
|
||||
bad=0
|
||||
for f in $added; do
|
||||
if [[ "$f" < "$newest_on_target" || "$f" == "$newest_on_target" ]]; then
|
||||
echo "::error::Migration $f sorts at or before the newest on ${TARGET_BRANCH} ($newest_on_target) — rename it with a CURRENT timestamp before merge (do not change its contents). See incident #361."
|
||||
echo "::error::Migration $f sorts at or before the newest on the base ($newest_on_target) — rename it with a CURRENT timestamp before merge (do not change its contents). See incident #361."
|
||||
bad=1
|
||||
fi
|
||||
done
|
||||
@@ -131,6 +159,14 @@ jobs:
|
||||
- name: Build prosemirror-markdown
|
||||
run: pnpm --filter @docmost/prosemirror-markdown build
|
||||
|
||||
# @docmost/token-estimate is a shared workspace package the client vitest
|
||||
# suite resolves via its dist build (main: ./dist/index.js); dist/ is
|
||||
# gitignored and `pnpm -r test` does NOT honour nx `dependsOn: ^build`, so
|
||||
# build it before the recursive test run or the client suite fails with
|
||||
# "Failed to resolve import '@docmost/token-estimate'" (#490).
|
||||
- name: Build token-estimate
|
||||
run: pnpm --filter @docmost/token-estimate build
|
||||
|
||||
- name: Run unit tests
|
||||
run: pnpm -r test
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ packages/mcp/build/
|
||||
# is a build artifact like build/ — never committed, always fresh.
|
||||
packages/mcp/src/registry-stamp.generated.ts
|
||||
|
||||
# token-estimate compiled output (#490; built in CI/Docker via `pnpm build` /
|
||||
# the server `pretest`, never committed, so src/ and prod can never diverge).
|
||||
packages/token-estimate/dist/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
@@ -471,6 +471,8 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
||||
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
|
||||
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
|
||||
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`, `ai`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons. The `ai@6.0.134` patch carries TWO independent server fixes, each with its own tripwire test: (1) it disables the SDK's O(n²) cumulative `partialOutput` accumulation when no output strategy is requested (server heap OOM on long agent runs, #184; tripwire: `apps/server/src/integrations/ai/ai-sdk-partial-output.patch.spec.ts`); (2) it fixes `writeToServerResponse`'s drain-hang — the loop awaited only `"drain"` under backpressure, so a mid-write client disconnect parked the pipe forever and leaked the reader/buffers until restart; it now races `"drain"` against `"close"`/`"error"`, cancels the reader on disconnect, and swallows the fire-and-forget read rejection (#486; tripwire: `apps/server/src/integrations/ai/ai-sdk-drain-hang.patch.spec.ts`). Both tripwires assert BOTH installed dist builds carry their patch marker. The patch MUST be re-created via `pnpm patch` when bumping `ai`.
|
||||
- **Upstream tracking (report the analysis upstream, don't just carry it):** both `ai` fixes and the hocuspocus one are candidates for upstreaming so we can eventually drop the local patch — the analysis is already written up in each patch's `PATCH(...)` header comments. File (a) an upstream **issue** on `vercel/ai` for the O(n²) cumulative `partialOutput` accumulation (heap OOM), (b) an upstream **issue** on `vercel/ai` for the `writeToServerResponse` drain-hang, and (c) an upstream **PR** on `@hocuspocus/server` for the connect-vs-unload race (local marker `PATCH(gitmost #401)` in `patches/@hocuspocus__server@3.4.4.patch`). Do NOT edit the patch files to add links — the patch bytes feed `patch_hash` in `pnpm-lock.yaml` (`ai@6.0.134` → `e8c599b3…`), so any content change there desyncs the lockfile pin and breaks `pnpm install`; keep upstream references here instead.
|
||||
- **`ai` version is split across the monorepo and MUST be aligned deliberately, NOT casually:** the server pins `ai@6.0.134` (patched, exact — the `patchedDependencies` key forces that version), while the client declares `ai@6.0.207` (unpatched — the server-side `writeToServerResponse`/`partialOutput` fixes are dead code in the browser, so the mismatch is currently benign but is real drift). Alignment is a **planned, install-gated step**, never a bare `package.json` edit: (1) choose the target version; (2) re-create ALL THREE patch hunks (partialOutput publish-each, the `DefaultStreamTextResult` lazy-`output` wiring, and the drain-hang race) against the target dist via `pnpm patch` — the line offsets shift between versions, so the current patch WILL fail to apply as-is; (3) run a full `pnpm install` so the lockfile + new `patch_hash` regenerate together; (4) confirm both tripwire specs still find their markers. `pnpm install` FAILS HARD on an unapplied patch — that failure is the guardrail, so treat the port as a deliberate plan rather than discovering it as a deploy-time surprise.
|
||||
- **The MCP tool inventory in `SERVER_INSTRUCTIONS` is GENERATED from the registry** (`packages/mcp/src/server-instructions.ts`: `buildToolInventory()` over `SHARED_TOOL_SPECS`) and spliced into the hand-written routing prose (`ROUTING_PROSE`). So adding/renaming/removing a **shared** spec in `packages/mcp/src/tool-specs.ts` auto-updates the `<tool_inventory>` — no manual `SERVER_INSTRUCTIONS` edit needed. Only an **inline** MCP-only tool (those registered via `server.registerTool(...)` in `index.ts`, not through the registry) needs a one-line entry in `INLINE_MCP_INVENTORY`. Enforced by `packages/mcp/test/unit/tool-inventory.test.mjs`, which fails when a registered tool is missing from the generated inventory (there is no `EXCEPTIONS` opt-out anymore — every tool must appear). Update `ROUTING_PROSE` when a tool's *intent guidance* (when-to-use) changes. `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
|
||||
|
||||
## CI / release
|
||||
|
||||
@@ -318,6 +318,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
longer controls whether a turn is a run — it now governs **only** the
|
||||
browser-disconnect semantics (ON = detached/survives a disconnect; OFF = a
|
||||
disconnect stops the run). (#487)
|
||||
- **Vendor `ai` patch: upstream-tracking + version-alignment plan documented.**
|
||||
The two local `ai@6.0.134` fixes (O(n²) `partialOutput` heap-OOM; the
|
||||
`writeToServerResponse` drain-hang) and the hocuspocus connect-vs-unload race
|
||||
now have explicit upstream-reporting and `ai`-version-alignment steps recorded
|
||||
in `AGENTS.md` (client `ai@6.0.207` vs server `ai@6.0.134`-patched drift). The
|
||||
patch bytes are unchanged — they feed the lockfile `patch_hash`, so the
|
||||
alignment is called out as an install-gated plan rather than a bare version
|
||||
bump. No runtime change.
|
||||
- **Client markdown paste/copy and AI-chat rendering now go through the canonical
|
||||
converter.** Pasting markdown into the editor, "Copy as markdown", the AI title
|
||||
generator, and the AI-chat markdown renderer all now use
|
||||
@@ -350,6 +358,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- **MCP write tools no longer report a false failure that provokes a duplicate
|
||||
write.** `drawioCreate` used to throw when the diagram landed as a NESTED block
|
||||
(anchored inside a callout or table cell) because there is no `#<index>` handle
|
||||
for it — but the diagram was already written, so a retry-prone agent re-created
|
||||
it and produced a duplicate. It now returns success with `nodeId: null` plus a
|
||||
warning that explains the write landed and how to re-read it (via
|
||||
`getOutline` / `getPageJson` by `attachmentId`). Separately, when the live
|
||||
collaboration-session cache hits its LRU entry cap, evicting a session whose
|
||||
write is still in flight no longer rejects that write as a hard failure — it is
|
||||
reported as INDETERMINATE ("the update may already have persisted; verify
|
||||
before retry") so the agent re-reads instead of blind-retrying, and a
|
||||
still-connecting session is no longer picked as an idle eviction victim by a
|
||||
parallel acquire. (#494)
|
||||
- **A long AI chat no longer bricks on the model's context window, and each turn
|
||||
stops re-persisting the whole tool-output history.** Tool outputs are now
|
||||
stored ONCE, in `metadata.parts`; the `tool_calls` trace keeps only per-step
|
||||
outcome flags (a v2 trace shape), ending the O(N²) write amplification that
|
||||
re-wrote every prior output on every step (measured on a live Postgres via the
|
||||
`pg_current_wal_lsn()` delta: the trace column shrank ~3200×, the full
|
||||
assistant row ~51%). The persisted record is unchanged in content — the full
|
||||
history still lives in `metadata.parts`. At REPLAY time only, the history sent
|
||||
to the provider is now bounded by a deterministic, prompt-cache-friendly token
|
||||
budget: `floor(0.7 × chatContextWindow)` when a window is configured (no cap —
|
||||
anti-brick protection, not a cost limiter), a flat 100k fallback for installs
|
||||
with no window set (exactly the ones that hit terminal overflow), or off when
|
||||
the window is explicitly `0`. Trimming truncates old tool outputs first, then
|
||||
mechanically collapses the oldest turns, always keeping the recent turns full
|
||||
and the tool-call/result pairing balanced. A provider context-overflow 400 is
|
||||
now classified and used as a reactive signal: the row is stamped so the NEXT
|
||||
turn re-trims aggressively (0.5×), which un-bricks a chat that just 400'd. The
|
||||
client token badge and the server budgeter now share one estimator (new
|
||||
`@docmost/token-estimate` package) so they can never diverge. Deferred-tool
|
||||
activation is also cached in the chat metadata to avoid re-resolving it each
|
||||
turn. (#490)
|
||||
- **Cyrillic (and any non-ASCII) draw.io labels no longer turn into mojibake
|
||||
when a diagram is opened in the draw.io editor.** Agent-created diagrams
|
||||
(`drawioCreate`) and Confluence-imported diagrams stored their model in the
|
||||
SVG's `content=` attribute as base64; the draw.io editor decodes that via
|
||||
Latin-1 `atob` (no UTF-8 step), so every non-ASCII char (e.g. `Старт-бит`,
|
||||
`ё`, `—`) split into garbage and the editor's autosave then persisted the
|
||||
corrupted model, breaking the page preview too. Both write paths
|
||||
(`buildDrawioSvg`, the import service's `createDrawioSvg`) now write `content=`
|
||||
as XML-entity-escaped mxfile XML — draw.io's own native form, decoded by the
|
||||
DOM as UTF-8 — so labels open intact. The decoder reads both the new
|
||||
entity-encoded form and the old base64 form, so existing diagrams still open.
|
||||
*Healing pre-fix diagrams:* only a diagram that still holds its original
|
||||
(correct-UTF-8) base64 — i.e. one not yet opened/autosaved in the draw.io
|
||||
editor — can be repaired in place by `drawioGet` → `drawioUpdate` with the
|
||||
same XML (rewrites the attachment in the new form); no migration script is
|
||||
needed. A diagram that was already opened in the editor persisted the
|
||||
mojibake at rest, so `drawioGet` reads the already-corrupted text and
|
||||
`drawioUpdate` faithfully rewrites it — that text is lost and is not
|
||||
recoverable by a rewrite. (#507)
|
||||
- **A chat with one malformed message part no longer 500s on every turn, and a
|
||||
failed send no longer duplicates the user's message.** Incoming client parts
|
||||
are now whitelisted to `text` (a forged tool-result part can no longer reach
|
||||
@@ -380,6 +441,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
anchor mark, silently dropping bold/italic/code/link on the affected run; the
|
||||
prevailing formatting of the replaced run is now carried onto the applied
|
||||
text. (#496)
|
||||
- **Markdown round-trips no longer silently drop a line that opens with a block
|
||||
trigger.** When a document is exported to Markdown and re-imported (git-sync
|
||||
stabilize, agent writes), a paragraph or continuation line (after a hard break)
|
||||
that begins with a block marker — an ATX heading `#`, a blockquote/callout `>`,
|
||||
a list marker (`-`/`*`/`+`/`N.`/`N)`), a code fence, a table `|`, a thematic
|
||||
break (`---`), or a setext underline (`--`, `----`, or a lone `=`) — is now
|
||||
backslash-escaped so it round-trips as text instead of being re-parsed into a
|
||||
heading/list/quote/rule and losing its content. Front-matter stripping is
|
||||
scoped to the import path only. (#493)
|
||||
- **The server no longer runs out of heap during long autonomous agent runs.** A
|
||||
new pnpm patch on `ai@6.0.134` stops the SDK from building a cumulative
|
||||
snapshot of the ENTIRE turn text on every streamed text-delta when no output
|
||||
@@ -496,6 +566,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
through that exact share (its own share or an ancestor `includeSubPages`
|
||||
share); any other value now returns the generic "not found" instead of
|
||||
serving the page. (#218)
|
||||
- **MCP tool-allowlist semantics flipped: an empty `[]` now means deny-all
|
||||
(previously it was coerced to "no restrictions").** For an external MCP server,
|
||||
a stored `tool_allowlist` of `[]` now denies **every** tool of that server
|
||||
(zero tools reach the agent) instead of being treated as an empty/unset filter
|
||||
that allowed all of them. A corrupt or non-array stored value now **fails
|
||||
closed** to deny-all rather than silently allowing everything. The admin form
|
||||
no longer silently widens an existing deny-all server: leaving its tag field
|
||||
empty preserves `[]` (deny-all) on save instead of NULL-ing the column to
|
||||
allow-all, so a routine rename/toggle can no longer grant the agent every tool.
|
||||
"No restrictions" is still expressible — a genuinely unrestricted server stores
|
||||
NULL, and clearing the field on such a server keeps it NULL. Operationally
|
||||
significant: audit any server that was created or left with a literal `[]`, as
|
||||
it now exposes no tools until an explicit allowlist (or NULL) is set. (#476)
|
||||
|
||||
- **Tool and provider error text no longer leaks to anonymous readers in the
|
||||
public-share AI chat.** A failing tool's raw error (which could carry an
|
||||
|
||||
@@ -59,6 +59,14 @@ COPY --from=builder /app/packages/mcp/data /app/packages/mcp/data
|
||||
COPY --from=builder /app/packages/prosemirror-markdown/build /app/packages/prosemirror-markdown/build
|
||||
COPY --from=builder /app/packages/prosemirror-markdown/package.json /app/packages/prosemirror-markdown/package.json
|
||||
|
||||
# apps/server imports @docmost/token-estimate (workspace:*) at runtime
|
||||
# (history-budget.ts, #490). tsc emits only dist/ and dist/ is gitignored, so the
|
||||
# prod install would resolve a broken workspace symlink and the server would die
|
||||
# with ERR_MODULE_NOT_FOUND on the first history-budget call. Ship the built
|
||||
# package + its manifest, mirroring prosemirror-markdown above.
|
||||
COPY --from=builder /app/packages/token-estimate/dist /app/packages/token-estimate/dist
|
||||
COPY --from=builder /app/packages/token-estimate/package.json /app/packages/token-estimate/package.json
|
||||
|
||||
# Copy root package files
|
||||
COPY --from=builder /app/package.json /app/package.json
|
||||
COPY --from=builder /app/pnpm*.yaml /app/
|
||||
|
||||
@@ -206,6 +206,137 @@ start the new migrations apply on top of your existing schema (`CREATE EXTENSION
|
||||
existing pages are indexed on their next edit. pgvector is still required for the migration to
|
||||
apply at all.
|
||||
|
||||
## Local embeddings server
|
||||
|
||||
The AI agent's semantic (RAG) search needs an **embeddings model**. Instead of paying a cloud
|
||||
provider (e.g. OpenAI `text-embedding-3-*`) to embed every page, you can run a small open-weights
|
||||
model yourself with Hugging Face
|
||||
[Text Embeddings Inference](https://github.com/huggingface/text-embeddings-inference) (TEI), which
|
||||
serves an OpenAI-compatible `/v1/embeddings` endpoint. `intfloat/multilingual-e5-small` is a good
|
||||
default: multilingual, 384-dim, and comfortable on CPU (~1–2 GB RAM, 1–2 vCPU). Point Gitmost at it
|
||||
under **Workspace settings → AI → Embeddings**.
|
||||
|
||||
### Option A — local (same Docker network as Gitmost)
|
||||
|
||||
Run TEI as a container on the network Gitmost is already on. The port is never published, so the
|
||||
endpoint stays internal and needs no authentication.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
embeddings:
|
||||
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.9 # pin version; use a cuda-* tag for GPU
|
||||
container_name: embeddings
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- gitmost_net # same network Gitmost is on
|
||||
command:
|
||||
- "--model-id"
|
||||
- "intfloat/multilingual-e5-small"
|
||||
- "--auto-truncate" # clamp over-long inputs instead of returning 413
|
||||
volumes:
|
||||
- tei-models:/data # weights are downloaded once and cached here
|
||||
|
||||
networks:
|
||||
gitmost_net:
|
||||
external: true # the network Gitmost already uses
|
||||
|
||||
volumes:
|
||||
tei-models:
|
||||
```
|
||||
|
||||
Gitmost settings (**Workspace settings → AI → Embeddings**):
|
||||
|
||||
| Field | Value |
|
||||
|-------------------|-----------------------------------|
|
||||
| Model | `intfloat/multilingual-e5-small` |
|
||||
| Base URL | `http://embeddings:80/v1/` |
|
||||
| Embedding API key | — (leave empty) |
|
||||
|
||||
> `embeddings` is the container name — Gitmost resolves it over DNS inside the Docker network.
|
||||
> The port is not published, so the endpoint is reachable only by containers on that network and
|
||||
> no authorization is required.
|
||||
|
||||
### Option B — separate host (public via Traefik + Let's Encrypt)
|
||||
|
||||
This assumes the host already runs Traefik with an ACME resolver (the example below uses
|
||||
`letsEncrypt`, the `websecure` entrypoint and a shared `docker_main_net` network). Replace the
|
||||
domain / network / resolver with your own.
|
||||
|
||||
**DNS:** add an A record `embeddings.example.com` → the IP of your Traefik host (same
|
||||
challenge / port 80 as the rest of your sites).
|
||||
|
||||
```yaml
|
||||
services:
|
||||
embeddings:
|
||||
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.9 # pin version; cuda-* tag for GPU
|
||||
container_name: embeddings
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- docker_main_net # the network Traefik is attached to
|
||||
command:
|
||||
- "--model-id"
|
||||
- "intfloat/multilingual-e5-small"
|
||||
- "--auto-truncate"
|
||||
- "--api-key"
|
||||
- "sk-emb-REPLACE_WITH_YOUR_KEY"
|
||||
volumes:
|
||||
- tei-models:/data
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.embeddings.rule: "Host(`embeddings.example.com`)"
|
||||
traefik.http.routers.embeddings.entrypoints: "websecure"
|
||||
traefik.http.routers.embeddings.tls: "true"
|
||||
traefik.http.routers.embeddings.tls.certresolver: "letsEncrypt"
|
||||
traefik.http.routers.embeddings.service: "embeddings"
|
||||
traefik.http.services.embeddings.loadbalancer.server.port: "80"
|
||||
# TEI enforces the Bearer key itself; Traefik only rate-limits to protect the CPU
|
||||
traefik.http.routers.embeddings.middlewares: "embeddings-rl"
|
||||
traefik.http.middlewares.embeddings-rl.ratelimit.average: "20"
|
||||
traefik.http.middlewares.embeddings-rl.ratelimit.burst: "40"
|
||||
traefik.http.middlewares.embeddings-rl.ratelimit.period: "1s"
|
||||
|
||||
networks:
|
||||
docker_main_net:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
tei-models:
|
||||
```
|
||||
|
||||
Gitmost settings (**Workspace settings → AI → Embeddings**):
|
||||
|
||||
| Field | Value |
|
||||
|-------------------|---------------------------------------|
|
||||
| Model | `intfloat/multilingual-e5-small` |
|
||||
| Base URL | `https://embeddings.example.com/v1/` |
|
||||
| Embedding API key | your `sk-emb-…` |
|
||||
|
||||
Check it from outside:
|
||||
|
||||
```bash
|
||||
curl -s https://embeddings.example.com/v1/embeddings \
|
||||
-H "Authorization: Bearer sk-emb-REPLACE_WITH_YOUR_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"intfloat/multilingual-e5-small","input":"query: hello"}' \
|
||||
| python3 -c 'import sys,json;print("dims:",len(json.load(sys.stdin)["data"][0]["embedding"]))'
|
||||
# -> dims: 384
|
||||
```
|
||||
|
||||
### Embeddings server notes
|
||||
|
||||
- **Vector dimension is 384.** If this Gitmost was previously embedded with a different model
|
||||
(e.g. `text-embedding-3-large` = 3072-dim), the old pgvector rows won't match the new dimension —
|
||||
clear the existing embeddings / re-index before switching. Gitmost only compares vectors of the
|
||||
same dimension, so mixed-dimension rows are silently ignored rather than searched.
|
||||
- **First start downloads the weights** (hundreds of MB) from `huggingface.co` into the
|
||||
`tei-models` volume; every start after that reads from the volume.
|
||||
- **Pin the version.** Pin the image, and optionally the model: add `--revision <commit-sha>` to
|
||||
`command` (the sha is on the model's page on Hugging Face).
|
||||
- **Air-gapped / no egress:** seed the `tei-models` volume ahead of time and add
|
||||
`environment: [HF_HUB_OFFLINE=1]`.
|
||||
- **GPU:** use the cuda tag of the same release (e.g.
|
||||
`ghcr.io/huggingface/text-embeddings-inference:cuda-1.9`) and start the container with `gpus: all`.
|
||||
|
||||
## Features
|
||||
|
||||
- Real-time collaboration
|
||||
|
||||
+131
@@ -193,6 +193,137 @@ dump/restore, существующий каталог данных переис
|
||||
> неизменным и бэкапьте вместе с базой данных.
|
||||
|
||||
|
||||
## Локальный сервер эмбеддингов
|
||||
|
||||
Семантическому (RAG) поиску AI-агента нужна **модель эмбеддингов**. Вместо оплаты облачного
|
||||
провайдера (например, OpenAI `text-embedding-3-*`) за эмбеддинг каждой страницы можно запустить
|
||||
небольшую open-weights модель у себя через Hugging Face
|
||||
[Text Embeddings Inference](https://github.com/huggingface/text-embeddings-inference) (TEI) — он
|
||||
отдаёт OpenAI-совместимый эндпоинт `/v1/embeddings`. Хороший дефолт — `intfloat/multilingual-e5-small`:
|
||||
многоязычная, 384-мерная, комфортно работает на CPU (~1–2 ГБ RAM, 1–2 vCPU). Пропишите её в
|
||||
**Настройки воркспейса → AI → Эмбеддинги**.
|
||||
|
||||
### Вариант A — локально (та же Docker-сеть, что и Gitmost)
|
||||
|
||||
Запустите TEI контейнером в той же сети, где уже работает Gitmost. Порт наружу не публикуется,
|
||||
поэтому эндпоинт остаётся внутренним и не требует авторизации.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
embeddings:
|
||||
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.9 # pin version; use a cuda-* tag for GPU
|
||||
container_name: embeddings
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- gitmost_net # same network Gitmost is on
|
||||
command:
|
||||
- "--model-id"
|
||||
- "intfloat/multilingual-e5-small"
|
||||
- "--auto-truncate" # clamp over-long inputs instead of returning 413
|
||||
volumes:
|
||||
- tei-models:/data # weights are downloaded once and cached here
|
||||
|
||||
networks:
|
||||
gitmost_net:
|
||||
external: true # the network Gitmost already uses
|
||||
|
||||
volumes:
|
||||
tei-models:
|
||||
```
|
||||
|
||||
Настройки Gitmost (**Настройки воркспейса → AI → Эмбеддинги**):
|
||||
|
||||
| Поле | Значение |
|
||||
|-------------------|-----------------------------------|
|
||||
| Model | `intfloat/multilingual-e5-small` |
|
||||
| Base URL | `http://embeddings:80/v1/` |
|
||||
| Embedding API key | — (оставить пустым) |
|
||||
|
||||
> `embeddings` — имя контейнера, Gitmost резолвит его по DNS внутри Docker-сети.
|
||||
> Наружу порт не публикуется, эндпоинт доступен только контейнерам этой сети, поэтому
|
||||
> авторизация не нужна.
|
||||
|
||||
### Вариант B — на отдельном хосте (наружу через Traefik + Let's Encrypt)
|
||||
|
||||
Предполагается, что на хосте уже есть Traefik с ACME-резолвером (в примере ниже — `letsEncrypt`,
|
||||
entrypoint `websecure`, общая сеть `docker_main_net`). Замените домен / сеть / резолвер на свои.
|
||||
|
||||
**DNS:** заведите A-запись `embeddings.example.com` → IP хоста с Traefik (тот же challenge / порт 80,
|
||||
что и у остальных сайтов).
|
||||
|
||||
```yaml
|
||||
services:
|
||||
embeddings:
|
||||
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.9 # pin version; cuda-* tag for GPU
|
||||
container_name: embeddings
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- docker_main_net # the network Traefik is attached to
|
||||
command:
|
||||
- "--model-id"
|
||||
- "intfloat/multilingual-e5-small"
|
||||
- "--auto-truncate"
|
||||
- "--api-key"
|
||||
- "sk-emb-REPLACE_WITH_YOUR_KEY"
|
||||
volumes:
|
||||
- tei-models:/data
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.embeddings.rule: "Host(`embeddings.example.com`)"
|
||||
traefik.http.routers.embeddings.entrypoints: "websecure"
|
||||
traefik.http.routers.embeddings.tls: "true"
|
||||
traefik.http.routers.embeddings.tls.certresolver: "letsEncrypt"
|
||||
traefik.http.routers.embeddings.service: "embeddings"
|
||||
traefik.http.services.embeddings.loadbalancer.server.port: "80"
|
||||
# TEI enforces the Bearer key itself; Traefik only rate-limits to protect the CPU
|
||||
traefik.http.routers.embeddings.middlewares: "embeddings-rl"
|
||||
traefik.http.middlewares.embeddings-rl.ratelimit.average: "20"
|
||||
traefik.http.middlewares.embeddings-rl.ratelimit.burst: "40"
|
||||
traefik.http.middlewares.embeddings-rl.ratelimit.period: "1s"
|
||||
|
||||
networks:
|
||||
docker_main_net:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
tei-models:
|
||||
```
|
||||
|
||||
Настройки Gitmost (**Настройки воркспейса → AI → Эмбеддинги**):
|
||||
|
||||
| Поле | Значение |
|
||||
|-------------------|---------------------------------------|
|
||||
| Model | `intfloat/multilingual-e5-small` |
|
||||
| Base URL | `https://embeddings.example.com/v1/` |
|
||||
| Embedding API key | ваш `sk-emb-…` |
|
||||
|
||||
Проверка снаружи:
|
||||
|
||||
```bash
|
||||
curl -s https://embeddings.example.com/v1/embeddings \
|
||||
-H "Authorization: Bearer sk-emb-REPLACE_WITH_YOUR_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"intfloat/multilingual-e5-small","input":"query: hello"}' \
|
||||
| python3 -c 'import sys,json;print("dims:",len(json.load(sys.stdin)["data"][0]["embedding"]))'
|
||||
# -> dims: 384
|
||||
```
|
||||
|
||||
### Заметки про сервер эмбеддингов
|
||||
|
||||
- **Размерность вектора — 384.** Если раньше этот Gitmost эмбеддился другой моделью
|
||||
(например, `text-embedding-3-large` = 3072-dim), старые строки в pgvector не совпадут по
|
||||
размерности — очистите существующие эмбеддинги / переиндексируйте перед переключением. Gitmost
|
||||
сравнивает только вектора одной размерности, поэтому строки другой размерности не участвуют в
|
||||
поиске, а не ломают его.
|
||||
- **Первый старт тянет веса** (сотни МБ) с `huggingface.co` в том `tei-models`; дальше — из тома.
|
||||
- **Пин версии.** Пиньте образ, а при желании и модель: добавьте в `command` `--revision <commit-sha>`
|
||||
(sha берётся со страницы модели на Hugging Face).
|
||||
- **Без egress (air-gapped):** засейте том `tei-models` заранее и добавьте
|
||||
`environment: [HF_HUB_OFFLINE=1]`.
|
||||
- **GPU:** возьмите cuda-тег того же релиза (например,
|
||||
`ghcr.io/huggingface/text-embeddings-inference:cuda-1.9`) и запустите контейнер с `gpus: all`.
|
||||
|
||||
|
||||
## Возможности
|
||||
|
||||
- Совместная работа в реальном времени
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"@casl/react": "5.0.1",
|
||||
"@docmost/editor-ext": "workspace:*",
|
||||
"@docmost/prosemirror-markdown": "workspace:*",
|
||||
"@docmost/token-estimate": "workspace:*",
|
||||
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
|
||||
"@mantine/core": "8.3.18",
|
||||
"@mantine/dates": "8.3.18",
|
||||
|
||||
@@ -256,6 +256,9 @@
|
||||
"Invite link": "Ссылка для приглашения",
|
||||
"Copy": "Копировать",
|
||||
"Copy to space": "Копировать в пространство",
|
||||
"Copy chat": "Копировать чат",
|
||||
"Dock to sidebar": "Закрепить в боковой панели",
|
||||
"Undock": "Открепить",
|
||||
"Copied": "Скопировано",
|
||||
"Failed to export chat": "Не удалось экспортировать чат",
|
||||
"Duplicate": "Дублировать",
|
||||
@@ -285,6 +288,9 @@
|
||||
"Alt text": "Альтернативный текст",
|
||||
"Describe this for accessibility.": "Опишите это для специальных возможностей.",
|
||||
"Add a description": "Добавить описание",
|
||||
"Caption": "Подпись",
|
||||
"Add a caption": "Добавить подпись",
|
||||
"Shown below the image.": "Отображается под изображением.",
|
||||
"Justify": "По ширине",
|
||||
"Merge cells": "Объединить ячейки",
|
||||
"Split cell": "Разделить ячейку",
|
||||
@@ -388,22 +394,6 @@
|
||||
"Quote": "Цитата",
|
||||
"Image": "Изображение",
|
||||
"Audio": "Аудио",
|
||||
"Transcribe": "Транскрибировать",
|
||||
"Transcribing…": "Транскрибация…",
|
||||
"No speech detected": "Речь не распознана",
|
||||
"Transcription failed": "Не удалось распознать речь",
|
||||
"Voice dictation is not configured": "Голосовой ввод не настроен",
|
||||
"Start dictation": "Начать диктовку",
|
||||
"Stop recording": "Остановить запись",
|
||||
"Microphone access denied": "Доступ к микрофону запрещён",
|
||||
"No microphone found": "Микрофон не найден",
|
||||
"Microphone is unavailable or already in use": "Микрофон недоступен или уже используется",
|
||||
"Could not start recording": "Не удалось начать запись",
|
||||
"Audio recording is not available in this browser/context": "Запись аудио недоступна в этом браузере/контексте",
|
||||
"Dictation": "Диктовка",
|
||||
"Dictation becomes available once the page finishes connecting": "Диктовка станет доступна после подключения к документу",
|
||||
"No connection to the collaboration server — dictation unavailable": "Нет связи с сервером совместного редактирования — диктовка недоступна",
|
||||
"This page is read-only": "Страница открыта только для чтения",
|
||||
"Embed PDF": "Встроить PDF",
|
||||
"Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.",
|
||||
"Embed as PDF": "Встроить как PDF",
|
||||
@@ -419,9 +409,6 @@
|
||||
"Footnote {{number}}": "Сноска {{number}}",
|
||||
"Go to footnote": "Перейти к сноске",
|
||||
"Back to reference": "Вернуться к ссылке",
|
||||
"Back to references": "Вернуться к ссылкам",
|
||||
"Back to reference {{label}}": "Вернуться к ссылке {{label}}",
|
||||
"Empty footnote": "Пустая сноска",
|
||||
"Math inline": "Строчная формула",
|
||||
"Insert inline math equation.": "Вставить математическое выражение в строку.",
|
||||
"Math block": "Блок формулы",
|
||||
@@ -447,6 +434,9 @@
|
||||
"{{count}} command available_other": "Доступно {{count}} команд",
|
||||
"{{count}} result available_one": "Доступен 1 результат",
|
||||
"{{count}} result available_other": "Доступно {{count}} результатов",
|
||||
"{{count}} result found_one": "Найден {{count}} результат",
|
||||
"{{count}} result found_few": "Найдено {{count}} результата",
|
||||
"{{count}} result found_other": "Найдено {{count}} результатов",
|
||||
"Equal columns": "Равные столбцы",
|
||||
"Left sidebar": "Левая боковая панель",
|
||||
"Right sidebar": "Правая боковая панель",
|
||||
@@ -456,6 +446,7 @@
|
||||
"Names do not match": "Названия не совпадают",
|
||||
"Today, {{time}}": "Сегодня, {{time}}",
|
||||
"Yesterday, {{time}}": "Вчера, {{time}}",
|
||||
"now": "сейчас",
|
||||
"Space created successfully": "Пространство успешно создано",
|
||||
"Space updated successfully": "Пространство успешно обновлено",
|
||||
"Space deleted successfully": "Пространство успешно удалено",
|
||||
@@ -559,6 +550,7 @@
|
||||
"Add 2FA method": "Добавить метод 2FA",
|
||||
"Backup codes": "Резервные коды",
|
||||
"Disable": "Отключить",
|
||||
"disabled": "отключено",
|
||||
"Invalid verification code": "Недействительный код подтверждения",
|
||||
"New backup codes have been generated": "Новые резервные коды сгенерированы",
|
||||
"Failed to regenerate backup codes": "Не удалось заново сгенерировать резервные коды",
|
||||
@@ -702,62 +694,6 @@
|
||||
"AI search": "Поиск ИИ",
|
||||
"AI Answer": "Ответ ИИ",
|
||||
"Ask AI": "Спросить ИИ",
|
||||
"AI agent": "AI-агент",
|
||||
"Take a look at the current document": "Посмотри текущий документ",
|
||||
"Start automatically": "Запускать автоматически",
|
||||
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "Когда включено, выбор этой роли отправляет стартовое сообщение и начинает чат. Когда выключено, роль выбирается, а первое сообщение вы вводите сами.",
|
||||
"Launch message": "Стартовое сообщение",
|
||||
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Отправляется автоматически при выборе этой роли. Оставьте пустым, чтобы использовать текст по умолчанию. Игнорируется, когда «Запускать автоматически» выключено.",
|
||||
"AI agent is typing…": "AI-агент печатает…",
|
||||
"{{name}} is typing…": "{{name}} печатает…",
|
||||
"Thinking…": "Думаю…",
|
||||
"Thinking… · {{count}} tokens": "Думаю… · {{count}} токенов",
|
||||
"Thinking… · {{count}} tokens_one": "Думаю… · {{count}} токен",
|
||||
"Thinking… · {{count}} tokens_few": "Думаю… · {{count}} токена",
|
||||
"Thinking… · {{count}} tokens_many": "Думаю… · {{count}} токенов",
|
||||
"Thinking · {{count}} tokens": "Размышления · {{count}} токенов",
|
||||
"Thinking · {{count}} tokens_one": "Размышления · {{count}} токен",
|
||||
"Thinking · {{count}} tokens_few": "Размышления · {{count}} токена",
|
||||
"Thinking · {{count}} tokens_many": "Размышления · {{count}} токенов",
|
||||
"Agent role": "Роль агента",
|
||||
"AI chat": "AI-чат",
|
||||
"AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.",
|
||||
"Ask a question about this documentation.": "Задайте вопрос об этой документации.",
|
||||
"Ask a question…": "Задайте вопрос…",
|
||||
"Ask the AI agent anything about your workspace.": "Спросите AI-агента о чём угодно по вашему рабочему пространству.",
|
||||
"Ask the AI agent…": "Спросите AI-агента…",
|
||||
"Copy chat": "Копировать чат",
|
||||
"Dock to sidebar": "Закрепить в боковой панели",
|
||||
"Undock": "Открепить",
|
||||
"Created successfully": "Успешно создано",
|
||||
"Context size / model limit": "Размер контекста / лимит модели",
|
||||
"Context window (tokens)": "Окно контекста (токены)",
|
||||
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
|
||||
"Delete this chat?": "Удалить этот чат?",
|
||||
"Deleted successfully": "Успешно удалено",
|
||||
"AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}",
|
||||
"AI agent {{name}}": "AI-агент {{name}}",
|
||||
"Failed to delete chat": "Не удалось удалить чат",
|
||||
"Failed to rename chat": "Не удалось переименовать чат",
|
||||
"Failed": "Ошибка",
|
||||
"OK · {{n}}": "OK · {{n}}",
|
||||
"Test": "Тест",
|
||||
"No tools available": "Инструменты недоступны",
|
||||
"Available tools": "Доступные инструменты",
|
||||
"Minimize": "Свернуть",
|
||||
"No chats yet.": "Чатов пока нет.",
|
||||
"Send": "Отправить",
|
||||
"Send when the agent finishes": "Отправить, когда агент закончит",
|
||||
"Queue message": "Поставить в очередь",
|
||||
"Remove queued message": "Убрать из очереди",
|
||||
"Send now": "Отправить сейчас",
|
||||
"Interrupt and send now": "Прервать и отправить сейчас",
|
||||
"Something went wrong": "Что-то пошло не так",
|
||||
"Stop": "Стоп",
|
||||
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",
|
||||
"The AI provider is not configured. Ask an administrator to set it up.": "AI-провайдер не настроен. Попросите администратора настроить его.",
|
||||
"Universal assistant": "Универсальный ассистент",
|
||||
"You": "Вы",
|
||||
"AI is thinking...": "ИИ обрабатывает запрос...",
|
||||
"Thinking": "Думаю",
|
||||
"Ask a question...": "Задайте вопрос...",
|
||||
@@ -784,8 +720,40 @@
|
||||
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Управляйте API-ключами для всех пользователей в рабочем пространстве. Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
||||
"View the <anchor>API documentation</anchor> for usage details.": "Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
||||
"View the <anchor>MCP documentation</anchor>.": "Смотрите <anchor>документацию по MCP</anchor>.",
|
||||
"Instructions": "Инструкции",
|
||||
"AI / Models": "ИИ / Модели",
|
||||
"AI / External tools (MCP)": "ИИ / Внешние инструменты (MCP)",
|
||||
"Add server": "Добавить сервер",
|
||||
"Edit server": "Изменить сервер",
|
||||
"Delete server": "Удалить сервер",
|
||||
"Are you sure you want to delete this MCP server?": "Вы уверены, что хотите удалить этот MCP-сервер?",
|
||||
"No external servers configured": "Внешние серверы не настроены",
|
||||
"Server name": "Имя сервера",
|
||||
"Transport": "Транспорт",
|
||||
"URL": "URL",
|
||||
"Authorization header": "Заголовок авторизации",
|
||||
"Tool allowlist": "Список разрешённых инструментов",
|
||||
"Optional. Leave empty to allow all tools the server exposes.": "Необязательно. Оставьте пустым, чтобы разрешить все инструменты, которые предоставляет сервер.",
|
||||
"Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".": "Необязательное указание агенту, как и когда использовать инструменты этого сервера. Добавляется в системный промпт. Инструменты сервера именуются с префиксом «<имя сервера>_*».",
|
||||
"Test": "Тест",
|
||||
"Available tools": "Доступные инструменты",
|
||||
"No tools available": "Инструменты недоступны",
|
||||
"Failed": "Ошибка",
|
||||
"OK · {{n}}": "OK · {{n}}",
|
||||
"Created successfully": "Успешно создано",
|
||||
"Deleted successfully": "Успешно удалено",
|
||||
"Clear": "Очистить",
|
||||
"Provider": "Провайдер",
|
||||
"•••• set": "•••• задан",
|
||||
"Clear key": "Очистить ключ",
|
||||
"Base URL": "Базовый URL",
|
||||
"Chat model": "Модель чата",
|
||||
"Embedding model": "Модель эмбеддингов",
|
||||
"System message": "Системное сообщение",
|
||||
"A built-in safety framework is always appended.": "Встроенный набор правил безопасности всегда добавляется автоматически.",
|
||||
"Test connection": "Проверить соединение",
|
||||
"Connection successful": "Соединение установлено",
|
||||
"Connection failed": "Не удалось установить соединение",
|
||||
"Only workspace admins can manage AI provider settings.": "Управлять настройками провайдера ИИ могут только администраторы рабочего пространства.",
|
||||
"Sources": "Источники",
|
||||
"AI Answers not available for attachments": "Ответы ИИ недоступны для вложений",
|
||||
"No answer available": "Ответ недоступен",
|
||||
@@ -1013,6 +981,7 @@
|
||||
"Try again": "Попробовать снова",
|
||||
"Untitled chat": "Чат без названия",
|
||||
"No document": "Без документа",
|
||||
"You": "Вы",
|
||||
"What can I help you with?": "Чем я могу вам помочь?",
|
||||
"Are you sure you want to revoke this {{credential}}": "Вы уверены, что хотите отозвать этот {{credential}}",
|
||||
"Automatically provision users and groups from your identity provider via SCIM.": "Автоматически предоставляйте доступ пользователям и группам из вашего провайдера удостоверений через SCIM.",
|
||||
@@ -1041,6 +1010,9 @@
|
||||
"Page menu": "Меню страницы",
|
||||
"Expand": "Развернуть",
|
||||
"Collapse": "Свернуть",
|
||||
"Expand all": "Развернуть все",
|
||||
"Collapse all": "Свернуть все",
|
||||
"Couldn't expand the tree: {{reason}}": "Не удалось развернуть дерево: {{reason}}",
|
||||
"Comment menu": "Меню комментария",
|
||||
"Group menu": "Меню группы",
|
||||
"Show hidden breadcrumbs": "Показать скрытые хлебные крошки",
|
||||
@@ -1077,7 +1049,7 @@
|
||||
"Search pages and spaces...": "Поиск страниц и пространств...",
|
||||
"No results found": "Результаты не найдены",
|
||||
"You don't have permission to create pages here": "У вас нет прав на создание страниц здесь",
|
||||
"Chat menu": "Меню чата",
|
||||
"Chat menu for {{title}}": "Меню чата для {{title}}",
|
||||
"API key menu": "Меню API-ключа",
|
||||
"Jump to comment selection": "Перейти к выбору комментария",
|
||||
"Slash commands": "Команды со слешем",
|
||||
@@ -1131,6 +1103,9 @@
|
||||
"Undo": "Отменить",
|
||||
"Redo": "Повторить",
|
||||
"Backlinks": "Обратные ссылки",
|
||||
"Back to references": "Вернуться к ссылкам",
|
||||
"Back to reference {{label}}": "Вернуться к ссылке {{label}}",
|
||||
"Empty footnote": "Пустая сноска",
|
||||
"Last updated by": "Последний изменивший",
|
||||
"Last updated": "Последнее обновление",
|
||||
"Stats": "Статистика",
|
||||
@@ -1164,6 +1139,7 @@
|
||||
"Page title": "Заголовок страницы",
|
||||
"Page content": "Содержимое страницы",
|
||||
"Member actions": "Действия с участником",
|
||||
"Member actions for {{name}}": "Действия с участником {{name}}",
|
||||
"Toggle password visibility": "Переключить видимость пароля",
|
||||
"Send comment": "Отправить комментарий",
|
||||
"Token actions": "Действия с токеном",
|
||||
@@ -1183,11 +1159,187 @@
|
||||
"Removed from favorites": "Удалено из избранного",
|
||||
"Added {{name}} to favorites": "{{name}} добавлено в избранное",
|
||||
"Removed {{name}} from favorites": "{{name}} удалено из избранного",
|
||||
"Label added": "Метка добавлена",
|
||||
"Label removed": "Метка удалена",
|
||||
"Image updated": "Изображение обновлено",
|
||||
"Unsupported image type": "Неподдерживаемый тип изображения",
|
||||
"Member deactivated": "Участник деактивирован",
|
||||
"Member activated": "Участник активирован",
|
||||
"Name is required": "Укажите имя",
|
||||
"Name must be 40 characters or fewer": "Имя должно содержать не более 40 символов",
|
||||
"Group name must be at least 2 characters": "Название группы должно содержать не менее 2 символов",
|
||||
"Group name must be 100 characters or fewer": "Название группы должно содержать не более 100 символов",
|
||||
"Description must be 500 characters or fewer": "Описание должно содержать не более 500 символов",
|
||||
"Invalid invitation link": "Недействительная ссылка-приглашение",
|
||||
"Page menu for {{name}}": "Меню страницы для {{name}}",
|
||||
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
|
||||
"AI chat": "AI-чат",
|
||||
"Ask a question about this documentation.": "Задайте вопрос об этой документации.",
|
||||
"Ask a question…": "Задайте вопрос…",
|
||||
"Thinking…": "Думаю…",
|
||||
"Thinking… · {{count}} tokens": "Думаю… · {{count}} токенов",
|
||||
"Thinking… · {{count}} tokens_one": "Думаю… · {{count}} токен",
|
||||
"Thinking… · {{count}} tokens_few": "Думаю… · {{count}} токена",
|
||||
"Thinking… · {{count}} tokens_many": "Думаю… · {{count}} токенов",
|
||||
"Thinking… · {{count}} tokens_other": "Думаю… · {{count}} токенов",
|
||||
"Thinking · {{count}} tokens": "Размышления · {{count}} токенов",
|
||||
"Thinking · {{count}} tokens_one": "Размышления · {{count}} токен",
|
||||
"Thinking · {{count}} tokens_few": "Размышления · {{count}} токена",
|
||||
"Thinking · {{count}} tokens_many": "Размышления · {{count}} токенов",
|
||||
"Thinking · {{count}} tokens_other": "Размышления · {{count}} токенов",
|
||||
"The assistant is unavailable right now. Please try again.": "Ассистент сейчас недоступен. Попробуйте ещё раз.",
|
||||
"Public share assistant": "Ассистент публичного доступа",
|
||||
"Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.": "Позвольте анонимным посетителям публичных ссылок обращаться к ИИ-ассистенту в рамках страниц этой публикации. Токены оплачиваете вы.",
|
||||
"Public assistant model": "Модель публичного ассистента",
|
||||
"Defaults to the chat model": "По умолчанию используется модель чата",
|
||||
"Optional cheaper model id for the public assistant. Empty uses the chat model above.": "Необязательный более дешёвый идентификатор модели для публичного ассистента. Если пусто, используется модель чата выше.",
|
||||
"Assistant identity": "Личность ассистента",
|
||||
"Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.": "Выберите роль агента, чью личность примет публичный ассистент. Правила безопасности всегда остаются в силе.",
|
||||
"Built-in assistant persona": "Встроенная личность ассистента",
|
||||
"Minimize": "Свернуть",
|
||||
"Context size / model limit": "Размер контекста / лимит модели",
|
||||
"Context window (tokens)": "Окно контекста (токены)",
|
||||
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
|
||||
"AI agent": "AI-агент",
|
||||
"Take a look at the current document": "Посмотри текущий документ",
|
||||
"AI agent is typing…": "AI-агент печатает…",
|
||||
"{{name}} is typing…": "{{name}} печатает…",
|
||||
"Send": "Отправить",
|
||||
"Send when the agent finishes": "Отправить, когда агент закончит",
|
||||
"Queue message": "Поставить в очередь",
|
||||
"Remove queued message": "Убрать из очереди",
|
||||
"Send now": "Отправить сейчас",
|
||||
"Interrupt and send now": "Прервать и отправить сейчас",
|
||||
"Stop": "Стоп",
|
||||
"Response stopped.": "Ответ остановлен.",
|
||||
"Connection lost — the answer was interrupted.": "Соединение потеряно — ответ был прерван.",
|
||||
"Response stopped (manually or the connection dropped).": "Ответ остановлен (вручную или из-за разрыва соединения).",
|
||||
"Chat menu": "Меню чата",
|
||||
"No chats yet.": "Чатов пока нет.",
|
||||
"Delete this chat?": "Удалить этот чат?",
|
||||
"Ask the AI agent…": "Спросите AI-агента…",
|
||||
"Ask the AI agent anything about your workspace.": "Спросите AI-агента о чём угодно по вашему рабочему пространству.",
|
||||
"Failed to rename chat": "Не удалось переименовать чат",
|
||||
"Failed to delete chat": "Не удалось удалить чат",
|
||||
"Something went wrong": "Что-то пошло не так",
|
||||
"AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.",
|
||||
"The AI provider is not configured. Ask an administrator to set it up.": "AI-провайдер не настроен. Попросите администратора настроить его.",
|
||||
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",
|
||||
"Searched pages": "Поиск по страницам",
|
||||
"Read page": "Прочитана страница",
|
||||
"Created page": "Создана страница",
|
||||
"Updated page": "Обновлена страница",
|
||||
"Renamed page": "Переименована страница",
|
||||
"Moved page": "Перемещена страница",
|
||||
"Deleted page (to trash)": "Удалена страница (в корзину)",
|
||||
"Commented": "Добавлен комментарий",
|
||||
"Resolved comment": "Комментарий решён",
|
||||
"Ran tool {{name}}": "Выполнен инструмент {{name}}",
|
||||
"AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}",
|
||||
"AI agent {{name}}": "AI-агент {{name}}",
|
||||
"Endpoints": "Эндпоинты",
|
||||
"where we fetch models": "откуда мы получаем модели",
|
||||
"All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.": "Все эндпоинты совместимы с OpenAI. Укажите в базовом URL адрес OpenAI, OpenRouter, локального Ollama или любого self-hosted сервера.",
|
||||
"Chat / LLM": "Чат / LLM",
|
||||
"root": "корневой",
|
||||
"Semantic search": "Семантический поиск",
|
||||
"Voice / STT": "Голос / STT",
|
||||
"Voice dictation": "Голосовой ввод",
|
||||
"Streaming dictation": "Потоковый голосовой ввод",
|
||||
"Transcribe as you speak, cutting on pauses": "Транскрибирование по мере речи, с разбивкой на паузах",
|
||||
"Voice dictation is not available yet.": "Голосовой ввод пока недоступен.",
|
||||
"Test endpoint": "Проверить эндпоинт",
|
||||
"Save and test": "Сохранить и проверить",
|
||||
"Save endpoints": "Сохранить эндпоинты",
|
||||
"Configured and enabled": "Настроено и включено",
|
||||
"Configured but disabled": "Настроено, но отключено",
|
||||
"Enabled but not configured": "Включено, но не настроено",
|
||||
"Not configured": "Не настроено",
|
||||
"External tools": "Внешние инструменты",
|
||||
"Gitmost as MCP client": "Gitmost как MCP-клиент",
|
||||
"Servers the agent calls out to.": "Серверы, к которым обращается агент.",
|
||||
"MCP server": "MCP-сервер",
|
||||
"expose the workspace": "открыть доступ к рабочему пространству",
|
||||
"Enable MCP server": "Включить MCP-сервер",
|
||||
"Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.": "Открывает рабочее пространство как MCP-сервер по адресу /mcp — это предоставляет возможность, а не потребляет модель.",
|
||||
"Resolves to {{url}}": "Разрешается в {{url}}",
|
||||
"Model": "Модель",
|
||||
"Done": "Готово",
|
||||
"shared prompt · safety framework appended automatically": "общий промпт · правила безопасности добавляются автоматически",
|
||||
"/v1/chat/completions · root endpoint — Embeddings and Voice inherit its URL and key": "/v1/chat/completions · корневой эндпоинт — Эмбеддинги и Голос наследуют его URL и ключ",
|
||||
"/v1/embeddings · embeds pages so semantic search can find them": "/v1/embeddings · создаёт эмбеддинги страниц, чтобы их находил семантический поиск",
|
||||
"/v1/audio/transcriptions · works with local whisper (speaches / faster-whisper-server)": "/v1/audio/transcriptions · работает с локальным whisper (speaches / faster-whisper-server)",
|
||||
"Vector search · requires pgvector": "Векторный поиск · требуется pgvector",
|
||||
"Embedding API key": "API-ключ для эмбеддингов",
|
||||
"Embeddings": "Эмбеддинги",
|
||||
"Leave empty to use the chat API key": "Оставьте пустым, чтобы использовать API-ключ чата",
|
||||
"Leave empty to use the chat base URL": "Оставьте пустым, чтобы использовать базовый URL чата",
|
||||
"Reindex now": "Переиндексировать сейчас",
|
||||
"Start dictation": "Начать диктовку",
|
||||
"Stop recording": "Остановить запись",
|
||||
"Transcribing…": "Транскрибация…",
|
||||
"Microphone access denied": "Доступ к микрофону запрещён",
|
||||
"No microphone found": "Микрофон не найден",
|
||||
"Could not start recording": "Не удалось начать запись",
|
||||
"Transcription failed": "Не удалось распознать речь",
|
||||
"Transcribe": "Транскрибировать",
|
||||
"No speech detected": "Речь не распознана",
|
||||
"Voice dictation is not configured": "Голосовой ввод не настроен",
|
||||
"Microphone is unavailable or already in use": "Микрофон недоступен или уже используется",
|
||||
"Audio recording is not available in this browser/context": "Запись аудио недоступна в этом браузере/контексте",
|
||||
"Dictation": "Диктовка",
|
||||
"Dictation becomes available once the page finishes connecting": "Диктовка станет доступна после подключения к документу",
|
||||
"No connection to the collaboration server — dictation unavailable": "Нет связи с сервером совместного редактирования — диктовка недоступна",
|
||||
"This page is read-only": "Страница открыта только для чтения",
|
||||
"Request format": "Формат запроса",
|
||||
"How transcription requests are sent to the endpoint": "Как запросы на транскрибирование отправляются на эндпоинт",
|
||||
"OpenAI-compatible (multipart/form-data)": "Совместимо с OpenAI (multipart/form-data)",
|
||||
"OpenRouter (JSON, base64 audio)": "OpenRouter (JSON, аудио в base64)",
|
||||
"Dictation language": "Язык диктовки",
|
||||
"Auto-detect": "Автоопределение",
|
||||
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
|
||||
"Agent role": "Роль агента",
|
||||
"Universal assistant": "Универсальный ассистент",
|
||||
"Add role": "Добавить роль",
|
||||
"Edit role": "Изменить роль",
|
||||
"Role name": "Название роли",
|
||||
"e.g. Proofreader": "напр. Корректор",
|
||||
"Optional. Shown as the chat badge.": "Необязательно. Отображается как значок чата.",
|
||||
"Optional. A short note about what this role does.": "Необязательно. Краткое описание того, что делает эта роль.",
|
||||
"Instructions": "Инструкции",
|
||||
"The built-in safety framework is always added automatically.": "Встроенный набор правил безопасности всегда добавляется автоматически.",
|
||||
"Model provider override": "Переопределение провайдера модели",
|
||||
"Optional. Defaults to the workspace provider.": "Необязательно. По умолчанию используется провайдер рабочего пространства.",
|
||||
"Model override": "Переопределение модели",
|
||||
"Optional. Defaults to the workspace model.": "Необязательно. По умолчанию используется модель рабочего пространства.",
|
||||
"e.g. gpt-4o-mini": "напр. gpt-4o-mini",
|
||||
"If you choose a different provider, it must already be configured in AI settings.": "Если вы выбираете другого провайдера, он уже должен быть настроен в настройках ИИ.",
|
||||
"Start automatically": "Запускать автоматически",
|
||||
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "Когда включено, выбор этой роли отправляет стартовое сообщение и начинает чат. Когда выключено, роль выбирается, а первое сообщение вы вводите сами.",
|
||||
"Launch message": "Стартовое сообщение",
|
||||
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Отправляется автоматически при выборе этой роли. Оставьте пустым, чтобы использовать текст по умолчанию. Игнорируется, когда «Запускать автоматически» выключено.",
|
||||
"Agent roles": "Роли агента",
|
||||
"Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.": "Многоразовые пресеты, определяющие поведение агента (и, при желании, его модель). Выбираются при запуске нового чата.",
|
||||
"No roles configured": "Роли не настроены",
|
||||
"Delete role": "Удалить роль",
|
||||
"Are you sure you want to delete this role?": "Вы уверены, что хотите удалить эту роль?",
|
||||
"HTML embed": "HTML-вставка",
|
||||
"Edit HTML embed": "Изменить HTML-вставку",
|
||||
"HTML embed is disabled in this workspace": "HTML-вставки отключены в этом рабочем пространстве",
|
||||
"Click to add HTML / CSS / JS": "Нажмите, чтобы добавить HTML / CSS / JS",
|
||||
"This HTML/CSS/JS runs in a sandboxed frame and cannot access the viewer's session, cookies, or API.": "Этот HTML/CSS/JS выполняется в изолированном фрейме и не имеет доступа к сессии, cookie или API просматривающего.",
|
||||
"<script>...</script>": "<script>...</script>",
|
||||
"Height (px, blank = auto)": "Высота (px, пусто = авто)",
|
||||
"advanced": "дополнительно",
|
||||
"Enable HTML embed": "Включить HTML-вставки",
|
||||
"Allow members to insert raw HTML/CSS/JavaScript blocks. The block renders in a sandboxed frame and cannot access the viewer's session, cookies, or API. Off by default.": "Разрешить участникам вставлять блоки с необработанным HTML/CSS/JavaScript. Блок отображается в изолированном фрейме и не имеет доступа к сессии, cookie или API просматривающего. По умолчанию выключено.",
|
||||
"When enabled, any member can insert an HTML embed block. The toggle just enables or disables the block type workspace-wide.": "Когда включено, любой участник может вставить блок HTML-вставки. Переключатель просто включает или отключает этот тип блока во всём рабочем пространстве.",
|
||||
"Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.": "Вставки выполняются в изолированном iframe с отдельным источником, поэтому они не могут читать или изменять страницу, в которую встроены.",
|
||||
"Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.": "Отключение этой опции скрывает существующие вставки (они отображаются как отключённая заглушка) и прекращает их показ на публичных страницах.",
|
||||
"Analytics / tracker": "Аналитика / трекер",
|
||||
"Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Вставляется дословно в <head> только ПУБЛИЧНЫХ страниц (тот же источник). Для сниппетов аналитики (Google Analytics, Яндекс.Метрика и т. п.). Только для администраторов.",
|
||||
"Go to login page": "Перейти на страницу входа",
|
||||
"Move to space": "Переместить в пространство",
|
||||
"Float left (wrap text)": "Обтекание слева",
|
||||
"Float right (wrap text)": "Обтекание справа",
|
||||
"Inline (side by side)": "В ряд",
|
||||
@@ -1199,6 +1351,7 @@
|
||||
"Showing {{count}} subpages_one": "Показано {{count}} подстраница",
|
||||
"Showing {{count}} subpages_few": "Показано {{count}} подстраницы",
|
||||
"Showing {{count}} subpages_many": "Показано {{count}} подстраниц",
|
||||
"Showing {{count}} subpages_other": "Показано {{count}} подстраниц",
|
||||
"Protocol": "Протокол",
|
||||
"How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning",
|
||||
"OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)",
|
||||
@@ -1268,7 +1421,6 @@
|
||||
"Retry": "Повторить",
|
||||
"The catalog is empty": "Каталог пуст",
|
||||
"No role bundles are published for this language yet. Try switching the content language.": "Для этого языка ещё не опубликовано ни одного набора ролей. Попробуйте сменить язык контента.",
|
||||
"No roles configured": "Роли не настроены",
|
||||
"Already up to date": "Уже актуальна",
|
||||
"Updated to the latest version": "Обновлено до последней версии",
|
||||
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isChunkLoadError } from "./chunk-load-error-boundary";
|
||||
import { isChunkLoadError, shouldAutoReload } from "./chunk-load-error-boundary";
|
||||
|
||||
// The detector decides whether a caught render error is a stale-deploy chunk-404
|
||||
// (→ auto-reload to fetch the new manifest) vs a genuine app error (→ generic
|
||||
@@ -35,3 +35,31 @@ describe("isChunkLoadError", () => {
|
||||
expect(isChunkLoadError(err)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The window gate replaces the old one-shot flag: it must permit recovery across
|
||||
// several deploys in one tab (each > window apart) while still stopping an infinite
|
||||
// reload loop when a lazy chunk is permanently broken (a second failure < window).
|
||||
describe("shouldAutoReload", () => {
|
||||
const WINDOW = 5 * 60 * 1000;
|
||||
const NOW = 1_000_000_000_000;
|
||||
|
||||
it("allows a reload when we have never auto-reloaded", () => {
|
||||
expect(shouldAutoReload(NOW, null, WINDOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows a reload when the last one was 6 minutes ago (outside the window)", () => {
|
||||
expect(shouldAutoReload(NOW, NOW - 6 * 60 * 1000, WINDOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks a reload when the last one was 1 minute ago (inside the window)", () => {
|
||||
expect(shouldAutoReload(NOW, NOW - 1 * 60 * 1000, WINDOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks a reload exactly at the window boundary (not strictly older)", () => {
|
||||
expect(shouldAutoReload(NOW, NOW - WINDOW, WINDOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a reload when the stored timestamp is unparseable (NaN)", () => {
|
||||
expect(shouldAutoReload(NOW, NaN, WINDOW)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,25 @@ import { ReactNode } from "react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { Button, Center, Stack, Text } from "@mantine/core";
|
||||
|
||||
const RELOAD_FLAG = "chunk-reload-attempted";
|
||||
// sessionStorage key holding the epoch-ms timestamp of the last automatic reload.
|
||||
const RELOAD_AT_KEY = "chunk-reload-at";
|
||||
// Allow at most one automatic reload per this window. A stale-deploy 404 is cured
|
||||
// by a single reload, so anything inside the window is treated as a reload loop
|
||||
// (permanently-broken chunk) and falls through to the manual UI. A window (rather
|
||||
// than a one-shot flag) lets a SECOND deploy in the same tab's lifetime recover too.
|
||||
const RELOAD_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
// Pure window decision, unit-tested in isolation: auto-reload only if we have never
|
||||
// auto-reloaded (lastReloadAt null/NaN) or the last one was strictly older than the
|
||||
// window. Anything inside the window is suppressed to break an infinite reload loop.
|
||||
export function shouldAutoReload(
|
||||
now: number,
|
||||
lastReloadAt: number | null,
|
||||
windowMs: number,
|
||||
): boolean {
|
||||
if (lastReloadAt === null || Number.isNaN(lastReloadAt)) return true;
|
||||
return now - lastReloadAt > windowMs;
|
||||
}
|
||||
|
||||
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
|
||||
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
|
||||
@@ -24,12 +42,16 @@ export function isChunkLoadError(error: unknown): boolean {
|
||||
function handleError(error: unknown) {
|
||||
if (!isChunkLoadError(error)) return;
|
||||
// A stale-chunk 404 is cured by a full reload that re-fetches index.html and
|
||||
// the new chunk manifest. Auto-reload once, guarding against a reload loop
|
||||
// (e.g. a genuinely missing chunk) with a one-shot sessionStorage flag. If the
|
||||
// flag is already set we fall through to the manual recovery UI below.
|
||||
// the new chunk manifest. Auto-reload at most once per RELOAD_WINDOW_MS: this
|
||||
// recovers across multiple deploys in a single tab's lifetime, yet a
|
||||
// permanently-broken lazy chunk (which would loop) is stopped after the first
|
||||
// reload and falls through to the manual recovery UI below.
|
||||
try {
|
||||
if (sessionStorage.getItem(RELOAD_FLAG)) return;
|
||||
sessionStorage.setItem(RELOAD_FLAG, "1");
|
||||
const raw = sessionStorage.getItem(RELOAD_AT_KEY);
|
||||
const lastReloadAt = raw === null ? null : Number.parseInt(raw, 10);
|
||||
const now = Date.now();
|
||||
if (!shouldAutoReload(now, lastReloadAt, RELOAD_WINDOW_MS)) return;
|
||||
sessionStorage.setItem(RELOAD_AT_KEY, String(now));
|
||||
} catch {
|
||||
// sessionStorage unavailable (private mode / disabled): skip the automatic
|
||||
// reload rather than risk an unguarded loop; the fallback UI still recovers.
|
||||
|
||||
@@ -58,8 +58,11 @@ import ConversationList from "@/features/ai-chat/components/conversation-list.ts
|
||||
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
||||
import {
|
||||
exportAiChat,
|
||||
getAiChatMessagesDelta,
|
||||
stopRun,
|
||||
} from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { mergeDeltaRowsIntoPages } from "@/features/ai-chat/utils/resume-helpers.ts";
|
||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts";
|
||||
import {
|
||||
shouldCollapseOnOutsidePointer,
|
||||
@@ -269,17 +272,64 @@ export default function AiChatWindow() {
|
||||
const { data: messageRows, isLoading: messagesLoading } =
|
||||
useAiChatMessagesQuery(
|
||||
activeChatId ?? undefined,
|
||||
// DELIBERATELY DUMB: poll every 2.5s WHILE ARMED, otherwise off. NO error
|
||||
// checks (TanStack resets fetchFailureCount each fetch; the poll must survive
|
||||
// a server restart), NO tail checks, NO cap here — the settled/stalled/idle-cap
|
||||
// semantics all live in ChatThread's FSM, which disarms via onResumeFallback.
|
||||
() => (degradedPoll === true ? 2500 : false),
|
||||
// #344: gate on windowOpen too — no message history is fetched (and no
|
||||
// degraded poll runs) while the window is closed; it loads when the window
|
||||
// opens with an active chat.
|
||||
// #491: the full infinite-query no longer POLLS. It seeds the thread ONCE; the
|
||||
// degraded fallback now runs a DELTA poller (below) that augments THIS cache
|
||||
// idempotently, instead of refetching every page (with full parts) every 2.5s.
|
||||
false,
|
||||
// #344: gate on windowOpen too — no message history is fetched while the window
|
||||
// is closed; it loads when the window opens with an active chat.
|
||||
windowOpen,
|
||||
);
|
||||
|
||||
// #491 degraded DELTA poll. While armed (degradedPoll) and the window is open on a
|
||||
// chat, poll POST /ai-chat/messages/delta every 2.5s: it returns only the rows
|
||||
// CHANGED since the previous cursor (+ the run fact) in ONE round-trip. We merge
|
||||
// those rows into the SAME infinite-query cache the thread reads (idempotently by
|
||||
// id — the delta's overlap window re-delivers rows), so the thread's reconcile
|
||||
// effect follows the detached run to its terminal row from a fraction of the wire
|
||||
// cost. The run-fact settle stays the thread FSM's job (row-status reconcile), so
|
||||
// we do NOT double-poll /run here. Cursor resets when the chat changes / disarms.
|
||||
const deltaCursorRef = useRef<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
deltaCursorRef.current = undefined;
|
||||
}, [activeChatId, degradedPoll]);
|
||||
useEffect(() => {
|
||||
if (!degradedPoll || !windowOpen || !activeChatId) return;
|
||||
const chatId = activeChatId;
|
||||
let cancelled = false;
|
||||
const tick = async (): Promise<void> => {
|
||||
try {
|
||||
const res = await getAiChatMessagesDelta(chatId, deltaCursorRef.current);
|
||||
if (cancelled) return;
|
||||
deltaCursorRef.current = res.cursor;
|
||||
if (res.rows.length > 0) {
|
||||
queryClient.setQueryData(
|
||||
AI_CHAT_MESSAGES_RQ_KEY(chatId),
|
||||
(
|
||||
old:
|
||||
| {
|
||||
pages: { items: IAiChatMessageRow[]; meta: unknown }[];
|
||||
pageParams: unknown[];
|
||||
}
|
||||
| undefined,
|
||||
) =>
|
||||
old
|
||||
? { ...old, pages: mergeDeltaRowsIntoPages(old.pages, res.rows) }
|
||||
: old,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Transient failure (e.g. a server restart mid-run): swallow and retry on
|
||||
// the next tick — the poll must survive a bounce, like the old dumb refetch.
|
||||
}
|
||||
};
|
||||
const id = setInterval(() => void tick(), 2500);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [degradedPoll, windowOpen, activeChatId, queryClient]);
|
||||
|
||||
// #184 reconnect-and-live-follow. Whether detached agent runs are enabled for
|
||||
// this workspace. When the feature is off no runs are ever created, so the
|
||||
// resume attempt would only ever 204; gating ChatThread's resume on it avoids a
|
||||
|
||||
@@ -172,9 +172,18 @@ function resetState() {
|
||||
h.state.getRun.mockResolvedValue({ run: null, message: null });
|
||||
}
|
||||
|
||||
// #491: the streaming tail carries a persisted step frontier (metadata.stepsPersisted),
|
||||
// which the tail-only attach reads as `n` in `?anchor=<id>&n=<n>`. Seeded WHOLE now.
|
||||
const streamingTail = () => [
|
||||
row("u1", "user", undefined, "hi"),
|
||||
row("a1", "assistant", "streaming", "partial"),
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: "partial",
|
||||
status: "streaming",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
metadata: { stepsPersisted: 2 },
|
||||
} as IAiChatMessageRow,
|
||||
];
|
||||
const settledTail = () => [
|
||||
row("u1", "user", undefined, "hi"),
|
||||
@@ -335,20 +344,24 @@ describe("ChatThread — send now", () => {
|
||||
expect(screen.getAllByLabelText("Remove queued message")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("Stop then a REAL network-drop finish exits to idle (honor-in-stopping), NOT a false reconnect", () => {
|
||||
it("Stop then a REAL network-drop finish exits to idle (honor-in-stopping), NOT a false reconnect", async () => {
|
||||
// Regression for the disconnect-first reorder: on the STOP path, even a drop-
|
||||
// form finish { isError:true, isDisconnect:true } arriving in `stopping` must be
|
||||
// HONORED (reducer) and exit to idle — it must NOT enter the reconnect ladder.
|
||||
startLocalStreamWithRun(); // live local stream, autonomous
|
||||
fireEvent.click(screen.getByLabelText("Stop")); // STOP_REQUESTED -> stopping
|
||||
h.state.error = { message: "Failed to fetch" };
|
||||
act(() => {
|
||||
// #491: the disconnect re-seeds from persist (async getRun) before dispatching
|
||||
// FINISH_DISCONNECT, which the reducer HONORS in `stopping` -> idle. Flush it.
|
||||
await act(async () => {
|
||||
h.state.onFinish?.({
|
||||
message: { id: "a1", role: "assistant", parts: [] },
|
||||
isAbort: false,
|
||||
isDisconnect: true,
|
||||
isError: true,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.queryByText(/reconnecting/i)).toBeNull();
|
||||
});
|
||||
@@ -803,19 +816,24 @@ describe("ChatThread — resume (attach) machinery", () => {
|
||||
expect(h.state.resumeStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("strips the streaming tail from the seed, keeps a user tail whole", () => {
|
||||
it("#491 tail-only: seeds the streaming tail WHOLE (no strip), keeps a user tail whole", () => {
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
||||
expect(h.state.seededMessages).toHaveLength(1);
|
||||
// MUTATION-VERIFY: re-introduce the seed-strip and this goes red — the streaming
|
||||
// tail (steps 0..N-1) MUST be seeded so the SDK continuation appends the tail to
|
||||
// the RIGHT message. Both rows (user + assistant) are seeded.
|
||||
expect(h.state.seededMessages).toHaveLength(2);
|
||||
cleanup();
|
||||
resetState();
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: userTail() });
|
||||
expect(h.state.seededMessages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("builds the attach URL with expect=live&anchor only for a stripped streaming tail", () => {
|
||||
it("#491 tail-only: builds the attach URL with ?anchor=&n= from the persisted step frontier", () => {
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
||||
// n=2 comes from a1's metadata.stepsPersisted (MUTATION-VERIFY: hardcode n=0 and
|
||||
// this fails). No `expect=live` param anymore.
|
||||
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
|
||||
"/api/ai-chat/runs/c1/stream?expect=live&anchor=a1",
|
||||
"/api/ai-chat/runs/c1/stream?anchor=a1&n=2",
|
||||
);
|
||||
cleanup();
|
||||
resetState();
|
||||
@@ -839,39 +857,41 @@ describe("ChatThread — resume (attach) machinery", () => {
|
||||
});
|
||||
}
|
||||
|
||||
it("204 on a streaming tail: restore + invalidate + onResumeFallback(true)", async () => {
|
||||
it("204 on a streaming tail: NO restore (row kept) + invalidate + onResumeFallback(true)", async () => {
|
||||
const { onResumeFallback, invalidateSpy } = renderThread({
|
||||
autonomousRunsEnabled: true,
|
||||
initialRows: streamingTail(),
|
||||
});
|
||||
await attachFetch({ status: 204, ok: false });
|
||||
expect(h.state.setMessages).toHaveBeenCalledTimes(1); // restore
|
||||
// #491 tail-only: the anchor row was never stripped, so there is NOTHING to
|
||||
// restore. MUTATION-VERIFY: re-add a restore setMessages here and it goes red.
|
||||
expect(h.state.setMessages).not.toHaveBeenCalled();
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["ai-chat-messages", "c1"],
|
||||
});
|
||||
expect(onResumeFallback).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("F7 restart-survival: a 500 attach failure restores the row AND arms the poll", async () => {
|
||||
it("F7 restart-survival: a 500 attach failure arms the poll WITHOUT a restore", async () => {
|
||||
const { onResumeFallback, invalidateSpy } = renderThread({
|
||||
autonomousRunsEnabled: true,
|
||||
initialRows: streamingTail(),
|
||||
});
|
||||
await attachFetch({ status: 500, ok: false });
|
||||
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
|
||||
expect(h.state.setMessages).not.toHaveBeenCalled();
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["ai-chat-messages", "c1"],
|
||||
});
|
||||
expect(onResumeFallback).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("F7 restart-survival: a network throw restores the row AND arms the poll", async () => {
|
||||
it("F7 restart-survival: a network throw arms the poll WITHOUT a restore", async () => {
|
||||
const { onResumeFallback, invalidateSpy } = renderThread({
|
||||
autonomousRunsEnabled: true,
|
||||
initialRows: streamingTail(),
|
||||
});
|
||||
await attachFetch(new Error("network down"), true);
|
||||
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
|
||||
expect(h.state.setMessages).not.toHaveBeenCalled();
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["ai-chat-messages", "c1"],
|
||||
});
|
||||
@@ -931,7 +951,7 @@ describe("ChatThread — resume (attach) machinery", () => {
|
||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("an empty resumed message (starved replay) restores the row AND arms the poll", () => {
|
||||
it("an empty resumed message (starved replay) arms the poll WITHOUT a restore", () => {
|
||||
h.state.status = "ready";
|
||||
const { onResumeFallback } = renderThread({
|
||||
autonomousRunsEnabled: true,
|
||||
@@ -947,7 +967,9 @@ describe("ChatThread — resume (attach) machinery", () => {
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
expect(h.state.setMessages).toHaveBeenCalledTimes(1); // restore
|
||||
// #491 tail-only: the seeded steps 0..N-1 are still on screen (the SDK
|
||||
// continuation never wiped them), so there is nothing to restore — just poll.
|
||||
expect(h.state.setMessages).not.toHaveBeenCalled();
|
||||
expect(onResumeFallback).toHaveBeenCalledWith(true); // arm
|
||||
});
|
||||
|
||||
@@ -995,24 +1017,41 @@ describe("ChatThread — live reconnect + stalled", () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// #491: the authoritative PERSISTED assistant row `getRun` projects on a local
|
||||
// disconnect — the re-seed source. Its metadata.stepsPersisted becomes `n`.
|
||||
const persistedAnchor = (steps = 3) => ({
|
||||
run: { id: "run-1", status: "running" },
|
||||
message: {
|
||||
id: "a2",
|
||||
role: "assistant",
|
||||
content: "persisted 0..N-1",
|
||||
status: "streaming",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
metadata: { stepsPersisted: steps },
|
||||
},
|
||||
});
|
||||
|
||||
// A REAL live SSE drop. ai@6.0.207 emits BOTH { isError:true, isDisconnect:true }
|
||||
// for a network TypeError AND sets useChat `error` — NOT the { isError:false,
|
||||
// error:null } form the old tests fed. This is the form browser QA hit; with the
|
||||
// buggy isError-first routing OR without the errorView render-gate these tests go
|
||||
// red (a real drop surfaces the terminal error banner, masking the reconnect
|
||||
// ladder). MUTATION-VERIFY of disconnect-first + the errorView phase-gate.
|
||||
function disconnect(message: unknown = liveMsg) {
|
||||
// for a network TypeError AND sets useChat `error`. #491: an autonomous local drop
|
||||
// now RE-SEEDS from persist (async getRun) BEFORE entering the reconnect ladder, so
|
||||
// this helper is async and flushes the getRun microtask before returning.
|
||||
async function disconnect(message: unknown = liveMsg) {
|
||||
h.state.error = { message: "Failed to fetch" }; // the SDK sets error on the drop
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
h.state.onFinish?.({
|
||||
message,
|
||||
isAbort: false,
|
||||
isDisconnect: true,
|
||||
isError: true,
|
||||
});
|
||||
// Flush the getRun().then re-seed + the deferred FINISH_DISCONNECT dispatch.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
function renderLive() {
|
||||
// The persisted-anchor read the local disconnect performs to re-seed from persist.
|
||||
h.state.getRun.mockResolvedValue(persistedAnchor());
|
||||
const view = renderThread({
|
||||
autonomousRunsEnabled: true,
|
||||
initialRows: settledTail(),
|
||||
@@ -1032,35 +1071,80 @@ describe("ChatThread — live reconnect + stalled", () => {
|
||||
});
|
||||
}
|
||||
|
||||
it("a live disconnect starts a backoff reconnect (banner + resumeStream after backoff)", () => {
|
||||
it("#491: a live disconnect RE-SEEDS from persist, then backs off to reconnect with ?anchor=&n=", async () => {
|
||||
renderLive();
|
||||
disconnect();
|
||||
await disconnect();
|
||||
// The re-seed read the authoritative persisted row and replaced the live partial.
|
||||
// MUTATION-VERIFY: skip the getRun re-seed (send `n` off the live message) and the
|
||||
// n below no longer matches the PERSISTED stepsPersisted.
|
||||
expect(h.state.getRun).toHaveBeenCalledWith("c1");
|
||||
expect(h.state.setMessages).toHaveBeenCalled(); // re-seeded the store from persist
|
||||
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
|
||||
expect(h.state.resumeStream).not.toHaveBeenCalled();
|
||||
advanceToAttempt(1);
|
||||
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
|
||||
// n=3 is the PERSISTED row's stepsPersisted (from getRun), NOT the live store.
|
||||
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
|
||||
"/api/ai-chat/runs/c1/stream?expect=live&anchor=a2",
|
||||
"/api/ai-chat/runs/c1/stream?anchor=a2&n=3",
|
||||
);
|
||||
});
|
||||
|
||||
it("#488 (browser QA): the reconnect banner is SHOWN, not masked by the residual useChat error", () => {
|
||||
it("#491 regression (#137/#161 dup): getRun REJECT on a live disconnect drops the live partial + nulls the anchor", async () => {
|
||||
// The re-seed source (getRun) FAILS — a flaky-network blip (SSE + getRun both
|
||||
// fail, network recovers in ~1s). The OLD .catch just re-entered the ladder with
|
||||
// NO re-seed and NO filter, so the reconnect could tail-apply the registry's
|
||||
// frames onto the live partial that ALREADY has those steps -> duplicated text.
|
||||
renderLive();
|
||||
h.state.getRun.mockReset();
|
||||
h.state.getRun.mockRejectedValue(new Error("network"));
|
||||
await disconnect(); // live partial = liveMsg (id "a2")
|
||||
expect(h.state.getRun).toHaveBeenCalledWith("c1");
|
||||
// THE GUARANTEE: on the getRun failure the live partial (a2) is FILTERED from the
|
||||
// store, so the reconnect can never tail-apply already-present steps onto it.
|
||||
// MUTATION-VERIFY: revert the .catch fix (enterReconnect only, no filter) and no
|
||||
// setMessages call removes a2 -> this reddens.
|
||||
const removedLivePartial = (
|
||||
h.state.setMessages as unknown as {
|
||||
mock: { calls: [unknown][] };
|
||||
}
|
||||
).mock.calls.some(([updater]) => {
|
||||
if (typeof updater !== "function") return false;
|
||||
const out = (updater as (p: { id: string }[]) => { id: string }[])([
|
||||
{ id: "a2" },
|
||||
{ id: "u1" },
|
||||
]);
|
||||
return !out.some((m) => m.id === "a2");
|
||||
});
|
||||
expect(removedLivePartial).toBe(true);
|
||||
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
|
||||
advanceToAttempt(1);
|
||||
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
|
||||
// Anchor was nulled -> replay-from-start (no params) / 204 -> poll; never a stale
|
||||
// ?anchor=&n= over the live partial.
|
||||
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
|
||||
"/api/ai-chat/runs/c1/stream",
|
||||
);
|
||||
});
|
||||
|
||||
it("#488 (browser QA): the reconnect banner is SHOWN, not masked by the residual useChat error", async () => {
|
||||
// The drop sets useChat `error` (real SDK), and the terminal errorView describes
|
||||
// it ("Lost connection to the server"). The FSM phase-gate must let the
|
||||
// `reconnecting` banner WIN over that residual error. MUTATION-VERIFY: revert the
|
||||
// errorView phase-gate (show errorView whenever error is set) and the terminal
|
||||
// banner masks "reconnecting…" -> red.
|
||||
renderLive();
|
||||
disconnect();
|
||||
await disconnect();
|
||||
expect(h.state.error).not.toBeNull(); // the SDK error IS set during recovery
|
||||
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
|
||||
// The terminal "Lost connection… reload" banner must NOT be showing.
|
||||
expect(screen.queryByText(/reload and try again/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("#488 commit 2: a disconnect BEFORE the first assistant frame reconnects with NO anchor", () => {
|
||||
it("#488 commit 2: a disconnect BEFORE the first assistant frame reconnects with NO anchor", async () => {
|
||||
renderLive();
|
||||
disconnect(null); // no assistant message yet (pre-first-frame break)
|
||||
// No persisted assistant row for a pre-first-frame break -> no anchor.
|
||||
h.state.getRun.mockResolvedValue({ run: null, message: null });
|
||||
await disconnect(null); // no assistant message yet (pre-first-frame break)
|
||||
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByText("Connection lost — the answer was interrupted."),
|
||||
@@ -1074,7 +1158,7 @@ describe("ChatThread — live reconnect + stalled", () => {
|
||||
|
||||
it("a live re-attach (2xx) clears the reconnect banner", async () => {
|
||||
renderLive();
|
||||
disconnect();
|
||||
await disconnect();
|
||||
advanceToAttempt(1);
|
||||
await reconnect({ status: 200, ok: true });
|
||||
expect(screen.queryByText(/reconnecting/i)).toBeNull();
|
||||
@@ -1082,7 +1166,7 @@ describe("ChatThread — live reconnect + stalled", () => {
|
||||
|
||||
it("a 204 arms the degraded poll and backs off to the next attempt", async () => {
|
||||
const { onResumeFallback } = renderLive();
|
||||
disconnect();
|
||||
await disconnect();
|
||||
advanceToAttempt(1);
|
||||
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
|
||||
await reconnect({ status: 204, ok: false });
|
||||
@@ -1094,7 +1178,7 @@ describe("ChatThread — live reconnect + stalled", () => {
|
||||
|
||||
it("exhausts the attempt limit into a manual Retry, which restarts the sequence", async () => {
|
||||
renderLive();
|
||||
disconnect();
|
||||
await disconnect();
|
||||
for (let n = 1; n <= 5; n++) {
|
||||
advanceToAttempt(n);
|
||||
expect(h.state.resumeStream).toHaveBeenCalledTimes(n);
|
||||
@@ -1112,22 +1196,23 @@ describe("ChatThread — live reconnect + stalled", () => {
|
||||
it("#488 commit 3: two breaks in a row produce two reconnect cycles", async () => {
|
||||
renderLive();
|
||||
// First break -> reconnect -> re-attach live.
|
||||
disconnect();
|
||||
await disconnect();
|
||||
advanceToAttempt(1);
|
||||
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
|
||||
await reconnect({ status: 200, ok: true });
|
||||
expect(screen.queryByText(/reconnecting/i)).toBeNull();
|
||||
// The re-attached observer stream drops AGAIN -> a SECOND reconnect cycle
|
||||
// (the old one-shot !wasResumed gate sent this to silent poll).
|
||||
disconnect();
|
||||
// The re-attached observer (live-follow) stream drops AGAIN -> a SECOND reconnect
|
||||
// cycle. #491: this too re-seeds from persist before re-attaching (never tail-
|
||||
// applies over the live-follow partial).
|
||||
await disconnect();
|
||||
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
|
||||
advanceToAttempt(1);
|
||||
expect(h.state.resumeStream).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does NOT reconnect when autonomous runs are disabled", () => {
|
||||
it("does NOT reconnect when autonomous runs are disabled", async () => {
|
||||
renderThread({ autonomousRunsEnabled: false, initialRows: settledTail() });
|
||||
disconnect();
|
||||
await disconnect();
|
||||
expect(screen.queryByText(/reconnecting/i)).toBeNull();
|
||||
expect(
|
||||
screen.getByText("Connection lost — the answer was interrupted."),
|
||||
@@ -1138,7 +1223,7 @@ describe("ChatThread — live reconnect + stalled", () => {
|
||||
|
||||
it("#488 commit 4a: the poll idle cap surfaces a stalled banner + Retry (not silent)", async () => {
|
||||
renderLive();
|
||||
disconnect();
|
||||
await disconnect();
|
||||
advanceToAttempt(1);
|
||||
await reconnect({ status: 204, ok: false }); // arms the poll (reconnecting)
|
||||
// No activity for the whole idle cap -> stalled.
|
||||
|
||||
@@ -42,7 +42,7 @@ import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/mess
|
||||
import {
|
||||
isStreamingTail,
|
||||
isSettledAssistantTail,
|
||||
seedRows,
|
||||
stepsPersistedOf,
|
||||
mergeById,
|
||||
} from "@/features/ai-chat/utils/resume-helpers.ts";
|
||||
import { getRun } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
@@ -266,25 +266,33 @@ export default function ChatThread({
|
||||
// is NOT one of the lifecycle flags the FSM replaced.
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
// attachStrategy DATA (behind the resumeStream effect; #491 swaps it to tail-only
|
||||
// WITHOUT touching the FSM). The controller is effect-owned (aborted in cleanup,
|
||||
// I5). `stripRef`/`strippedRowRef` are the current full-replay+strip anchor.
|
||||
// attachStrategy DATA (behind the resumeStream effect; #491 tail-only, WITHOUT
|
||||
// touching the FSM). The controller is effect-owned (aborted in cleanup, I5).
|
||||
// `anchorRef` is the PERSISTED assistant row that pins the run (server invariant
|
||||
// 6) and its persisted step frontier N: it feeds `?anchor=<id>&n=<stepsPersisted>`
|
||||
// so the tail-only attach returns frames for steps >= N (the seed carries 0..N-1).
|
||||
// It is NOT a "stripped" row — the seed keeps every row (tail-only replaces the
|
||||
// old full-replay+strip). Null when there is no streaming/active tail to resume.
|
||||
const attachAbortRef = useRef<AbortController | null>(null);
|
||||
const stripRef = useRef(chatId !== null && isStreamingTail(initialRows ?? []));
|
||||
const strippedRowRef = useRef<IAiChatMessageRow | null>(
|
||||
stripRef.current ? (initialRows ?? [])[initialRows!.length - 1] : null,
|
||||
const anchorRef = useRef<{ id: string; stepsPersisted: number } | null>(
|
||||
(() => {
|
||||
if (chatId === null || !isStreamingTail(initialRows ?? [])) return null;
|
||||
const rows = initialRows ?? [];
|
||||
const tail = rows[rows.length - 1];
|
||||
return { id: tail.id, stepsPersisted: stepsPersistedOf(tail) };
|
||||
})(),
|
||||
);
|
||||
// Effect-owned backoff timers (not lifecycle flags): the reconnect ladder and the
|
||||
// stalled inactivity cap. Cleared by the cancelReconnect effect / the cap effect.
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const idleCapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// #491 tail-only: seed EVERY persisted row unchanged (no strip). The streaming
|
||||
// tail holds steps 0..N-1; the run-stream registry's tail (steps >= N) is APPENDED
|
||||
// to it by the SDK continuation (readUIMessageStream({ message })), so it must be
|
||||
// present in the store for the attach to continue the RIGHT message.
|
||||
const initialMessages = useMemo<UIMessage[]>(
|
||||
() =>
|
||||
seedRows(
|
||||
initialRows ?? [],
|
||||
stripRef.current && autonomousRunsEnabled === true,
|
||||
).map(rowToUiMessage),
|
||||
() => (initialRows ?? []).map(rowToUiMessage),
|
||||
[initialRows],
|
||||
);
|
||||
|
||||
@@ -335,21 +343,16 @@ export default function ChatThread({
|
||||
(eff: RunEffect, epoch: number) => {
|
||||
switch (eff.type) {
|
||||
case "resumeStream": {
|
||||
// The attach GET. Stamp the outcome's generation (I1). A reconnect
|
||||
// attempt filters the pinned live row from the store first (the mount
|
||||
// seed already stripped it), so the live replay's text-start rebuilds it
|
||||
// without duplicating parts (#430).
|
||||
// The attach GET. Stamp the outcome's generation (I1). #491 tail-only: the
|
||||
// store already holds EXACTLY the persisted steps 0..N-1 (the mount seed IS
|
||||
// persist; a reconnect was re-seeded from persist BEFORE FINISH_DISCONNECT
|
||||
// scheduled it — see the onFinish disconnect handler), so there is nothing
|
||||
// to filter here: the SDK continues that seeded message, appending the tail
|
||||
// (steps >= N) without duplicating the pre-drop partial step.
|
||||
pendingAttachEpochRef.current = epoch;
|
||||
// The resumed stream's onFinish is stamped with THIS attach generation
|
||||
// (F1), so a superseded attempt's late finish is dropped.
|
||||
turnEpochRef.current = epoch;
|
||||
if (machineRef.current.phase.name === "reconnecting") {
|
||||
const anchor = strippedRowRef.current;
|
||||
if (anchor)
|
||||
setMessagesRef.current?.((prev) =>
|
||||
prev.filter((m) => m.id !== anchor.id),
|
||||
);
|
||||
}
|
||||
void resumeStreamRef.current?.();
|
||||
break;
|
||||
}
|
||||
@@ -464,18 +467,23 @@ export default function ChatThread({
|
||||
new DefaultChatTransport<UIMessage>({
|
||||
api: "/api/ai-chat/stream",
|
||||
credentials: "include",
|
||||
prepareReconnectToStreamRequest: () => ({
|
||||
// Build the attach URL from the REAL chat id. ?expect=live&anchor=<row id>
|
||||
// only when a streaming tail was stripped: expect=live opts into a
|
||||
// finished-retained replay (safe only because the row is stripped and the
|
||||
// replay rebuilds it), and the anchor pins the replay to OUR run — a
|
||||
// mismatching (newer) run 204s into the restore+poll path instead.
|
||||
api: `/api/ai-chat/runs/${chatIdRef.current}/stream${
|
||||
stripRef.current
|
||||
? `?expect=live&anchor=${strippedRowRef.current!.id}`
|
||||
: ""
|
||||
}`,
|
||||
}),
|
||||
prepareReconnectToStreamRequest: () => {
|
||||
// #491 tail-only attach URL. When there is an anchor (a streaming/active
|
||||
// tail to resume) build `?anchor=<assistantRowId>&n=<stepsPersisted>`: the
|
||||
// server returns the TAIL — a synthetic `start` frame + frames for steps
|
||||
// >= n, then live — which the SDK continuation appends to the seeded row.
|
||||
// The server 204s (-> restore-noop + poll) when it cannot cover the
|
||||
// frontier (overflow/rotation gap) or the anchor mismatches (a newer run).
|
||||
// No anchor (a user tail / pre-first-frame break) => no params.
|
||||
const anchor = anchorRef.current;
|
||||
return {
|
||||
api: `/api/ai-chat/runs/${chatIdRef.current}/stream${
|
||||
anchor
|
||||
? `?anchor=${anchor.id}&n=${anchor.stepsPersisted}`
|
||||
: ""
|
||||
}`,
|
||||
};
|
||||
},
|
||||
fetch: async (input: RequestInfo | URL, init: RequestInit = {}) => {
|
||||
if ((init.method ?? "GET") !== "GET") {
|
||||
// Send path (POST). #488 commit 5: NO client 409 retry ladder anymore
|
||||
@@ -562,8 +570,9 @@ export default function ChatThread({
|
||||
|
||||
// Attach GET outcome -> FSM event. The epoch guard replaces BOTH the one-shot
|
||||
// 204 guard (noStreamHandledRef) and the unmount gate: a stale/superseded or
|
||||
// post-DISPOSE outcome is dropped (I1). For a NONE outcome the attachStrategy
|
||||
// recovery (restore the stripped row + invalidate for a fresh poll) runs first.
|
||||
// post-DISPOSE outcome is dropped (I1). #491 tail-only: on a NONE outcome there is
|
||||
// NOTHING to restore — the anchor row was never stripped from the view (the seed
|
||||
// keeps it) — so we only invalidate for a fresh poll + dispatch the FSM event.
|
||||
const handleAttachOutcome = useCallback(
|
||||
(ep: number, wasReconnecting: boolean, live: boolean) => {
|
||||
if (ep !== epochRef.current) return; // stale generation — drop
|
||||
@@ -575,10 +584,6 @@ export default function ChatThread({
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (strippedRowRef.current)
|
||||
setMessagesRef.current?.((prev) =>
|
||||
mergeById(prev, rowToUiMessage(strippedRowRef.current!)),
|
||||
);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current),
|
||||
});
|
||||
@@ -661,56 +666,31 @@ export default function ChatThread({
|
||||
// keeps executing server-side — must win; only a NON-disconnect error (a
|
||||
// provider 500, `{ isError:true, isDisconnect:false }`) is terminal.
|
||||
if (isDisconnect) {
|
||||
if (wasObserver) {
|
||||
// A resumed/attached OBSERVER stream dropped. Recover via the degraded
|
||||
// poll (restore the stripped row only when there is no visible content;
|
||||
// never clobber a fuller on-screen tail, invariant 9). The FSM decides
|
||||
// reconnect-vs-poll from liveFollow (a live-follow drop reconnects again,
|
||||
// #488 commit 3; a mount-resume drop polls).
|
||||
if (mountedRef.current) {
|
||||
const hasVisible = msgHasVisible;
|
||||
if (!hasVisible && strippedRowRef.current)
|
||||
setMessages((prev) =>
|
||||
mergeById(prev, rowToUiMessage(strippedRowRef.current!)),
|
||||
);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current),
|
||||
});
|
||||
dispatch({
|
||||
type: "FINISH_DISCONNECT",
|
||||
hasVisibleContent: hasVisible,
|
||||
epoch: stampEpoch,
|
||||
});
|
||||
}
|
||||
if (!mountedRef.current) {
|
||||
setStopNotice(null);
|
||||
return;
|
||||
}
|
||||
// A LOCAL live turn dropped. #488 commit 2: recover by the RUN-FACT, not by
|
||||
// the presence of an assistant message — a setup-phase break (before the
|
||||
// first frame) still leaves a detached run writing to pages. In autonomous
|
||||
// mode a run is active for the whole turn, so seed the run-fact from the
|
||||
// start-metadata runId when known, else a sentinel (the attach GET goes by
|
||||
// chatId, not runId). Pin the assistant row as the strip/anchor when present.
|
||||
if (autonomousRunsEnabled === true && mountedRef.current) {
|
||||
const hasAnchor =
|
||||
message?.role === "assistant" && typeof message.id === "string";
|
||||
if (hasAnchor) {
|
||||
strippedRowRef.current = {
|
||||
id: message.id,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
status: "streaming",
|
||||
createdAt: new Date().toISOString(),
|
||||
metadata: { parts: message.parts },
|
||||
};
|
||||
stripRef.current = true;
|
||||
} else {
|
||||
strippedRowRef.current = null;
|
||||
stripRef.current = false;
|
||||
}
|
||||
// No detached run to recover (legacy, non-autonomous): a plain disconnect —
|
||||
// terminal notice, no reconnect. (An observer only exists in autonomous mode,
|
||||
// so this is always a local turn.)
|
||||
if (autonomousRunsEnabled !== true) {
|
||||
dispatch({
|
||||
type: "RUN_FACT",
|
||||
runFact: { runId: extractRunId(message) ?? "pending" },
|
||||
type: "FINISH_DISCONNECT",
|
||||
hasVisibleContent: false,
|
||||
epoch: stampEpoch,
|
||||
});
|
||||
setStopNotice("disconnect");
|
||||
return;
|
||||
}
|
||||
// A mount-resume OBSERVER (one-shot resume, NOT live-follow) drop falls to
|
||||
// the degraded POLL, which merges by id — it does NOT attach, so there is
|
||||
// nothing to re-seed. #491 tail-only: the anchor row was never removed from
|
||||
// the view (the seed keeps it; the continuation only APPENDED), so nothing to
|
||||
// restore either. The FSM routes this to `polling` (ownership observer,
|
||||
// !liveFollow).
|
||||
if (wasObserver && !machineRef.current.ctx.liveFollow) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current),
|
||||
});
|
||||
dispatch({
|
||||
type: "FINISH_DISCONNECT",
|
||||
@@ -718,14 +698,92 @@ export default function ChatThread({
|
||||
epoch: stampEpoch,
|
||||
});
|
||||
setStopNotice(null);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
// We will (re-)ENTER THE RECONNECT LADDER (an attach): a LOCAL live turn's
|
||||
// first drop, OR a live-follow observer's SUBSEQUENT drop (#488 commit 3).
|
||||
// #488 commit 2: recover by the RUN-FACT, not by the presence of an assistant
|
||||
// message — a setup-phase break still leaves a detached run writing to pages.
|
||||
//
|
||||
// #491 tail-only (THE crux): the live store holds a PARTIAL step that is AHEAD
|
||||
// of the persisted boundary; tail-applying the reconnect's step frames over it
|
||||
// would DUPLICATE that partial step. So entering reconnecting is ALWAYS via a
|
||||
// RE-SEED FROM PERSIST — never the live store. Fetch the authoritative
|
||||
// persisted assistant row (`getRun` returns the projected `message`), replace
|
||||
// the live partial by id (mergeById -> the store now holds EXACTLY steps
|
||||
// 0..N-1), and set the anchor to `{ id, n = stepsPersisted }`. Only AFTER the
|
||||
// re-seed is applied do we enter the ladder (FINISH_DISCONNECT schedules the
|
||||
// backoff) — so the attach can never tail-apply over the live partial.
|
||||
const cid = chatIdRef.current;
|
||||
// The live-message runId is the run-fact source (the attach GET keys on
|
||||
// chatId, so a sentinel still recovers a setup-phase break).
|
||||
const runId = extractRunId(message ?? undefined) ?? "pending";
|
||||
const enterReconnect = (fact: string): void => {
|
||||
if (!mountedRef.current) return;
|
||||
// Epoch-stamp the run-fact too (I1): the getRun rtt widens the
|
||||
// onFinish->dispatch window, so a concurrent SEND_LOCAL during it must be
|
||||
// able to drop this stale RUN_FACT (else it clobbers the new turn's
|
||||
// runFact.runId). Consistent with the postRun RUN_FACT stamp.
|
||||
dispatch({ type: "RUN_FACT", runFact: { runId: fact }, epoch: stampEpoch });
|
||||
dispatch({
|
||||
type: "FINISH_DISCONNECT",
|
||||
hasVisibleContent: false,
|
||||
hasVisibleContent: msgHasVisible,
|
||||
epoch: stampEpoch,
|
||||
});
|
||||
setStopNotice("disconnect");
|
||||
};
|
||||
// Restore the STRUCTURAL guarantee that the live partial is never the
|
||||
// tail-apply base: drop the live partial from the store by id and null the
|
||||
// anchor, so the reconnect replays from step 0 into a CLEAN store (a full
|
||||
// rebuild) or, past any rotation, 204s -> degraded poll. Used on BOTH the
|
||||
// no-persisted-row and getRun-FAILURE paths — after this there is no path
|
||||
// where the attach tail-applies frames onto a row that already has them
|
||||
// (the #137/#161 duplication class).
|
||||
const dropLivePartialAndReplayFromStart = (): void => {
|
||||
if (message?.role === "assistant" && typeof message.id === "string") {
|
||||
const liveId = message.id;
|
||||
setMessagesRef.current?.((prev) =>
|
||||
prev.filter((m) => m.id !== liveId),
|
||||
);
|
||||
}
|
||||
anchorRef.current = null;
|
||||
};
|
||||
if (cid) {
|
||||
void getRun(cid)
|
||||
.then((res) => {
|
||||
if (!mountedRef.current) return;
|
||||
const persisted = res.message;
|
||||
if (persisted && persisted.role === "assistant") {
|
||||
anchorRef.current = {
|
||||
id: persisted.id,
|
||||
stepsPersisted: stepsPersistedOf(persisted),
|
||||
};
|
||||
// Replace the live partial with the persisted row IN PLACE by id —
|
||||
// the re-seed from persist. The attach's tail (steps >= N) then
|
||||
// appends to a store holding EXACTLY steps 0..N-1: no duplication.
|
||||
setMessages((prev) => mergeById(prev, rowToUiMessage(persisted)));
|
||||
} else {
|
||||
// No persisted assistant row (pre-first-frame break): drop the live
|
||||
// partial + replay from start (no anchor/n) so nothing is duplicated.
|
||||
dropLivePartialAndReplayFromStart();
|
||||
}
|
||||
enterReconnect(res.run?.id ?? runId);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!mountedRef.current) return;
|
||||
// Persist read FAILED: we cannot re-seed from fresh persist, and a
|
||||
// stale mount-time anchor over the live partial would tail-apply
|
||||
// already-present steps -> duplication (a flaky-network blip:
|
||||
// SSE + getRun both fail, network recovers in ~1s, the registry still
|
||||
// covers from the mount frontier). Restore the removed-filter guarantee
|
||||
// instead: drop the live partial + replay from start / 204 -> poll.
|
||||
dropLivePartialAndReplayFromStart();
|
||||
enterReconnect(runId);
|
||||
});
|
||||
} else {
|
||||
dropLivePartialAndReplayFromStart();
|
||||
enterReconnect(runId);
|
||||
}
|
||||
setStopNotice(null);
|
||||
return;
|
||||
}
|
||||
// A NON-disconnect stream error (a provider 500 etc.) -> terminal error banner.
|
||||
@@ -746,11 +804,10 @@ export default function ChatThread({
|
||||
if (mountedRef.current) {
|
||||
const hasVisible = msgHasVisible;
|
||||
if (!hasVisible) {
|
||||
// Starved replay: restore the stripped row + poll to the real terminal.
|
||||
if (strippedRowRef.current)
|
||||
setMessages((prev) =>
|
||||
mergeById(prev, rowToUiMessage(strippedRowRef.current!)),
|
||||
);
|
||||
// Starved replay (the tail carried no new steps). #491 tail-only: the
|
||||
// seeded steps 0..N-1 are still on screen (the SDK continuation never
|
||||
// wiped them — `start` does not reset parts), so there is nothing to
|
||||
// restore; just poll to the real terminal.
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current),
|
||||
});
|
||||
@@ -863,12 +920,12 @@ export default function ChatThread({
|
||||
const tail = rows[rows.length - 1];
|
||||
if (!tail || tail.role !== "assistant") return;
|
||||
setMessages((prev) => mergeById(prev, rowToUiMessage(tail)));
|
||||
// Anchor-mismatch coherence: a restored stripped row A that a DIFFERENT run's
|
||||
// row B has replaced as the tail would linger as an orphan — settle A from
|
||||
// fresh history so no phantom row survives.
|
||||
const stripped = strippedRowRef.current;
|
||||
if (stripped && stripped.id !== tail.id) {
|
||||
const historical = rows.find((r) => r.id === stripped.id);
|
||||
// Anchor-mismatch coherence: if a DIFFERENT run's row B has replaced our anchor
|
||||
// row A as the tail, A would linger as an orphan — reconcile A by id from FRESH
|
||||
// PERSISTED history (not the pinned live row) so no phantom row survives.
|
||||
const anchor = anchorRef.current;
|
||||
if (anchor && anchor.id !== tail.id) {
|
||||
const historical = rows.find((r) => r.id === anchor.id);
|
||||
if (historical)
|
||||
setMessages((prev) => mergeById(prev, rowToUiMessage(historical)));
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ 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.
|
||||
|
||||
@@ -114,3 +115,89 @@ 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.
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
// Stub react-i18next (the component reads `useTranslation`). Mirrors the other
|
||||
// message-item specs.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import MessageItem from "./message-item";
|
||||
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||
// The REAL canonical renderer (NOT the spy the memo test installs): this file
|
||||
// exercises the actual markdown output so the visual-regression assertions below
|
||||
// compare against genuine HTML (incl. the schema's `<li><p>` wrappers).
|
||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||
import classes from "./ai-chat.module.css";
|
||||
|
||||
const msg = (
|
||||
parts: UIMessage["parts"],
|
||||
extra?: Partial<UIMessage>,
|
||||
): UIMessage =>
|
||||
({ id: "m1", role: "assistant", parts, ...extra }) as UIMessage;
|
||||
|
||||
const renderRow = (message: UIMessage, turnStreaming = false) =>
|
||||
render(
|
||||
<MantineProvider>
|
||||
<MessageItem
|
||||
message={message}
|
||||
signature={messageSignature(message)}
|
||||
turnStreaming={turnStreaming}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
// A rich multi-block answer that exercises headings, a list (the `<li><p>` case
|
||||
// the scoped CSS tightens), inline emphasis, and multiple paragraphs.
|
||||
const ANSWER = [
|
||||
"# Заголовок",
|
||||
"",
|
||||
"Первый абзац с **жирным** и `кодом`.",
|
||||
"",
|
||||
"- пункт один",
|
||||
"- пункт два",
|
||||
"",
|
||||
"Второй абзац.",
|
||||
].join("\n");
|
||||
|
||||
describe("MessageItem final render — visual parity with the canonical pipeline", () => {
|
||||
it("a finalized text part renders exactly renderChatMarkdown(text)", () => {
|
||||
const { container } = renderRow(
|
||||
msg([{ type: "text", text: ANSWER, state: "done" }]),
|
||||
);
|
||||
const block = container.querySelector(`.${classes.markdown}`);
|
||||
expect(block).not.toBeNull();
|
||||
// Byte-for-byte the canonical output (the SAME whole-text pass the pre-#492
|
||||
// MarkdownPart produced), including `<li><p>…</p></li>` wrappers.
|
||||
expect(block!.innerHTML).toBe(renderChatMarkdown(ANSWER, {}));
|
||||
// The list wrapper is really present (guards against a vacuous empty render).
|
||||
expect(container.querySelectorAll("li p").length).toBe(2);
|
||||
});
|
||||
|
||||
it("the streaming incremental view CONVERGES to the canonical render on finish", () => {
|
||||
// Mount mid-stream (live tail) — the DOM here is the incremental view.
|
||||
const { container, rerender } = render(
|
||||
<MantineProvider>
|
||||
<MessageItem
|
||||
message={msg([{ type: "text", text: ANSWER, state: "streaming" }])}
|
||||
signature={messageSignature(
|
||||
msg([{ type: "text", text: ANSWER, state: "streaming" }]),
|
||||
)}
|
||||
turnStreaming
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
// Finish the turn: state flips to done AND the turn is no longer streaming.
|
||||
const done = msg([{ type: "text", text: ANSWER, state: "done" }]);
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<MessageItem message={done} signature={messageSignature(done)} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
// After finish there is exactly ONE canonical markdown container whose HTML is
|
||||
// the whole-text render — identical to the non-streaming path above.
|
||||
const blocks = container.querySelectorAll(`.${classes.markdown}`);
|
||||
expect(blocks.length).toBe(1);
|
||||
expect(blocks[0].innerHTML).toBe(renderChatMarkdown(ANSWER, {}));
|
||||
});
|
||||
|
||||
it("neutralizeInternalLinks is honored on the finalized render", () => {
|
||||
const linkAnswer = "См. [страницу](/p/abc).";
|
||||
const { container } = render(
|
||||
<MantineProvider>
|
||||
<MessageItem
|
||||
message={msg([{ type: "text", text: linkAnswer, state: "done" }])}
|
||||
signature={messageSignature(
|
||||
msg([{ type: "text", text: linkAnswer, state: "done" }]),
|
||||
)}
|
||||
neutralizeInternalLinks
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
const block = container.querySelector(`.${classes.markdown}`);
|
||||
expect(block!.innerHTML).toBe(
|
||||
renderChatMarkdown(linkAnswer, { neutralizeInternalLinks: true }),
|
||||
);
|
||||
// The internal link was made inert (no href) by the neutralization flag.
|
||||
const a = container.querySelector("a");
|
||||
expect(a?.hasAttribute("href")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import 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";
|
||||
@@ -86,17 +87,39 @@ 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 marked + DOMPurify pass. Props are
|
||||
* primitives, so React.memo's default shallow compare is exactly right (the
|
||||
* `text` string is compared by value).
|
||||
* 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.
|
||||
*/
|
||||
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 (
|
||||
@@ -179,47 +202,10 @@ function MessageItem({
|
||||
{resolveAssistantName(assistantName) ?? t("AI agent")}
|
||||
</Text>
|
||||
{message.parts.map((part, index) => {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (isToolPart(part.type)) {
|
||||
return (
|
||||
<ToolCallCard
|
||||
@@ -232,7 +218,76 @@ function MessageItem({
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
switch (part.type) {
|
||||
case "reasoning": {
|
||||
// Reasoning ("thinking") -> a collapsible block with its own token
|
||||
// count. Empty/whitespace reasoning with no authoritative count
|
||||
// carries nothing to show, so skip it (avoids an empty 0-token block).
|
||||
const text = part.text ?? "";
|
||||
if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
|
||||
return null;
|
||||
// Absent state (persisted rows) and "done" both mean finalized.
|
||||
// `messageSignature` already includes each part's `state`, so the
|
||||
// streaming→done flip changes the row signature and re-renders this
|
||||
// row — which is what lets ReasoningBlock switch from chunked plain
|
||||
// text to its one-time markdown parse (see reasoning-block.tsx).
|
||||
// ALSO require the turn to be live: a part stranded at
|
||||
// `state:"streaming"` after the turn ended (no `reasoning-end` — see
|
||||
// the `turnStreaming` prop doc) must still finalize and parse.
|
||||
const streaming = turnStreaming && part.state === "streaming";
|
||||
return (
|
||||
<ReasoningBlock
|
||||
key={index}
|
||||
text={text}
|
||||
tokens={reasoningTokens}
|
||||
streaming={streaming}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case "text": {
|
||||
// Skip empty/whitespace-only text parts (a streaming message often
|
||||
// starts with an empty text part before the first token arrives); the
|
||||
// typing indicator covers that gap until real content streams in.
|
||||
if (!part.text.trim()) return null;
|
||||
// The live, actively-growing tail part of the in-flight turn renders
|
||||
// incrementally (see MarkdownPart); a finalized part (persisted, or
|
||||
// the turn-end flip) renders the whole text through one canonical
|
||||
// pass. Same liveness rule as the reasoning branch above.
|
||||
const streaming = turnStreaming && part.state === "streaming";
|
||||
return (
|
||||
<MarkdownPart
|
||||
key={index}
|
||||
text={part.text}
|
||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||
streaming={streaming}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case "source-url":
|
||||
case "source-document":
|
||||
case "file":
|
||||
case "step-start":
|
||||
// Not surfaced in the chat bubble (v1) — same as the pre-#492 default.
|
||||
return null;
|
||||
|
||||
default: {
|
||||
// Compile-time exhaustiveness over the CLOSED union members: every
|
||||
// literal-typed part kind is handled above, so the only kinds that
|
||||
// can reach here are the OPEN template-literal ones (`tool-*` — caught
|
||||
// by the guard at runtime — and `data-*`) plus `dynamic-tool`. Adding
|
||||
// a NEW closed part kind to UIMessagePart makes this assignment fail
|
||||
// to compile, forcing it to be handled instead of silently ignored
|
||||
// (this replaces the pre-#492 fall-through `return null` + WARNING).
|
||||
const _exhaustive:
|
||||
| `tool-${string}`
|
||||
| "dynamic-tool"
|
||||
| `data-${string}` = part.type;
|
||||
void _exhaustive;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
})}
|
||||
{/* A persisted turn error (server stored it in metadata.error). Rendered
|
||||
here so it survives a thread remount and shows in reopened history. */}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { memo, useMemo } from "react";
|
||||
import { splitPlainChunks } from "@/features/ai-chat/components/streaming-plain-text.tsx";
|
||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
/**
|
||||
* One STABILIZED markdown block, rendered through the canonical pipeline and
|
||||
* memoized on its string prop. During streaming only the TAIL chunk grows (the
|
||||
* `splitPlainChunks` append-only invariant guarantees every earlier chunk is
|
||||
* byte-identical across deltas), so React skips every stable block and each one
|
||||
* is parsed by `renderChatMarkdown` EXACTLY ONCE — turning the pre-#492
|
||||
* "re-parse the whole accumulated answer on every ~20Hz tick" (O(ticks)) into
|
||||
* O(number of blocks). The markup is DOMPurify-sanitized inside renderChatMarkdown
|
||||
* before it reaches `dangerouslySetInnerHTML`.
|
||||
*
|
||||
* NOTE (transient streaming-only artifact): a safe cut is a blank-line boundary,
|
||||
* so a construct that legitimately contains a blank line (e.g. a fenced code block
|
||||
* with an empty line) can be split across chunks and render oddly WHILE it is still
|
||||
* streaming. This is cosmetic and self-heals: the moment the part finalizes,
|
||||
* MarkdownPart renders the WHOLE text through one canonical pass (visual parity
|
||||
* with the pre-#492 output). The reasoning path makes the same trade (plain text
|
||||
* while streaming, one markdown parse at the end).
|
||||
*/
|
||||
const MarkdownChunk = memo(function MarkdownChunk({
|
||||
text,
|
||||
neutralizeInternalLinks,
|
||||
}: {
|
||||
text: string;
|
||||
neutralizeInternalLinks: boolean;
|
||||
}) {
|
||||
const html = renderChatMarkdown(text, { neutralizeInternalLinks });
|
||||
if (html) {
|
||||
return (
|
||||
<div
|
||||
className={classes.markdown}
|
||||
// Sanitized by renderChatMarkdown (DOMPurify) before insertion.
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Malformed/unsupported markdown could not render synchronously: raw text.
|
||||
return (
|
||||
<div className={classes.markdown} style={{ whiteSpace: "pre-wrap" }}>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The cheap streaming-time stand-in for the finalized answer's one-time markdown
|
||||
* parse (see MarkdownPart in message-item.tsx). Mirrors StreamingPlainText's
|
||||
* chunked-memo pattern but renders the STABILIZED prefix as real markdown (each
|
||||
* block parsed once, memoized) and only the LIVE tail as flat plain text — so the
|
||||
* user sees formatted output for everything up to the last safe cut, and the not-
|
||||
* yet-stable tail (which markdown-parsing every tick would make O(ticks)) stays a
|
||||
* single cheap escaped text node until it stabilizes into a new block.
|
||||
*
|
||||
* `splitPlainChunks` yields chunks where, under append-only growth, every chunk
|
||||
* except the LAST is immutable; the last chunk is the live tail. Index keys are
|
||||
* therefore stable (a given index never changes to a different chunk's content).
|
||||
*/
|
||||
export function StreamingMarkdownText({
|
||||
text,
|
||||
neutralizeInternalLinks,
|
||||
}: {
|
||||
text: string;
|
||||
neutralizeInternalLinks: boolean;
|
||||
}) {
|
||||
const chunks = useMemo(() => splitPlainChunks(text), [text]);
|
||||
return (
|
||||
<>
|
||||
{chunks.map((chunk, index) =>
|
||||
index < chunks.length - 1 ? (
|
||||
<MarkdownChunk
|
||||
key={index}
|
||||
text={chunk}
|
||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||
/>
|
||||
) : (
|
||||
// The live tail: flat, React-escaped plain text (no markdown parse, no
|
||||
// sanitizer, no innerHTML). `pre-wrap` preserves its newlines; trailing
|
||||
// separator newlines are dropped at display time so the block gap comes
|
||||
// from the markdown margins, not a doubled empty line (mirrors
|
||||
// PlainChunk in streaming-plain-text.tsx).
|
||||
<div
|
||||
key={index}
|
||||
className={classes.markdown}
|
||||
style={{ whiteSpace: "pre-wrap" }}
|
||||
>
|
||||
{chunk.replace(/\n+$/, "")}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -57,6 +57,31 @@ export async function stopRun(
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delta poll (#491): the chat's message rows changed since `cursor` (a DB-clock
|
||||
* timestamp echoed from the previous poll) plus the current run fact, in ONE
|
||||
* round-trip — the degraded-poll fallback's payload, replacing the old "refetch
|
||||
* ALL infinite-query pages every 2.5s with full parts" poll. Omit `cursor` on the
|
||||
* first poll (returns just a fresh cursor, no rows, to start the chain). The
|
||||
* overlap window guarantees occasional REPEATS, so the caller MUST merge rows
|
||||
* idempotently by id (mergeById). Owner-gated server-side.
|
||||
*/
|
||||
export async function getAiChatMessagesDelta(
|
||||
chatId: string,
|
||||
cursor?: string,
|
||||
): Promise<{
|
||||
rows: IAiChatMessageRow[];
|
||||
cursor: string;
|
||||
run: { id: string; status: string } | null;
|
||||
}> {
|
||||
const req = await api.post<{
|
||||
rows: IAiChatMessageRow[];
|
||||
cursor: string;
|
||||
run: { id: string; status: string } | null;
|
||||
}>("/ai-chat/messages/delta", { chatId, cursor });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* #488: the run-fact — "is a run active on this chat?" — first-class from the
|
||||
* server (POST /ai-chat/run). Called on mount to seed the client FSM's run-fact
|
||||
|
||||
@@ -48,6 +48,7 @@ Legend: **†** = command-transition (bumps `epoch`, I1). Effects in `[…]`.
|
||||
| `RETRY` (manual, stalled banner) | stalled | polling(attach-none) **†** | `[armPoll]` |
|
||||
| `POLL_TERMINAL` (settled tail merged) | polling, reconnecting, stopping | idle | `[disarmPoll, cancelReconnect]`, runFact←null (I4) |
|
||||
| `POLL_IDLE_CAP` (inactivity cap) | polling, reconnecting | stalled | `[disarmPoll, cancelReconnect]` (commit 4a — no more silent) |
|
||||
| `POLL_IDLE_CAP` (inactivity cap) | stopping | idle | `[disarmPoll, cancelReconnect]`, runFact←null (Review #4: a Stop-armed poll with no SDK/terminal backstop gets a bounded exit — NOT `stalled`, Stop was already pressed so nothing to retry) |
|
||||
| `RUN_FACT{null}` (POST /run → null/terminal, 204) | reconnecting/attaching/polling/stopping | idle | `[cancelReconnect, disarmPoll]`, runFact←null (I3 fresh-negative gate) |
|
||||
| `RUN_FACT{runId}` | any | (same) | runFact←runId (pessimism toward an attempt) |
|
||||
| `STOP_REQUESTED` (user Stop) | streaming, reconnecting, polling | stopping **†** | `[stopRun, abortAttach, cancelReconnect, armPoll]` (poll drives the terminal — I4 exit by data) |
|
||||
@@ -121,8 +122,7 @@ holds. **Pending column: empty.**
|
||||
| 11 | `stopPendingRef` | **FSM phase `stopping`** | the deferred stop fires from the chat-id adoption effect while `stopping` |
|
||||
| 12 | `mountedRef` | **retained (React liveness)** | orthogonal to run-lifecycle; gates imperative onFinish side-effects post-unmount. Epoch (I1) handles stale COMMAND-outcomes; DISPOSE bumps it |
|
||||
| 13 | `attemptResumeRef` | **FSM `ATTACH_START` + run-fact** | mount arms attach ONLY on a confirmed active run (commit 4b: streaming-tail status, or POST /run for a user tail) |
|
||||
| 14 | `stripRef` | **data** (attachStrategy) | strip+replay detail; the `resumeStream` effect reads it |
|
||||
| 15 | `strippedRowRef` | **data** (attachStrategy) | the anchor row |
|
||||
| 14–15 | `anchorRef {id, stepsPersisted}` | **data** (attachStrategy) | #491 tail-only: replaced `stripRef`/`strippedRowRef`. The PERSISTED assistant row that pins the run (server invariant 6) + its step frontier N; feeds `?anchor=<id>&n=<stepsPersisted>`. No strip — the seed keeps every row; entering reconnecting re-seeds from persist |
|
||||
| 16 | `attachAbortRef` | **effect-owned controller** | aborted by the `abortAttach` effect in cleanup (I5) |
|
||||
| 17–25 | `chatIdRef`, `openPageRef`, `getEditorSelectionRef`, `roleIdRef`, `stableIdRef`, `queuedRef`, `sendMessageRef`, `statusRef`, `lastForwardedChatIdRef` | **data** (identity/send mirrors) | unchanged — not lifecycle flags |
|
||||
| NEW | `pendingSupersedeRef` | **data** (send-plumbing) | the runId injected into the next `POST /stream {supersede}`; the single replacement for the 3 DELETED one-shots (#8/#9/#10) — net −2 refs |
|
||||
@@ -151,8 +151,12 @@ message. Sources, in the order they update `ctx.runFact`:
|
||||
3. **Attach outcomes:** `ATTACH_LIVE` (2xx) confirms active; a 204 on a non-stripped
|
||||
path is an authoritative NEGATIVE fact → the runtime dispatches `RUN_FACT{null}`,
|
||||
which cancels recovery (I3 fresh-negative gate).
|
||||
4. **Poll (future resume-stack iteration #491):** the delta will carry the run field;
|
||||
until then the poll drives to a terminal ROW, dispatched as `POLL_TERMINAL`.
|
||||
4. **Poll (#491, implemented):** the degraded poll now hits the delta endpoint
|
||||
(`POST /ai-chat/messages/delta`), which ALREADY carries the run fact
|
||||
(`run: {id, status} | null`) alongside the changed rows. The client does NOT yet
|
||||
consume that run field — it still drives to a terminal ROW (merged by id),
|
||||
dispatched as `POLL_TERMINAL` — so the run field rides the wire for a future
|
||||
client that settles straight off it.
|
||||
|
||||
Pessimism rule: a stale-but-positive fact PERMITS entering recovery (attach); the
|
||||
204 then cuts it. A fresh negative fact gates recovery OUT immediately.
|
||||
@@ -178,6 +182,9 @@ Pessimism rule: a stale-but-positive fact PERMITS entering recovery (attach); th
|
||||
/run) are effect-owned and aborted in cleanup (`abortAttach` on `DISPOSE`), not
|
||||
render-phase refs. A client abort of an already-sent POST does not cancel the
|
||||
server action, so disarming on unmount is safe.
|
||||
- **attachStrategy** (strip+replay today) is behind the `resumeStream` effect; the
|
||||
resume-stack iteration (#491) swaps it to tail-only WITHOUT touching the FSM.
|
||||
- **attachStrategy** is behind the `resumeStream` effect; #491 swapped it to
|
||||
tail-only (`?anchor=&n=`, `anchorRef` data) WITHOUT touching the FSM. Entering
|
||||
reconnecting always re-seeds from persist; on a getRun failure the live partial
|
||||
is dropped + replay-from-start so it is never the tail-apply base (no #137/#161
|
||||
duplication).
|
||||
- **Queue** stays a data structure; flush/interrupt decisions are transitions.
|
||||
|
||||
@@ -181,6 +181,12 @@ export interface IAiChatMessageRow {
|
||||
toolCalls?: unknown;
|
||||
metadata?: {
|
||||
parts?: UIMessage["parts"];
|
||||
// #491 step-alignment anchor: the count of FINISHED steps whose parts are in
|
||||
// THIS row, written atomically with `parts` server-side (flushAssistant). The
|
||||
// resume client reads it as its persisted step frontier N — the tail-only
|
||||
// attach asks the run-stream registry for the frames of step N onward (the
|
||||
// seed already carries steps 0..N-1). Absent on pre-#491 rows -> read as 0.
|
||||
stepsPersisted?: number;
|
||||
// AI SDK v6 `totalUsage` persisted on assistant rows. Legacy cumulative
|
||||
// figure (sum of every step's usage for the turn); kept for back-compat and
|
||||
// as the fallback for older rows that have no `contextTokens`.
|
||||
|
||||
@@ -6,10 +6,13 @@ describe("estimateTokens", () => {
|
||||
expect(estimateTokens("")).toBe(0);
|
||||
});
|
||||
|
||||
it("ceils chars/4 so any non-empty text is at least 1 token", () => {
|
||||
// #490: migrated onto the shared @docmost/token-estimate module (chars/2.5, up
|
||||
// from the old client-only chars/4) so the client counter and the server replay
|
||||
// budgeter can never diverge.
|
||||
it("ceils chars/2.5 so any non-empty text is at least 1 token", () => {
|
||||
expect(estimateTokens("a")).toBe(1);
|
||||
expect(estimateTokens("abcd")).toBe(1);
|
||||
expect(estimateTokens("abcde")).toBe(2);
|
||||
expect(estimateTokens("12345678")).toBe(2);
|
||||
expect(estimateTokens("ab")).toBe(1);
|
||||
expect(estimateTokens("abcde")).toBe(2); // 5 / 2.5 = 2
|
||||
expect(estimateTokens("x".repeat(10))).toBe(4); // 10 / 2.5 = 4
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,18 +2,10 @@
|
||||
* Rough client-side token estimation for AI-chat UI affordances.
|
||||
*
|
||||
* No provider streams exact per-token usage mid-stream, so any in-flight figure
|
||||
* is a CLIENT ESTIMATE (chars/≈4 heuristic). Pure + unit-testable: it never runs
|
||||
* a real BPE tokenizer (that would be O(n²) on the hot path, bloat the bundle,
|
||||
* and be wrong for Gemini/Ollama anyway). Used by the in-body reasoning counter
|
||||
* ("Thinking · N tokens").
|
||||
* is a CLIENT ESTIMATE. This re-exports the SHARED estimator from
|
||||
* `@docmost/token-estimate` (chars/2.5) so the in-body counter and the server's
|
||||
* replay budgeter use the SAME heuristic — two divergent estimators would mean
|
||||
* "the badge shows 60%" while "the budgeter already trimmed" (#490). Used by the
|
||||
* in-body reasoning counter ("Thinking · N tokens").
|
||||
*/
|
||||
|
||||
/**
|
||||
* Rough token estimate for a piece of text using the standard chars/≈4 heuristic.
|
||||
* Returns 0 for empty/whitespace-free-of-content input, and ceils so any
|
||||
* non-empty text counts as at least one token.
|
||||
*/
|
||||
export function estimateTokens(text: string): number {
|
||||
if (!text) return 0;
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
export { estimateTokens } from "@docmost/token-estimate";
|
||||
|
||||
@@ -89,6 +89,23 @@ describe("describeChatError", () => {
|
||||
expect(view.title).not.toBe("AI provider not configured");
|
||||
});
|
||||
|
||||
it("classifies a token-degeneration abort under the SAME 'Response stopped.' marker the live view shows (#495)", () => {
|
||||
// The exact reason the server persists in metadata.error on a degeneration
|
||||
// abort (ai-chat.service OUTPUT_DEGENERATION_ERROR). Live, this event shows
|
||||
// the neutral "Response stopped." notice; the persisted banner MUST match it
|
||||
// so live and refetch never disagree.
|
||||
const view = describeChatError(
|
||||
"Output degeneration detected (repeated token loop)",
|
||||
t,
|
||||
);
|
||||
expect(view.title).toBe("Response stopped.");
|
||||
expect(view.detail).toBe(
|
||||
"The answer was stopped automatically because the model fell into a repeated output loop.",
|
||||
);
|
||||
// Regression guard: it must NOT fall through to the generic heading.
|
||||
expect(view.title).not.toBe("Something went wrong");
|
||||
});
|
||||
|
||||
it("classifies a dropped connection (ECONNRESET) as a lost-connection error", () => {
|
||||
expect(
|
||||
describeChatError("Cannot connect to API: read ECONNRESET", t).title,
|
||||
|
||||
@@ -77,6 +77,22 @@ export function describeChatError(
|
||||
};
|
||||
}
|
||||
|
||||
// Our own token-degeneration abort (#444): the server aborts a runaway
|
||||
// repetition loop and persists this exact reason in metadata.error. LIVE, the
|
||||
// same abort surfaces as the neutral "Response stopped." notice (the client
|
||||
// cannot tell it from a manual Stop mid-stream), so the persisted banner must
|
||||
// read the SAME "Response stopped." marker — otherwise the live view and a
|
||||
// later refetch show two different texts for one event. The detail explains the
|
||||
// loop-guard cause without contradicting the shared heading.
|
||||
if (/output degeneration detected|repeated token loop/i.test(msg)) {
|
||||
return {
|
||||
title: t("Response stopped."),
|
||||
detail: t(
|
||||
"The answer was stopped automatically because the model fell into a repeated output loop.",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (/"statusCode"\s*:\s*403\b/.test(msg)) {
|
||||
return {
|
||||
title: t("AI chat is disabled"),
|
||||
|
||||
@@ -4,7 +4,8 @@ import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.t
|
||||
import {
|
||||
isStreamingTail,
|
||||
isSettledAssistantTail,
|
||||
seedRows,
|
||||
stepsPersistedOf,
|
||||
mergeDeltaRowsIntoPages,
|
||||
mergeById,
|
||||
} from "./resume-helpers.ts";
|
||||
|
||||
@@ -12,8 +13,18 @@ function row(
|
||||
id: string,
|
||||
role: string,
|
||||
status?: string,
|
||||
stepsPersisted?: number,
|
||||
): IAiChatMessageRow {
|
||||
return { id, role, content: "", status, createdAt: "2026-01-01T00:00:00Z" };
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
content: "",
|
||||
status,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
...(stepsPersisted !== undefined
|
||||
? { metadata: { stepsPersisted } }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeMsg(id: string, text: string): UIMessage {
|
||||
@@ -65,23 +76,92 @@ describe("isSettledAssistantTail", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("seedRows", () => {
|
||||
const rows = [row("u1", "user"), row("a1", "assistant", "streaming")];
|
||||
|
||||
it("returns the rows unchanged when not stripping", () => {
|
||||
expect(seedRows(rows, false)).toBe(rows);
|
||||
describe("stepsPersistedOf", () => {
|
||||
it("reads metadata.stepsPersisted", () => {
|
||||
expect(stepsPersistedOf(row("a1", "assistant", "streaming", 3))).toBe(3);
|
||||
expect(stepsPersistedOf(row("a1", "assistant", "streaming", 0))).toBe(0);
|
||||
});
|
||||
|
||||
it("drops the last row when stripping", () => {
|
||||
const seeded = seedRows(rows, true);
|
||||
expect(seeded).toHaveLength(1);
|
||||
expect(seeded[0].id).toBe("u1");
|
||||
it("defaults to 0 for a pre-#491 row (absent), null/undefined, or a bad value", () => {
|
||||
expect(stepsPersistedOf(row("a1", "assistant", "streaming"))).toBe(0);
|
||||
expect(stepsPersistedOf(null)).toBe(0);
|
||||
expect(stepsPersistedOf(undefined)).toBe(0);
|
||||
expect(
|
||||
stepsPersistedOf({
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
createdAt: "x",
|
||||
metadata: { stepsPersisted: -2 },
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("returns an empty list when stripping a single-row list", () => {
|
||||
expect(seedRows([row("a1", "assistant", "streaming")], true)).toHaveLength(
|
||||
0,
|
||||
);
|
||||
it("floors a non-integer count", () => {
|
||||
expect(
|
||||
stepsPersistedOf({
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
createdAt: "x",
|
||||
metadata: { stepsPersisted: 2.9 },
|
||||
}),
|
||||
).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeDeltaRowsIntoPages", () => {
|
||||
const pages = () => [
|
||||
{ items: [row("u1", "user"), row("a1", "assistant", "streaming", 1)], meta: {} },
|
||||
];
|
||||
|
||||
it("returns the pages unchanged for an empty delta", () => {
|
||||
const p = pages();
|
||||
expect(mergeDeltaRowsIntoPages(p, [])).toBe(p);
|
||||
});
|
||||
|
||||
it("appends a genuinely new row to the last page in chronological order", () => {
|
||||
const merged = mergeDeltaRowsIntoPages(pages(), [row("a2", "assistant", "streaming", 0)]);
|
||||
expect(merged[0].items.map((i) => i.id)).toEqual(["u1", "a1", "a2"]);
|
||||
});
|
||||
|
||||
it("replaces a grown row in place (per-step growth), never appends a duplicate", () => {
|
||||
const merged = mergeDeltaRowsIntoPages(pages(), [
|
||||
row("a1", "assistant", "streaming", 2),
|
||||
]);
|
||||
expect(merged[0].items.map((i) => i.id)).toEqual(["u1", "a1"]);
|
||||
// the in-place replacement carries the grown step frontier.
|
||||
expect(stepsPersistedOf(merged[0].items[1])).toBe(2);
|
||||
});
|
||||
|
||||
it("does not mutate the input pages", () => {
|
||||
const input = pages();
|
||||
const before = input[0].items.slice();
|
||||
mergeDeltaRowsIntoPages(input, [row("a2", "assistant", "streaming", 0)]);
|
||||
expect(input[0].items).toEqual(before); // untouched
|
||||
});
|
||||
|
||||
// #491 CONTRACT: the delta overlap window re-delivers the same rows, so merging
|
||||
// MUST be idempotent — applying a delta twice equals applying it once (no growth,
|
||||
// no reorder). A regression re-introduces duplicate assistant bubbles per poll.
|
||||
it("is idempotent: applying the same delta twice equals once", () => {
|
||||
const delta = [
|
||||
row("a1", "assistant", "streaming", 2), // grown existing row
|
||||
row("a2", "assistant", "streaming", 0), // new row
|
||||
];
|
||||
const once = mergeDeltaRowsIntoPages(pages(), delta);
|
||||
const twice = mergeDeltaRowsIntoPages(once, delta);
|
||||
const thrice = mergeDeltaRowsIntoPages(twice, delta);
|
||||
expect(once[0].items.map((i) => i.id)).toEqual(["u1", "a1", "a2"]);
|
||||
expect(twice[0].items.map((i) => i.id)).toEqual(["u1", "a1", "a2"]);
|
||||
expect(twice).toEqual(once);
|
||||
expect(thrice).toEqual(once);
|
||||
});
|
||||
|
||||
it("seeds a first page when the cache is empty", () => {
|
||||
const merged = mergeDeltaRowsIntoPages([], [row("u1", "user")]);
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0].items.map((i) => i.id)).toEqual(["u1"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,4 +189,37 @@ describe("mergeById", () => {
|
||||
expect(mergeById(prev, null)).toBe(prev);
|
||||
expect(mergeById(prev, undefined)).toBe(prev);
|
||||
});
|
||||
|
||||
// #491 CONTRACT: the delta poll's overlap window GUARANTEES the same row is
|
||||
// re-delivered across close polls, so merging must be IDEMPOTENT by id — merging
|
||||
// the same row (or an equal-length list of rows) twice must not duplicate or
|
||||
// reorder. This is the property the whole delta-poll design leans on; a
|
||||
// regression here would re-introduce duplicate assistant bubbles on every poll.
|
||||
it("is idempotent by id: re-merging the same row does not duplicate or reorder", () => {
|
||||
const seed = [makeMsg("u1", "hi"), makeMsg("a1", "step 1")];
|
||||
const repeat = makeMsg("a1", "step 1"); // the SAME row the overlap re-delivers
|
||||
const once = mergeById(seed, repeat);
|
||||
const twice = mergeById(once, repeat);
|
||||
const thrice = mergeById(twice, repeat);
|
||||
// Length is stable (no growth), order is stable (user then assistant).
|
||||
expect(once.map((m) => m.id)).toEqual(["u1", "a1"]);
|
||||
expect(twice.map((m) => m.id)).toEqual(["u1", "a1"]);
|
||||
expect(thrice.map((m) => m.id)).toEqual(["u1", "a1"]);
|
||||
// The repeated merge converges: the row is replaced in place, never appended.
|
||||
expect(twice[1]).toBe(repeat);
|
||||
});
|
||||
|
||||
it("is idempotent across a batch of repeated + grown rows (delta re-delivery)", () => {
|
||||
// A delta poll re-delivers a1 (unchanged) and a2 (grown one step). Applying the
|
||||
// batch twice must equal applying it once — the poll can re-send either.
|
||||
const start = [makeMsg("u1", "hi"), makeMsg("a1", "done")];
|
||||
const batch = [makeMsg("a1", "done"), makeMsg("a2", "grown step 2")];
|
||||
const apply = (list: typeof start) =>
|
||||
batch.reduce((acc, row) => mergeById(acc, row), list);
|
||||
const once = apply(start);
|
||||
const twice = apply(once);
|
||||
expect(once.map((m) => m.id)).toEqual(["u1", "a1", "a2"]);
|
||||
expect(twice.map((m) => m.id)).toEqual(["u1", "a1", "a2"]);
|
||||
expect(twice).toEqual(once);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,9 +11,10 @@ import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.t
|
||||
|
||||
/**
|
||||
* A STREAMING tail: the last persisted row is an assistant row still marked
|
||||
* `status === 'streaming'`. Such a tail is stripped from the seed and rebuilt by
|
||||
* the replay (`expect=live`), since the SDK's `text-start` always pushes a new
|
||||
* part and replaying over a seeded in-progress row would duplicate its text.
|
||||
* `status === 'streaming'`. #491 (tail-only): such a tail is seeded UNCHANGED —
|
||||
* it carries the persisted steps 0..N-1 — and the run-stream registry's tail
|
||||
* (frames for steps >= N) is APPENDED to it by the SDK's `readUIMessageStream`
|
||||
* continuation. Only the presence of this tail decides WHETHER to attach.
|
||||
*/
|
||||
export function isStreamingTail(rows: IAiChatMessageRow[]): boolean {
|
||||
const tail = rows[rows.length - 1];
|
||||
@@ -32,15 +33,61 @@ export function isSettledAssistantTail(rows: IAiChatMessageRow[]): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed rows for `useChat`: return the rows unchanged, or without the last row when
|
||||
* `strip` is set (the streaming tail is stripped so the live replay rebuilds it
|
||||
* without duplicating parts).
|
||||
* #491 tail-only anchor: the count of FINISHED steps whose parts are persisted in
|
||||
* THIS assistant row (`metadata.stepsPersisted`), written atomically with `parts`
|
||||
* server-side. The resume client reads it as its persisted step frontier N — the
|
||||
* tail-only attach asks the run-stream registry for the frames of step N onward
|
||||
* (the seed already carries steps 0..N-1). Absent on pre-#491 rows => 0.
|
||||
*/
|
||||
export function seedRows(
|
||||
export function stepsPersistedOf(
|
||||
row: IAiChatMessageRow | null | undefined,
|
||||
): number {
|
||||
const n = row?.metadata?.stepsPersisted;
|
||||
return typeof n === "number" && n >= 0 ? Math.floor(n) : 0;
|
||||
}
|
||||
|
||||
/** One page of the messages infinite-query cache (`{ items, meta }`). */
|
||||
export interface IMessagePage {
|
||||
items: IAiChatMessageRow[];
|
||||
meta: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* #491 delta-poll merge: upsert the delta poll's `rows` into the messages
|
||||
* infinite-query page structure IDEMPOTENTLY by id. The delta endpoint's overlap
|
||||
* window GUARANTEES occasional REPEATS, so this MUST converge: a row already
|
||||
* present is REPLACED IN PLACE (per-step growth of an in-progress row), a new row
|
||||
* is APPENDED to the last page in chronological order (the server returns delta
|
||||
* rows oldest-first). Applying the same delta twice equals applying it once. Never
|
||||
* mutates the input pages (returns fresh page objects with cloned item arrays).
|
||||
*/
|
||||
export function mergeDeltaRowsIntoPages(
|
||||
pages: IMessagePage[],
|
||||
rows: IAiChatMessageRow[],
|
||||
strip: boolean,
|
||||
): IAiChatMessageRow[] {
|
||||
return strip ? rows.slice(0, -1) : rows;
|
||||
): IMessagePage[] {
|
||||
if (rows.length === 0) return pages;
|
||||
const next: IMessagePage[] = pages.map((p) => ({
|
||||
...p,
|
||||
items: p.items.slice(),
|
||||
}));
|
||||
const locate = (id: string): [number, number] | null => {
|
||||
for (let pi = 0; pi < next.length; pi++) {
|
||||
const ii = next[pi].items.findIndex((it) => it.id === id);
|
||||
if (ii !== -1) return [pi, ii];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
for (const row of rows) {
|
||||
const at = locate(row.id);
|
||||
if (at) {
|
||||
next[at[0]].items[at[1]] = row; // replace in place — idempotent by id
|
||||
} else if (next.length > 0) {
|
||||
next[next.length - 1].items.push(row); // append chronologically
|
||||
} else {
|
||||
next.push({ items: [row], meta: undefined });
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readUIMessageStream, type UIMessage } from "ai";
|
||||
import pkg from "../../../../package.json";
|
||||
|
||||
/**
|
||||
* PIN-SPEC TRIP-WIRE (#491). The tail-only attach continuation relies on THREE
|
||||
* behaviors of `ai@6.0.207`, verified line-by-line in the issue. Without this
|
||||
* test, an `ai` bump could silently break attach (the client would append the
|
||||
* live tail to the wrong message, or duplicate a step):
|
||||
*
|
||||
* 1. `readUIMessageStream({ message })` CONTINUES the passed message — it does
|
||||
* not start a fresh one — so the tail streamed after a re-seed is appended to
|
||||
* the seeded assistant row (the same DB id).
|
||||
* 2. A `start` frame does NOT reset the existing message's parts (so the seeded
|
||||
* steps 0..N-1 survive; the synthetic `start` the registry prepends only
|
||||
* carries the run-fact metadata).
|
||||
* 3. Text parts do NOT cross a `finish-step` boundary — a new `text-start` after
|
||||
* `finish-step` is a NEW part — so the reconstructed steps stay separated and
|
||||
* the step frontier stays meaningful.
|
||||
*
|
||||
* If an `ai` upgrade changes any of these, this test fails LOUD instead of the
|
||||
* resume path silently corrupting.
|
||||
*/
|
||||
describe("ai SDK continuation trip-wire (#491, tail-only attach)", () => {
|
||||
it("is pinned to the exact ai version the continuation was verified against", () => {
|
||||
// A caret/range bump is exactly what would silently break attach — require an
|
||||
// exact pin. Bumping ai MUST re-verify the behavior asserted below, then this.
|
||||
expect((pkg as { dependencies: Record<string, string> }).dependencies.ai).toBe(
|
||||
"6.0.207",
|
||||
);
|
||||
});
|
||||
|
||||
it("continues the seeded message: start does not reset parts, the tail appends as new parts", async () => {
|
||||
// A seeded assistant row with ONE finished step already reconstructed.
|
||||
const seeded: UIMessage = {
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{ type: "step-start" },
|
||||
{ type: "text", text: "STEP0", state: "done" },
|
||||
],
|
||||
} as UIMessage;
|
||||
|
||||
// The tail the registry delivers on re-attach: a synthetic start (run-fact),
|
||||
// then step 1's frames, then finish. As UI-message chunks (what the SSE frames
|
||||
// decode to).
|
||||
const chunks = [
|
||||
{ type: "start", messageMetadata: { runId: "r1", chatId: "c1" } },
|
||||
{ type: "start-step" },
|
||||
{ type: "text-start", id: "t1" },
|
||||
{ type: "text-delta", id: "t1", delta: "STEP1" },
|
||||
{ type: "text-end", id: "t1" },
|
||||
{ type: "finish-step" },
|
||||
{ type: "finish" },
|
||||
];
|
||||
const stream = new ReadableStream({
|
||||
start(c) {
|
||||
for (const ch of chunks) c.enqueue(ch);
|
||||
c.close();
|
||||
},
|
||||
});
|
||||
|
||||
let last: UIMessage | undefined;
|
||||
for await (const msg of readUIMessageStream({ message: seeded, stream })) {
|
||||
last = msg;
|
||||
}
|
||||
|
||||
expect(last).toBeDefined();
|
||||
// Same message id (continuation, not a fresh message).
|
||||
expect(last!.id).toBe("assistant-1");
|
||||
// The seeded step-0 parts SURVIVED the `start` frame, and step 1 was appended
|
||||
// as SEPARATE parts (text did not cross the finish-step boundary).
|
||||
const shape = last!.parts.map((p) => `${p.type}:${(p as { text?: string }).text ?? ""}`);
|
||||
expect(shape).toEqual([
|
||||
"step-start:",
|
||||
"text:STEP0",
|
||||
"step-start:",
|
||||
"text:STEP1",
|
||||
]);
|
||||
// The run-fact metadata from the synthetic start frame is applied.
|
||||
expect(last!.metadata).toMatchObject({ runId: "r1", chatId: "c1" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import * as Y from "yjs";
|
||||
import { yHistoryAvailability } from "./use-toolbar-state.ts";
|
||||
|
||||
// Undo/redo availability is derived from the Yjs UndoManager's PRIVATE
|
||||
// `undoStack` / `redoStack` fields (see use-toolbar-state.ts for why we read the
|
||||
// stack lengths directly instead of the expensive `editor.can().undo()` dry-run).
|
||||
// These tests lock in the behavior AND pin the library shape so a yjs / y-undo
|
||||
// upgrade that renames/restructures those internals fails loudly here rather than
|
||||
// silently enabling/disabling the toolbar buttons in production.
|
||||
describe("yHistoryAvailability", () => {
|
||||
it("reports availability from the stack lengths", () => {
|
||||
expect(yHistoryAvailability({ undoStack: [], redoStack: [] })).toEqual({
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
});
|
||||
expect(
|
||||
yHistoryAvailability({ undoStack: [{}], redoStack: [] }),
|
||||
).toEqual({ canUndo: true, canRedo: false });
|
||||
expect(
|
||||
yHistoryAvailability({ undoStack: [{}], redoStack: [{}, {}] }),
|
||||
).toEqual({ canUndo: true, canRedo: true });
|
||||
});
|
||||
|
||||
it("returns null when the private stack shape is unrecognized (upgrade guard)", () => {
|
||||
// Simulates a yjs / y-undo upgrade that renames or restructures the private
|
||||
// fields: the caller then falls back to the safe prosemirror-history default
|
||||
// instead of throwing on `.length` of undefined or reading garbage.
|
||||
expect(yHistoryAvailability(undefined)).toBeNull();
|
||||
expect(yHistoryAvailability(null)).toBeNull();
|
||||
expect(yHistoryAvailability({})).toBeNull();
|
||||
expect(yHistoryAvailability({ undoStack: 5, redoStack: 5 })).toBeNull();
|
||||
// Only one stack present (partial rename) is still not trusted.
|
||||
expect(yHistoryAvailability({ undoStack: [] })).toBeNull();
|
||||
});
|
||||
|
||||
it("pin-test: a real yjs UndoManager still exposes undoStack/redoStack arrays", () => {
|
||||
const doc = new Y.Doc();
|
||||
const text = doc.getText("prosemirror");
|
||||
const undoManager = new Y.UndoManager(text);
|
||||
|
||||
// Fresh manager: both stacks empty -> nothing to undo/redo.
|
||||
expect(yHistoryAvailability(undoManager)).toEqual({
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
});
|
||||
|
||||
// A tracked edit must push onto the private undoStack. If a future yjs
|
||||
// renames these fields, yHistoryAvailability(undoManager) returns null and
|
||||
// the expectation below fails loudly.
|
||||
text.insert(0, "hello");
|
||||
undoManager.stopCapturing();
|
||||
expect(yHistoryAvailability(undoManager)).toEqual({
|
||||
canUndo: true,
|
||||
canRedo: false,
|
||||
});
|
||||
|
||||
// Undoing moves the item to the redoStack -> redo becomes available.
|
||||
undoManager.undo();
|
||||
expect(yHistoryAvailability(undoManager)).toEqual({
|
||||
canUndo: false,
|
||||
canRedo: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,30 @@ export interface ToolbarState {
|
||||
// When neither history backend is installed (the pre-sync static editor —
|
||||
// mainExtensions only, undoRedo disabled), both fall through to 0 -> false,
|
||||
// matching the previous `safeCan` behavior.
|
||||
// Reads the Yjs UndoManager's undo/redo availability from its stack lengths.
|
||||
//
|
||||
// `undoStack` / `redoStack` are PRIVATE y-undo / yjs internals, so we touch them
|
||||
// defensively: a yjs or y-undo upgrade that renames or restructures these fields
|
||||
// must not silently mis-drive the toolbar buttons (nor throw on `.length` of
|
||||
// `undefined`). We only trust them when they are actually arrays; otherwise this
|
||||
// returns null and the caller falls back to a safe default. The pin-test in
|
||||
// use-toolbar-state.test.ts asserts the current library shape, so an upgrade that
|
||||
// breaks this contract fails loudly there instead of failing silently in the UI.
|
||||
export function yHistoryAvailability(
|
||||
undoManager: unknown,
|
||||
): { canUndo: boolean; canRedo: boolean } | null {
|
||||
if (!undoManager || typeof undoManager !== "object") return null;
|
||||
const { undoStack, redoStack } = undoManager as {
|
||||
undoStack?: unknown;
|
||||
redoStack?: unknown;
|
||||
};
|
||||
if (!Array.isArray(undoStack) || !Array.isArray(redoStack)) return null;
|
||||
return {
|
||||
canUndo: undoStack.length > 0,
|
||||
canRedo: redoStack.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function historyAvailability(editor: Editor): {
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
@@ -43,16 +67,14 @@ function historyAvailability(editor: Editor): {
|
||||
|
||||
// Collaboration history (Yjs) takes precedence when present.
|
||||
const yState = yUndoPluginKey.getState(state) as
|
||||
| { undoManager?: { undoStack: unknown[]; redoStack: unknown[] } }
|
||||
| { undoManager?: unknown }
|
||||
| undefined;
|
||||
if (yState?.undoManager) {
|
||||
return {
|
||||
canUndo: yState.undoManager.undoStack.length > 0,
|
||||
canRedo: yState.undoManager.redoStack.length > 0,
|
||||
};
|
||||
}
|
||||
const yAvail = yHistoryAvailability(yState?.undoManager);
|
||||
if (yAvail) return yAvail;
|
||||
|
||||
// Plain prosemirror-history (returns 0 when the history plugin is absent).
|
||||
// This is also the safe default when a Yjs UndoManager is present but its
|
||||
// private stack shape is no longer recognized (yHistoryAvailability -> null).
|
||||
return {
|
||||
canUndo: undoDepth(state) > 0,
|
||||
canRedo: redoDepth(state) > 0,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Italic } from "@tiptap/extension-italic";
|
||||
import { Link } from "@tiptap/extension-link";
|
||||
import { gitmostInsertTranscriptIntoEditor } from "./gitmost-recording.ts";
|
||||
|
||||
const ZWSP = ""; // U+200B, the helper's block-trigger neutralizer
|
||||
const ZWSP = ""; // U+200B — asserted ABSENT (the block-escape lives in the serializer now)
|
||||
|
||||
/**
|
||||
* #377 — the web-side bridge must append the native host's transcript below the
|
||||
@@ -18,8 +18,9 @@ const ZWSP = ""; // U+200B, the helper's block-trigger neutralizer
|
||||
* regression would be caught), asserting the resulting document rather than
|
||||
* mocking the editor: transcript present -> "Transcript" heading + one paragraph
|
||||
* per non-empty line; content is inserted as LITERAL TEXT (no HTML/markdown
|
||||
* parsing); col-0 markdown block triggers are neutralized so git-sync keeps them
|
||||
* paragraphs; absent/empty/non-string -> no-op.
|
||||
* parsing); col-0 markdown block triggers are stored verbatim (the git-sync
|
||||
* serializer block-escapes them, so no client-side ZWSP is needed);
|
||||
* absent/empty/non-string -> no-op.
|
||||
*/
|
||||
describe("gitmostInsertTranscriptIntoEditor", () => {
|
||||
const makeEditor = () =>
|
||||
@@ -91,19 +92,22 @@ describe("gitmostInsertTranscriptIntoEditor", () => {
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("neutralizes col-0 markdown block triggers with a leading ZWSP (git-sync safety)", () => {
|
||||
it("inserts col-0 markdown block triggers as verbatim paragraph text (no ZWSP workaround)", () => {
|
||||
const editor = makeEditor();
|
||||
// Trigger lines (some with a leaked indent) + a normal prefixed line.
|
||||
// Trigger lines (some with a leaked indent) + a normal prefixed line. The
|
||||
// git-sync serializer now block-escapes a leading trigger itself, so the
|
||||
// bridge inserts each line's TEXT byte-exact (only the leaked indent is
|
||||
// trimmed) — no invisible ZWSP is prepended anymore.
|
||||
const inserted = gitmostInsertTranscriptIntoEditor(
|
||||
editor,
|
||||
[
|
||||
"- dash",
|
||||
" > quote", // leading indent must be trimmed then neutralized
|
||||
" > quote", // leading indent is trimmed, text otherwise verbatim
|
||||
"# hash",
|
||||
"1. one",
|
||||
"> [!info] note",
|
||||
"```js",
|
||||
"---", // solid thematic break -> horizontalRule (text-losing) if unneutralized
|
||||
"---",
|
||||
"***",
|
||||
"___",
|
||||
"You: normal line",
|
||||
@@ -116,20 +120,23 @@ describe("gitmostInsertTranscriptIntoEditor", () => {
|
||||
.map((n: any) => n.content?.[0]?.text)
|
||||
.filter((t: any) => typeof t === "string") as string[];
|
||||
|
||||
// Every block-trigger line is prefixed with the invisible ZWSP (indent
|
||||
// trimmed first); the normal `You:` line is left byte-exact.
|
||||
// Each trigger line is stored as its own byte-exact text (indent trimmed);
|
||||
// the git-sync round-trip keeps it a paragraph via the serializer's
|
||||
// block-escape, so no ZWSP is needed here.
|
||||
expect(texts).toEqual([
|
||||
ZWSP + "- dash",
|
||||
ZWSP + "> quote",
|
||||
ZWSP + "# hash",
|
||||
ZWSP + "1. one",
|
||||
ZWSP + "> [!info] note",
|
||||
ZWSP + "```js",
|
||||
ZWSP + "---",
|
||||
ZWSP + "***",
|
||||
ZWSP + "___",
|
||||
"- dash",
|
||||
"> quote",
|
||||
"# hash",
|
||||
"1. one",
|
||||
"> [!info] note",
|
||||
"```js",
|
||||
"---",
|
||||
"***",
|
||||
"___",
|
||||
"You: normal line",
|
||||
]);
|
||||
// Guard: no invisible ZWSP leaked into any inserted line.
|
||||
for (const t of texts) expect(t).not.toContain(ZWSP);
|
||||
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
@@ -240,45 +240,22 @@ export async function gitmostUploadFileToEditor(
|
||||
}
|
||||
}
|
||||
|
||||
// Zero-width space (U+200B). Prepended to a transcript line that begins with a
|
||||
// markdown BLOCK trigger: it is invisible in the rendered doc but shifts the
|
||||
// trigger off column 0, so the git-sync doc->markdown->doc round-trip keeps the
|
||||
// line a plain paragraph (see GITMOST_MD_BLOCK_TRIGGER_RE).
|
||||
const GITMOST_ZWSP = "";
|
||||
|
||||
// A markdown BLOCK-level construct that, sitting at column 0 of a paragraph
|
||||
// line, the git-sync markdown serializer (packages/prosemirror-markdown
|
||||
// markdown-converter.ts, `case "paragraph"`) would re-parse into a NON-paragraph
|
||||
// block on the doc->markdown->doc cycle. That serializer emits paragraph text
|
||||
// verbatim with NO block-escape (the pre-existing root cause), so a leading
|
||||
// `#`/`-`/`*`/`+`/`>`, an ordered-list `N.`/`N)`, a code fence ```/~~~, a table
|
||||
// `|`, or a `> [!info]` callout opener would silently become a heading / list /
|
||||
// quote / code block / table / callout. The final alternative matches a WHOLE-
|
||||
// LINE thematic break — solid `---`/`***`/`___` or spaced `- - -`/`_ _ _` (3+ of
|
||||
// the same `-`/`*`/`_`) — which round-trips into a `horizontalRule`; because
|
||||
// that node carries NO text, an un-neutralized separator line would LOSE its
|
||||
// text entirely (worse than the list/quote case). This matches a TRIMMED line's
|
||||
// start; the transcript's own `You:` / `Speaker N:` prefix begins with a letter
|
||||
// and never matches, so prefixed lines are left byte-exact.
|
||||
const GITMOST_MD_BLOCK_TRIGGER_RE =
|
||||
/^(?:#{1,6}(?:\s|$)|[-*+](?:\s|$)|>|\d+[.)](?:\s|$)|```|~~~|\||([-*_])(?:\s*\1){2,}\s*$)/;
|
||||
|
||||
// Append a transcript block BELOW the recording's audio node in a live editor:
|
||||
// a "Transcript" heading followed by one paragraph per non-empty transcript
|
||||
// line. The transcript is plain text, `\n`-separated, each line already
|
||||
// formatted as `You: ...` / `Speaker N: ...` by the native host — line text is
|
||||
// inserted as a TEXT node (never HTML/markdown), so there is no injection or
|
||||
// mark-parsing surface. Each kept line is trimmed (drops an indent that would
|
||||
// both leak into the display and, at col 0, form a markdown block trigger) and,
|
||||
// if it still begins with a col-0 markdown block trigger, gets an invisible
|
||||
// zero-width space prepended so the git-sync round-trip cannot turn it into a
|
||||
// list/quote/heading/callout/code/table (defensive boundary against the
|
||||
// serializer's missing block-escape). This is best-effort and meant to run
|
||||
// AFTER the audio has already been inserted; the caller must guard against a
|
||||
// throw so a transcript failure never fails the (already successful) recording.
|
||||
// Returns true when a block was inserted, false when there was nothing to
|
||||
// insert (transcript undefined/empty/not-a-string). A non-string value is a
|
||||
// no-op, not an error.
|
||||
// leak into the display). A line that begins with a col-0 markdown block
|
||||
// trigger (`#`/`-`/`>`/`1.`/fence/`---`/…) needs no client-side workaround: the
|
||||
// git-sync serializer (packages/prosemirror-markdown, `case "paragraph"`) now
|
||||
// block-escapes such a leading trigger, so the doc->markdown->doc round-trip
|
||||
// keeps the line a paragraph on its own — the former invisible-ZWSP defense is
|
||||
// gone. This is best-effort and meant to run AFTER the audio has already been
|
||||
// inserted; the caller must guard against a throw so a transcript failure never
|
||||
// fails the (already successful) recording. Returns true when a block was
|
||||
// inserted, false when there was nothing to insert (transcript
|
||||
// undefined/empty/not-a-string). A non-string value is a no-op, not an error.
|
||||
export function gitmostInsertTranscriptIntoEditor(
|
||||
editor: Editor,
|
||||
transcript: unknown,
|
||||
@@ -288,13 +265,7 @@ export function gitmostInsertTranscriptIntoEditor(
|
||||
.split("\n")
|
||||
// Trim each line and drop blank (whitespace-only) ones.
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
// Neutralize a col-0 markdown block trigger with an invisible ZWSP so the
|
||||
// git-sync round-trip keeps the line a paragraph. Host lines (`You:` /
|
||||
// `Speaker N:`) never match and stay byte-exact.
|
||||
.map((line) =>
|
||||
GITMOST_MD_BLOCK_TRIGGER_RE.test(line) ? GITMOST_ZWSP + line : line,
|
||||
);
|
||||
.filter((line) => line.length > 0);
|
||||
if (lines.length === 0) return false;
|
||||
|
||||
const content = [
|
||||
|
||||
@@ -281,10 +281,12 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
|
||||
setOpenTreeNodes((prev) => ({ ...prev, [id]: isOpen }));
|
||||
if (isOpen) {
|
||||
const node = treeModel.find(data, id) as SpaceTreeNode | null;
|
||||
if (
|
||||
node?.hasChildren &&
|
||||
(!node.children || node.children.length === 0)
|
||||
) {
|
||||
// Same "unloaded branch" predicate the realtime insert paths use
|
||||
// (`isUnloadedBranch`) so the lazy-load gate and the realtime inserts
|
||||
// (`insertByPosition` / `placeByPosition`) can never disagree about what
|
||||
// counts as unloaded (#525). Note: local raw `insert` (DnD/create-page)
|
||||
// does not yet route through it — see #525 follow-up.
|
||||
if (treeModel.isUnloadedBranch(node)) {
|
||||
const fetched = await fetchAllAncestorChildren({
|
||||
pageId: id,
|
||||
spaceId: node.spaceId,
|
||||
|
||||
@@ -74,6 +74,48 @@ describe("treeModel.isDescendant", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// #525: the single "is this branch unloaded?" predicate shared by the lazy-load
|
||||
// gate and the insert paths. Unloaded == server says hasChildren but none are
|
||||
// present locally (canonical form `children: []`, also `undefined`). A parent
|
||||
// without hasChildren is genuinely empty, not unloaded.
|
||||
describe("treeModel.isUnloadedBranch", () => {
|
||||
type PH = TreeNode<{ name: string; hasChildren?: boolean }>;
|
||||
it("true for hasChildren + empty array (canonical unloaded form)", () => {
|
||||
const n: PH = { id: "p", name: "P", hasChildren: true, children: [] };
|
||||
expect(treeModel.isUnloadedBranch(n)).toBe(true);
|
||||
});
|
||||
it("true for hasChildren + undefined children", () => {
|
||||
const n: PH = { id: "p", name: "P", hasChildren: true };
|
||||
expect(treeModel.isUnloadedBranch(n)).toBe(true);
|
||||
});
|
||||
it("false for hasChildren + already-loaded children", () => {
|
||||
const n: PH = {
|
||||
id: "p",
|
||||
name: "P",
|
||||
hasChildren: true,
|
||||
children: [{ id: "c", name: "C" }],
|
||||
};
|
||||
expect(treeModel.isUnloadedBranch(n)).toBe(false);
|
||||
});
|
||||
it("false for a genuinely-empty parent (no hasChildren)", () => {
|
||||
expect(
|
||||
treeModel.isUnloadedBranch({
|
||||
id: "p",
|
||||
name: "P",
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
} as PH),
|
||||
).toBe(false);
|
||||
expect(
|
||||
treeModel.isUnloadedBranch({ id: "p", name: "P" } as PH),
|
||||
).toBe(false);
|
||||
});
|
||||
it("false for null/undefined", () => {
|
||||
expect(treeModel.isUnloadedBranch(null)).toBe(false);
|
||||
expect(treeModel.isUnloadedBranch(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("treeModel.visible", () => {
|
||||
it("returns only root nodes when no openIds", () => {
|
||||
const v = treeModel.visible(fixture, new Set());
|
||||
@@ -197,43 +239,64 @@ describe("treeModel.insertByPosition", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// #159 #1: inserting/moving a node under a parent whose children are NOT
|
||||
// loaded (`children === undefined`, e.g. a collapsed page) must NOT materialize
|
||||
// a partial `[node]` list — that would defeat the lazy-load gate and hide the
|
||||
// parent's other real children. The node is left to be lazy-loaded; only
|
||||
// `hasChildren` is flagged so the chevron appears.
|
||||
it("does NOT materialize a child under an UNLOADED parent (children undefined)", () => {
|
||||
type PH = TreeNode<{
|
||||
name: string;
|
||||
position?: string;
|
||||
hasChildren?: boolean;
|
||||
}>;
|
||||
type PH = TreeNode<{
|
||||
name: string;
|
||||
position?: string;
|
||||
hasChildren?: boolean;
|
||||
}>;
|
||||
|
||||
// #159 #1 / #525: inserting/moving a node under an UNLOADED parent must NOT
|
||||
// materialize a partial `[node]` list — that would defeat the lazy-load gate and
|
||||
// hide the parent's other real children. The canonical unloaded form here is
|
||||
// `children: []` + `hasChildren: true` (from `pageToTreeNode` /
|
||||
// `pruneCollapsedChildren`), which the pre-#525 `=== undefined` guard MISSED.
|
||||
// The node is left to be lazy-loaded; the chevron stays enabled.
|
||||
it("does NOT materialize a child under an UNLOADED parent (children: [], hasChildren: true)", () => {
|
||||
const tree: PH[] = [
|
||||
{ id: "p", name: "P", position: "a0", hasChildren: false }, // children: undefined
|
||||
{ id: "p", name: "P", position: "a0", hasChildren: true, children: [] },
|
||||
];
|
||||
const node: PH = { id: "x", name: "X", position: "a1" };
|
||||
const t = treeModel.insertByPosition(tree, "p", node);
|
||||
const parent = treeModel.find(t, "p");
|
||||
// The node was NOT inserted (children stay unloaded -> lazy-load fetches the
|
||||
// full set, including this node, on expand).
|
||||
expect(parent?.children).toBeUndefined();
|
||||
// full set, including this node, on expand). MUTATION: the pre-#525 predicate
|
||||
// `children === undefined` does not fire for `[]`, so it would insert `[x]`
|
||||
// here and reredden this expectation.
|
||||
expect(parent?.children).toEqual([]);
|
||||
expect(treeModel.find(t, "x")).toBeNull();
|
||||
// ...but the chevron is enabled so the user can expand to load it.
|
||||
// ...and the chevron stays enabled so the user can expand to load it.
|
||||
expect((parent as PH).hasChildren).toBe(true);
|
||||
});
|
||||
|
||||
it("DOES insert under a LOADED-but-empty parent (children: [])", () => {
|
||||
type PH = TreeNode<{
|
||||
name: string;
|
||||
position?: string;
|
||||
hasChildren?: boolean;
|
||||
}>;
|
||||
it("does NOT materialize a child under an UNLOADED parent (children undefined, hasChildren: true)", () => {
|
||||
const tree: PH[] = [
|
||||
{ id: "p", name: "P", position: "a0", hasChildren: true }, // children: undefined
|
||||
];
|
||||
const node: PH = { id: "x", name: "X", position: "a1" };
|
||||
const t = treeModel.insertByPosition(tree, "p", node);
|
||||
const parent = treeModel.find(t, "p");
|
||||
expect(parent?.children).toBeUndefined();
|
||||
expect(treeModel.find(t, "x")).toBeNull();
|
||||
expect((parent as PH).hasChildren).toBe(true);
|
||||
});
|
||||
|
||||
it("DOES insert under a genuinely-empty parent (children: [], hasChildren: false)", () => {
|
||||
const tree: PH[] = [
|
||||
{ id: "p", name: "P", position: "a0", hasChildren: false, children: [] },
|
||||
];
|
||||
const node: PH = { id: "x", name: "X", position: "a1" };
|
||||
const t = treeModel.insertByPosition(tree, "p", node);
|
||||
// A loaded (empty) child list is complete, so the node IS inserted.
|
||||
// No server children (`hasChildren: false`), so materializing the first child
|
||||
// is correct — nothing is hidden.
|
||||
expect(treeModel.find(t, "p")?.children?.map((n) => n.id)).toEqual(["x"]);
|
||||
});
|
||||
|
||||
it("DOES insert under a genuinely-empty parent (children undefined, hasChildren: false)", () => {
|
||||
const tree: PH[] = [
|
||||
{ id: "p", name: "P", position: "a0", hasChildren: false }, // children: undefined
|
||||
];
|
||||
const node: PH = { id: "x", name: "X", position: "a1" };
|
||||
const t = treeModel.insertByPosition(tree, "p", node);
|
||||
expect(treeModel.find(t, "p")?.children?.map((n) => n.id)).toEqual(["x"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -43,6 +43,26 @@ export const treeModel = {
|
||||
};
|
||||
},
|
||||
|
||||
// A branch is "unloaded" when the server says it HAS children (`hasChildren`)
|
||||
// but none are present locally. The canonical unloaded form in this codebase
|
||||
// is `children: []` (produced by `pageToTreeNode` and by `pruneCollapsedChildren`
|
||||
// resetting collapsed branches), NOT `children: undefined` — so a predicate that
|
||||
// only checks `=== undefined` misses the real case and materializes a misleading
|
||||
// partial list (#525). This is the SINGLE source of truth for "should a
|
||||
// fetch/materialize be deferred?", shared by the lazy-load gate (`handleToggle`)
|
||||
// and the realtime insert paths (`insertByPosition` / `placeByPosition`), so they
|
||||
// can never drift apart again. (The local raw `insert` primitive and its DnD/
|
||||
// create-page callers do NOT yet route through this predicate — see #525
|
||||
// follow-up.) A parent WITHOUT `hasChildren` is genuinely empty
|
||||
// (no server children) — inserting its first child is correct, not deferred.
|
||||
isUnloadedBranch<T extends object>(
|
||||
node: TreeNode<T> | null | undefined,
|
||||
): boolean {
|
||||
if (!node) return false;
|
||||
const hasChildren = (node as { hasChildren?: boolean }).hasChildren === true;
|
||||
return hasChildren && (node.children == null || node.children.length === 0);
|
||||
},
|
||||
|
||||
isDescendant<T extends object>(
|
||||
tree: TreeNode<T>[],
|
||||
ancestorId: string,
|
||||
@@ -127,14 +147,15 @@ export const treeModel = {
|
||||
}
|
||||
const parent = treeModel.find(tree, parentId);
|
||||
// The parent is in the tree but its children have NOT been lazy-loaded yet
|
||||
// (`children === undefined`, distinct from a loaded-but-empty `[]`). Inserting
|
||||
// (`hasChildren` set + children absent/empty — see `isUnloadedBranch`; the
|
||||
// canonical unloaded form is `children: []`, NOT just `undefined`). Inserting
|
||||
// here would MATERIALIZE a misleading partial child list (`[node]`) that
|
||||
// defeats the lazy-load gate — which fetches only when children are
|
||||
// absent/empty — so the parent's OTHER real children would never load and the
|
||||
// moved/added node would be the only one shown (a silent data loss, #159 #1).
|
||||
// Instead, leave the children unloaded and just flag `hasChildren` so the
|
||||
// chevron appears; expanding fetches the FULL set (including this node).
|
||||
if (parent && parent.children === undefined) {
|
||||
if (parent && treeModel.isUnloadedBranch(parent)) {
|
||||
return treeModel.update(
|
||||
tree,
|
||||
parentId,
|
||||
|
||||
@@ -13,8 +13,7 @@ let currentAlias: IShareAlias | null = null;
|
||||
let availabilityResult: {
|
||||
valid: boolean;
|
||||
available: boolean;
|
||||
currentPageId: string | null;
|
||||
} = { valid: true, available: true, currentPageId: null };
|
||||
} = { valid: true, available: true };
|
||||
|
||||
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||
useShareAliasForPageQuery: () => ({ data: currentAlias }),
|
||||
@@ -56,7 +55,7 @@ describe("ShareAliasSection — taken-name handling is never a dead end", () =>
|
||||
beforeEach(() => {
|
||||
setMutateAsync.mockReset();
|
||||
currentAlias = null;
|
||||
availabilityResult = { valid: true, available: true, currentPageId: null };
|
||||
availabilityResult = { valid: true, available: true };
|
||||
});
|
||||
|
||||
it("shows a 'will move it here' HINT (not a terminal error) when the name belongs to another page, and keeps Save enabled", async () => {
|
||||
@@ -65,7 +64,6 @@ describe("ShareAliasSection — taken-name handling is never a dead end", () =>
|
||||
availabilityResult = {
|
||||
valid: true,
|
||||
available: false,
|
||||
currentPageId: "page-X",
|
||||
};
|
||||
|
||||
renderSection("page-Y");
|
||||
@@ -97,7 +95,6 @@ describe("ShareAliasSection — taken-name handling is never a dead end", () =>
|
||||
availabilityResult = {
|
||||
valid: true,
|
||||
available: false,
|
||||
currentPageId: "page-X",
|
||||
};
|
||||
// The server rejects the un-confirmed save asking the client to confirm.
|
||||
setMutateAsync.mockRejectedValueOnce({
|
||||
@@ -106,7 +103,6 @@ describe("ShareAliasSection — taken-name handling is never a dead end", () =>
|
||||
status: 409,
|
||||
data: {
|
||||
code: "ALIAS_REASSIGN_REQUIRED",
|
||||
currentPageId: "page-X",
|
||||
currentPageTitle: "Alias Test Page X",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -48,7 +48,6 @@ export default function ShareAliasSection({
|
||||
const [availability, setAvailability] = useState<{
|
||||
valid: boolean;
|
||||
available: boolean;
|
||||
currentPageId: string | null;
|
||||
} | null>(null);
|
||||
const [reassign, setReassign] = useState<{
|
||||
alias: string;
|
||||
@@ -76,7 +75,6 @@ export default function ShareAliasSection({
|
||||
setAvailability({
|
||||
valid: res.valid,
|
||||
available: res.available,
|
||||
currentPageId: res.currentPageId,
|
||||
});
|
||||
} catch {
|
||||
setAvailability(null);
|
||||
|
||||
@@ -108,7 +108,6 @@ export interface IShareAliasAvailability {
|
||||
alias: string;
|
||||
valid: boolean;
|
||||
available: boolean;
|
||||
currentPageId: string | null;
|
||||
}
|
||||
|
||||
export interface ISharedPageTree {
|
||||
|
||||
@@ -82,17 +82,19 @@ describe("applyMoveTreeNode", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("does NOT create a partial child list when the destination is loaded-but-collapsed (children unloaded) — keeps it lazy-loadable (#159)", () => {
|
||||
// `dstCollapsed` is in the tree but its children were never lazy-loaded
|
||||
// (children === undefined). The OLD behavior inserted `src` as the ONLY
|
||||
// child ([src]), which defeated the lazy-load gate and HID the parent's
|
||||
// other real children. Now the move leaves children unloaded (so expanding
|
||||
// fetches the FULL set, including src) and just flags hasChildren.
|
||||
it("does NOT create a partial child list when the destination is loaded-but-collapsed (children unloaded) — keeps it lazy-loadable (#159 #525)", () => {
|
||||
// `dstCollapsed` is in the tree but its children were never lazy-loaded. The
|
||||
// CANONICAL unloaded form here is `hasChildren: true` + `children: []` (from
|
||||
// `pageToTreeNode` / `pruneCollapsedChildren`), NOT `children: undefined`.
|
||||
// The pre-#525 predicate (`children === undefined`) missed this form and
|
||||
// inserted `src` as the ONLY child ([src]), defeating the lazy-load gate and
|
||||
// HIDING the parent's other real children. Now the move leaves children
|
||||
// unloaded (so expanding fetches the FULL set, including src).
|
||||
const tree: SpaceTreeNode[] = [
|
||||
node("dstCollapsed", {
|
||||
position: "a0",
|
||||
hasChildren: false,
|
||||
children: undefined as unknown as SpaceTreeNode[],
|
||||
hasChildren: true,
|
||||
children: [],
|
||||
}),
|
||||
node("src", { position: "a9" }),
|
||||
];
|
||||
@@ -105,9 +107,10 @@ describe("applyMoveTreeNode", () => {
|
||||
pageData: {},
|
||||
});
|
||||
const dst = treeModel.find(next, "dstCollapsed");
|
||||
// Children stay unloaded -> the lazy-load gate fetches the FULL set (incl.
|
||||
// src) on expand, rather than showing a misleading partial [src] list.
|
||||
expect(dst?.children).toBeUndefined();
|
||||
// Children stay unloaded ([] not materialized to [src]) -> the lazy-load gate
|
||||
// fetches the FULL set (incl. src) on expand. MUTATION: the pre-#525
|
||||
// `=== undefined` predicate would insert [src] here and redden this.
|
||||
expect(dst?.children).toEqual([]);
|
||||
expect(dst?.hasChildren).toBe(true);
|
||||
// src moved away from its old root slot (it lives under dstCollapsed
|
||||
// server-side and reappears when the parent is expanded/loaded).
|
||||
|
||||
+10
-2
@@ -28,6 +28,7 @@ import {
|
||||
IAiMcpServerCreate,
|
||||
IAiMcpServerUpdate,
|
||||
} from "@/features/workspace/services/ai-mcp-server-service.ts";
|
||||
import { resolveToolAllowlist } from "./ai-mcp-server-form.utils.ts";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
@@ -121,13 +122,20 @@ export default function AiMcpServerForm({
|
||||
async function handleSubmit(values: FormValues) {
|
||||
const headers = resolveHeaders();
|
||||
|
||||
// An empty tag field means "no restriction" (sent as null) — since #476 the
|
||||
// server persists a literal `[]` as deny-all (zero tools). But a server that
|
||||
// was ALREADY deny-all loads into an empty field too; sending null there
|
||||
// would silently widen it to allow-all on a routine edit, so preserve `[]`.
|
||||
// See resolveToolAllowlist for the full rationale.
|
||||
const toolAllowlist = resolveToolAllowlist(values.toolAllowlist, server);
|
||||
|
||||
if (isEdit && server) {
|
||||
const payload: IAiMcpServerUpdate = {
|
||||
id: server.id,
|
||||
name: values.name,
|
||||
transport: values.transport,
|
||||
url: values.url,
|
||||
toolAllowlist: values.toolAllowlist,
|
||||
toolAllowlist,
|
||||
// Always sent: a blank value clears the stored guidance (server -> null).
|
||||
instructions: values.instructions,
|
||||
enabled: values.enabled,
|
||||
@@ -140,7 +148,7 @@ export default function AiMcpServerForm({
|
||||
name: values.name,
|
||||
transport: values.transport,
|
||||
url: values.url,
|
||||
toolAllowlist: values.toolAllowlist,
|
||||
toolAllowlist,
|
||||
// Blank => server stores null (no guidance).
|
||||
instructions: values.instructions,
|
||||
enabled: values.enabled,
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveToolAllowlist } from "./ai-mcp-server-form.utils.ts";
|
||||
|
||||
describe("resolveToolAllowlist", () => {
|
||||
it("sends the typed tools when the field is non-empty", () => {
|
||||
expect(resolveToolAllowlist(["a", "b"], { toolAllowlist: null })).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates as null (unrestricted) when empty and there is no server", () => {
|
||||
expect(resolveToolAllowlist([], undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("sends null for an empty field on a previously-unrestricted server", () => {
|
||||
expect(resolveToolAllowlist([], { toolAllowlist: null })).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves deny-all: an empty field on a `[]` server stays `[]`, not null", () => {
|
||||
// The core #476/#477 guard: editing a deny-all server (rename/toggle) with
|
||||
// an empty tag field must NOT silently widen it to allow-all.
|
||||
expect(resolveToolAllowlist([], { toolAllowlist: [] })).toEqual([]);
|
||||
});
|
||||
|
||||
it("still sends explicit tools even if the server was deny-all", () => {
|
||||
expect(resolveToolAllowlist(["x"], { toolAllowlist: [] })).toEqual(["x"]);
|
||||
});
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { IAiMcpServer } from "@/features/workspace/services/ai-mcp-server-service.ts";
|
||||
|
||||
// Resolve the tool allowlist value to persist from the form field.
|
||||
//
|
||||
// An empty tag field normally means "no restriction" and is sent as null so
|
||||
// the server drops the column (all tools allowed). But a server that was
|
||||
// ALREADY deny-all (a stored literal `[]`, meaning zero tools — creatable via
|
||||
// the API) loads into the form as an empty field too. Coercing that empty
|
||||
// field to null on submit would SILENTLY widen a deny-all server to allow-all
|
||||
// on any routine edit (rename, toggle) — the exact silent-widen class #476
|
||||
// closed on the read side. So when the edited server was deny-all, preserve
|
||||
// `[]` (deny-all); only a genuinely-unrestricted server (stored null/absent)
|
||||
// stays null.
|
||||
export function resolveToolAllowlist(
|
||||
fieldValue: string[],
|
||||
server?: Pick<IAiMcpServer, "toolAllowlist">,
|
||||
): string[] | null {
|
||||
if (fieldValue.length > 0) return fieldValue;
|
||||
const wasDenyAll =
|
||||
Array.isArray(server?.toolAllowlist) && server.toolAllowlist.length === 0;
|
||||
return wasDenyAll ? [] : null;
|
||||
}
|
||||
+124
@@ -6,6 +6,8 @@ import {
|
||||
nextReindexPollInterval,
|
||||
isReindexComplete,
|
||||
isReindexButtonLoading,
|
||||
reindexRunKey,
|
||||
isNewReindexRun,
|
||||
} from './ai-provider-settings';
|
||||
|
||||
describe('resolveCardStatus', () => {
|
||||
@@ -221,6 +223,128 @@ describe('isReindexComplete', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('reindexRunKey', () => {
|
||||
it('is null when the status carries no run identity', () => {
|
||||
expect(reindexRunKey(undefined)).toBeNull();
|
||||
expect(
|
||||
reindexRunKey({ reindexing: false, indexedPages: 5, totalPages: 5 }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('is null for a legacy/degraded record with an empty runId', () => {
|
||||
// The server sends runId='' for a record written before the field existed;
|
||||
// the client must treat that as "no identity" (fall back to prior behaviour).
|
||||
expect(
|
||||
reindexRunKey({
|
||||
reindexing: true,
|
||||
indexedPages: 0,
|
||||
totalPages: 10,
|
||||
runId: '',
|
||||
reindexStartedAt: 1000,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('folds runId and startedAt into one stable key', () => {
|
||||
expect(
|
||||
reindexRunKey({
|
||||
reindexing: true,
|
||||
indexedPages: 0,
|
||||
totalPages: 10,
|
||||
runId: 'run-a',
|
||||
reindexStartedAt: 1000,
|
||||
}),
|
||||
).toBe('run-a:1000');
|
||||
});
|
||||
|
||||
it('changes when the runId changes for the same startedAt', () => {
|
||||
const a = reindexRunKey({
|
||||
reindexing: true,
|
||||
indexedPages: 0,
|
||||
totalPages: 10,
|
||||
runId: 'run-a',
|
||||
reindexStartedAt: 1000,
|
||||
});
|
||||
const b = reindexRunKey({
|
||||
reindexing: true,
|
||||
indexedPages: 0,
|
||||
totalPages: 10,
|
||||
runId: 'run-b',
|
||||
reindexStartedAt: 1000,
|
||||
});
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('changes when the same runId restarts at a new startedAt', () => {
|
||||
const a = reindexRunKey({
|
||||
reindexing: true,
|
||||
indexedPages: 0,
|
||||
totalPages: 10,
|
||||
runId: 'run-a',
|
||||
reindexStartedAt: 1000,
|
||||
});
|
||||
const b = reindexRunKey({
|
||||
reindexing: true,
|
||||
indexedPages: 0,
|
||||
totalPages: 10,
|
||||
runId: 'run-a',
|
||||
reindexStartedAt: 2000,
|
||||
});
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNewReindexRun (poll keying on runId)', () => {
|
||||
// Derive the status shape from the helper itself so the test needs no export
|
||||
// of the component-internal ReindexStatus type.
|
||||
type ReindexStatusLike = NonNullable<Parameters<typeof reindexRunKey>[0]>;
|
||||
const run = (runId: string, startedAt: number): ReindexStatusLike => ({
|
||||
reindexing: true,
|
||||
indexedPages: 0,
|
||||
totalPages: 10,
|
||||
runId,
|
||||
reindexStartedAt: startedAt,
|
||||
});
|
||||
|
||||
it('first identity after none latched is a NEW run', () => {
|
||||
expect(isNewReindexRun(null, run('run-a', 1000))).toBe(true);
|
||||
});
|
||||
|
||||
it('the SAME identity is not a new run (same run being watched)', () => {
|
||||
const key = reindexRunKey(run('run-a', 1000));
|
||||
expect(isNewReindexRun(key, run('run-a', 1000))).toBe(false);
|
||||
});
|
||||
|
||||
it('a DIFFERENT runId is a new run (reset per-run poll state)', () => {
|
||||
const key = reindexRunKey(run('run-a', 1000));
|
||||
expect(isNewReindexRun(key, run('run-b', 1000))).toBe(true);
|
||||
});
|
||||
|
||||
it('an identity-less poll (no runId / cleared record) is never a new run', () => {
|
||||
const key = reindexRunKey(run('run-a', 1000));
|
||||
expect(
|
||||
isNewReindexRun(key, {
|
||||
reindexing: false,
|
||||
indexedPages: 10,
|
||||
totalPages: 10,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('a legacy empty-runId poll does not spuriously reset a latched run', () => {
|
||||
const key = reindexRunKey(run('run-a', 1000));
|
||||
expect(
|
||||
isNewReindexRun(key, {
|
||||
reindexing: true,
|
||||
indexedPages: 3,
|
||||
totalPages: 10,
|
||||
runId: '',
|
||||
reindexStartedAt: 1000,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isReindexButtonLoading', () => {
|
||||
it('loads while the POST mutation is pending', () => {
|
||||
expect(
|
||||
|
||||
+54
-1
@@ -173,9 +173,43 @@ export function resolveKeyField(
|
||||
// Subset of the status payload that drives the reindex poll decisions.
|
||||
type ReindexStatus = Pick<
|
||||
IAiSettings,
|
||||
"reindexing" | "indexedPages" | "totalPages"
|
||||
"reindexing" | "indexedPages" | "totalPages" | "runId" | "reindexStartedAt"
|
||||
>;
|
||||
|
||||
/**
|
||||
* A stable per-RUN key for the reindex poll: `runId:startedAt`, or `null` when
|
||||
* the status carries no run identity (no active run, or a legacy/degraded
|
||||
* server record with an empty runId). Two polls of the SAME run share a key; a
|
||||
* new run mints a fresh runId and so a different key.
|
||||
*
|
||||
* This is the single place the client turns the server's run identity into the
|
||||
* value it keys on — it removes the "is this the same run I've been watching or
|
||||
* a brand-new one?" ambiguity that made a class of reindex-status bugs (a stale
|
||||
* pre-reindex snapshot vs a fresh run) get fixed twice (#262). `startedAt` is
|
||||
* folded in so a run that somehow reuses a runId but restarted is still new.
|
||||
*/
|
||||
export function reindexRunKey(status: ReindexStatus | undefined): string | null {
|
||||
const runId = status?.runId;
|
||||
if (!runId) return null;
|
||||
return `${runId}:${status?.reindexStartedAt ?? ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the latest poll represents a NEW reindex run relative to the
|
||||
* run key the client last latched (`prevKey`, `null` if none yet). True only
|
||||
* when the status carries an identity AND it differs from the latched one — the
|
||||
* signal to reset any per-run poll state (the "seen active" latch / progress the
|
||||
* UI held). The same identity (or no identity) is NOT a new run, so an unchanged
|
||||
* or identity-less poll never resets mid-run.
|
||||
*/
|
||||
export function isNewReindexRun(
|
||||
prevKey: string | null,
|
||||
status: ReindexStatus | undefined,
|
||||
): boolean {
|
||||
const key = reindexRunKey(status);
|
||||
return key !== null && key !== prevKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide the TanStack Query `refetchInterval` while a reindex may be running.
|
||||
* Returns the poll interval (ms) to keep polling, or `false` to stop.
|
||||
@@ -320,6 +354,13 @@ export default function AiProviderSettings() {
|
||||
// counter at 0 until a manual reload. A ref (not state) because it must not
|
||||
// trigger a render and is only ever read where `reindexing` is already false.
|
||||
const reindexSeenActiveRef = useRef(false);
|
||||
// The run identity (runId:startedAt) the current poll window is keyed on. When
|
||||
// a poll reports a DIFFERENT runId the server has started a NEW run, so we
|
||||
// re-latch to it and reset `reindexSeenActiveRef` — a fresh run must never
|
||||
// inherit the previous run's "seen active"/completion state (which would stop
|
||||
// polling immediately or read the old run's counters as this run's). null =
|
||||
// no run keyed yet (steady state, or a legacy record without a runId).
|
||||
const reindexRunKeyRef = useRef<string | null>(null);
|
||||
|
||||
// Only admins may read the (masked) AI settings; the server enforces this too.
|
||||
const { data: settings, isLoading } = useAiSettingsQuery(isAdmin, (query) =>
|
||||
@@ -336,6 +377,14 @@ export default function AiProviderSettings() {
|
||||
// unmount because the deadline state goes away with the component.
|
||||
useEffect(() => {
|
||||
if (reindexDeadline === null) return;
|
||||
// Key the poll on the run identity: if this poll carries a runId different
|
||||
// from the one we latched, the server started a NEW run, so adopt it and
|
||||
// drop the per-run "seen active" latch (a fresh run must not inherit the
|
||||
// previous run's completion state). Same runId => same run, leave it alone.
|
||||
if (isNewReindexRun(reindexRunKeyRef.current, settings)) {
|
||||
reindexRunKeyRef.current = reindexRunKey(settings);
|
||||
reindexSeenActiveRef.current = false;
|
||||
}
|
||||
// Latch "we have seen the active run" the moment a poll reports it, so the
|
||||
// completion check below (and the refetchInterval's) only fires once the run
|
||||
// has genuinely started — never on the stale pre-reindex snapshot.
|
||||
@@ -1220,6 +1269,10 @@ export default function AiProviderSettings() {
|
||||
// immediately.
|
||||
onSuccess: () => {
|
||||
reindexSeenActiveRef.current = false;
|
||||
// Forget the previous run's identity so the first poll of
|
||||
// this window (carrying the new run's runId) is recognized
|
||||
// as a new run and keyed afresh.
|
||||
reindexRunKeyRef.current = null;
|
||||
setReindexDeadline(Date.now() + REINDEX_POLL_CAP_MS);
|
||||
},
|
||||
})
|
||||
|
||||
@@ -27,7 +27,9 @@ export interface IAiMcpServerCreate {
|
||||
// Auth headers map (e.g. { Authorization: 'Bearer ...' }). Encrypted on save;
|
||||
// never returned.
|
||||
headers?: Record<string, string>;
|
||||
toolAllowlist?: string[];
|
||||
// Omit/null => no restriction; `[]` is persisted verbatim and means
|
||||
// deny-all (zero tools) since #476.
|
||||
toolAllowlist?: string[] | null;
|
||||
// Admin-authored prompt guidance (#180). Blank => stored as null.
|
||||
instructions?: string;
|
||||
enabled?: boolean;
|
||||
@@ -43,7 +45,9 @@ export interface IAiMcpServerUpdate {
|
||||
transport?: McpTransport;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
toolAllowlist?: string[];
|
||||
// Absent => unchanged; null => no restriction; `[]` is persisted verbatim
|
||||
// and means deny-all (zero tools) since #476.
|
||||
toolAllowlist?: string[] | null;
|
||||
// Admin-authored prompt guidance (#180). Absent => unchanged; blank => cleared.
|
||||
instructions?: string;
|
||||
enabled?: boolean;
|
||||
|
||||
@@ -51,6 +51,14 @@ export interface IAiSettings {
|
||||
// True while a full workspace reindex is actively running; the counts above
|
||||
// then reflect the live run progress (done climbs 0 -> total).
|
||||
reindexing?: boolean;
|
||||
// Identity of the ACTIVE reindex run (present only while `reindexing`). The
|
||||
// poll keys on `runId`: a changed value means a NEW run (reset the per-run
|
||||
// poll state the UI latched), the same value is the run already being watched.
|
||||
// Absent/empty ('') => no identity available; the client keeps prior behaviour.
|
||||
runId?: string;
|
||||
// Epoch-ms the active run started; paired with `runId` so a restart with a
|
||||
// recycled id is still detected as a new run.
|
||||
reindexStartedAt?: number;
|
||||
}
|
||||
|
||||
// Update payload. Key semantics (same for `apiKey` and `embeddingApiKey`):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { templateRoute } from "./route-template";
|
||||
import { templateRoute, KNOWN_ROUTE_TEMPLATES } from "./route-template";
|
||||
|
||||
describe("templateRoute", () => {
|
||||
it("templates a space page path (never leaks slugs)", () => {
|
||||
@@ -32,4 +32,30 @@ describe("templateRoute", () => {
|
||||
expect(templateRoute("/weird/unknown/thing")).toBe("other");
|
||||
expect(templateRoute("/s/team/p/slug/extra/segments")).toBe("other");
|
||||
});
|
||||
|
||||
// The server's /api/telemetry/vitals mirror (ALLOWED_ROUTE_TEMPLATES) drops any
|
||||
// route outside KNOWN_ROUTE_TEMPLATES, so templateRoute must NEVER emit a label
|
||||
// that is not in that dictionary — otherwise legit client metrics get dropped.
|
||||
it("only ever emits labels contained in KNOWN_ROUTE_TEMPLATES (#495)", () => {
|
||||
const samples = [
|
||||
"/",
|
||||
"/home",
|
||||
"/settings/members",
|
||||
"/settings/groups/g-1",
|
||||
"/s/team",
|
||||
"/s/team/trash",
|
||||
"/s/team/p/slug",
|
||||
"/p/slug",
|
||||
"/share/abc",
|
||||
"/share/abc/p/slug",
|
||||
"/share/p/slug",
|
||||
"/labels/urgent",
|
||||
"/invites/inv-1",
|
||||
"/weird/unknown/thing", // -> "other"
|
||||
"/deep/unmatched/x/y/z", // -> "other"
|
||||
];
|
||||
for (const path of samples) {
|
||||
expect(KNOWN_ROUTE_TEMPLATES.has(templateRoute(path))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,22 @@ const STATIC_ROUTES = new Set<string>([
|
||||
'/settings/sharing',
|
||||
]);
|
||||
|
||||
/**
|
||||
* The COMPLETE, finite vocabulary `templateRoute` can ever emit: the two
|
||||
* synthetic labels (`/` and `other`), the static routes, and the dynamic
|
||||
* templates. Exported so the public `/api/telemetry/vitals` endpoint can reject
|
||||
* any `route` outside this dictionary server-side (the endpoint is anonymous, so
|
||||
* an un-checked `route` is a free-text write surface). The server keeps a mirror
|
||||
* (`ALLOWED_ROUTE_TEMPLATES` in client-metrics.constants.ts) — this is the
|
||||
* canonical source; keep them in lockstep.
|
||||
*/
|
||||
export const KNOWN_ROUTE_TEMPLATES: ReadonlySet<string> = new Set<string>([
|
||||
'/',
|
||||
'other',
|
||||
...STATIC_ROUTES,
|
||||
...ROUTE_PATTERNS.map((p) => p.template),
|
||||
]);
|
||||
|
||||
export function templateRoute(pathname: string): string {
|
||||
// Normalise a trailing slash (except root).
|
||||
const path =
|
||||
|
||||
@@ -3,6 +3,7 @@ import "@mantine/spotlight/styles.css";
|
||||
import "@mantine/notifications/styles.css";
|
||||
import '@mantine/dates/styles.css';
|
||||
import "@/styles/a11y-overrides.css";
|
||||
import "@/styles/notification-overrides.css";
|
||||
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
@@ -47,7 +48,15 @@ function renderApp() {
|
||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||
<ModalsProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
||||
{/* top-center: toasts sit in the top of the viewport, in the line
|
||||
of sight, and no longer cover centered content (e.g. "Load
|
||||
more"). The below-chrome vertical offset is applied via a
|
||||
position-scoped CSS rule in notification-overrides.css (NOT an
|
||||
inline `style`): Mantine renders all six position containers at
|
||||
once and an inline root style would land on every one, giving the
|
||||
bottom-* containers both top+bottom → full-viewport transparent
|
||||
overlays that swallow clicks. */}
|
||||
<Notifications position="top-center" limit={3} zIndex={10000} />
|
||||
<HelmetProvider>
|
||||
{/* Root boundary above every lazy route's Suspense: a stale-chunk
|
||||
404 after a deploy is caught and recovered here instead of
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Toast (Mantine Notification) visibility overrides.
|
||||
* Mantine renders colorless toasts on --mantine-color-body (== the page
|
||||
* background: white in light mode) with a faint shadow, so on white pages the
|
||||
* card has no visible edge. These rules give every toast a type-tinted
|
||||
* background, a WCAG-checked border and a stronger shadow so it separates from
|
||||
* the page. The [data-mantine-color-scheme] + static-class selector (0,2,0)
|
||||
* beats Mantine's own (0,1,0) rules regardless of stylesheet order (Mantine's
|
||||
* bg/border rules wrap the scheme attribute in :where(), so they stay (0,1,0)).
|
||||
* --notification-color is defined on the same element (defaults to primary,
|
||||
* set per `color` prop), so tint/border follow the toast type. This also covers
|
||||
* the loading/import toast (no accent bar, since the spinner takes the icon
|
||||
* slot): its visibility comes from tone + border + shadow + the colored spinner.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Push the top-anchored toast containers below the top chrome (fixed 45px
|
||||
* header + optional 45px format toolbar + ~6px gap) so a toast (z-index 10000)
|
||||
* neither covers nor intercepts clicks on the header/toolbar (both z-index 99).
|
||||
*
|
||||
* Scoped to [data-position^='top'] on purpose. Mantine renders ALL SIX position
|
||||
* containers simultaneously (`position` only routes toasts into one via the
|
||||
* store); the root `style` prop would be applied to every one of them by
|
||||
* getStyles("root"). A blanket `top` would land on the bottom-* containers too
|
||||
* (which carry `bottom:16px`) → position:fixed + both edges + height:auto makes
|
||||
* them stretch the full viewport height, and the container root has neither
|
||||
* pointer-events:none nor a background, so those transparent z-10000 overlays
|
||||
* would swallow clicks across the whole page. Restricting to top-* leaves the
|
||||
* bottom containers at height:0.
|
||||
*
|
||||
* Specificity: `.mantine-Notifications-root[data-position^='top']` is (0,2,0)
|
||||
* (class + attribute) and beats Mantine's own top rule
|
||||
* `.m_b37d9ac7:where([data-position='top-center']){top:16px}` which is (0,1,0)
|
||||
* (the :where() contributes 0), regardless of stylesheet order.
|
||||
*/
|
||||
.mantine-Notifications-root[data-position^='top'] {
|
||||
top: 96px;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme='light'] .mantine-Notification-root {
|
||||
/* ~10% type color over white: clearly off-white, text contrast preserved */
|
||||
background-color: color-mix(in srgb, var(--notification-color) 10%, var(--mantine-color-white));
|
||||
/* Border must clear WCAG 3:1 non-text contrast on white. The repo rejects
|
||||
gray-4 for this (a11y-overrides.css); gray-6 base (~3.32:1) darkened by the
|
||||
type color stays >= 3:1. */
|
||||
border: 1px solid color-mix(in srgb, var(--notification-color) 45%, var(--mantine-color-gray-6));
|
||||
box-shadow: var(--mantine-shadow-xl);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme='dark'] .mantine-Notification-root {
|
||||
/* Dark page (dark-7/8) vs toast (dark-6) already separate a little; border +
|
||||
shadow carry the type cue here (a 7% dark tint was near-invisible). */
|
||||
background-color: color-mix(in srgb, var(--notification-color) 14%, var(--mantine-color-dark-6));
|
||||
border: 1px solid color-mix(in srgb, var(--notification-color) 45%, var(--mantine-color-dark-3));
|
||||
box-shadow: var(--mantine-shadow-xl);
|
||||
}
|
||||
|
||||
/* Mantine's message-with-title color is gray-6 (#868e96, already only ~3.32:1
|
||||
on white — below AA 4.5:1); the new tint pushes it lower. Bump to gray-7 to
|
||||
keep multi-line colored toasts readable, consistent with the repo's existing
|
||||
WCAG tuning (theme.ts already bumps this same gray-6 up elsewhere). */
|
||||
[data-mantine-color-scheme='light'] .mantine-Notification-description[data-with-title] {
|
||||
color: var(--mantine-color-gray-7);
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
|
||||
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/prosemirror-markdown build",
|
||||
"pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/prosemirror-markdown build && pnpm --filter @docmost/token-estimate build",
|
||||
"test": "jest",
|
||||
"test:int": "jest --config test/jest-integration.json",
|
||||
"test:watch": "jest --watch",
|
||||
@@ -44,6 +44,7 @@
|
||||
"@docmost/mcp": "workspace:*",
|
||||
"@docmost/pdf-inspector": "1.9.6",
|
||||
"@docmost/prosemirror-markdown": "workspace:*",
|
||||
"@docmost/token-estimate": "workspace:*",
|
||||
"@fastify/compress": "^9.0.0",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
@@ -206,6 +207,7 @@
|
||||
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
||||
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
||||
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
|
||||
"^@docmost/token-estimate$": "<rootDir>/../../../packages/token-estimate/src/index.ts",
|
||||
"^src/(.*)$": "<rootDir>/$1",
|
||||
"^@tiptap/react$": "<rootDir>/../test/stubs/tiptap-react.js"
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { TransclusionService } from '../core/page/transclusion/transclusion.serv
|
||||
import { TransclusionModule } from '../core/page/transclusion/transclusion.module';
|
||||
import { StorageModule } from '../integrations/storage/storage.module';
|
||||
import { EnvironmentModule } from '../integrations/environment/environment.module';
|
||||
import { ApiKeyModule } from '../core/api-key/api-key.module';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
@@ -31,6 +32,7 @@ import { EnvironmentModule } from '../integrations/environment/environment.modul
|
||||
exports: [CollaborationGateway],
|
||||
imports: [
|
||||
TokenModule,
|
||||
ApiKeyModule,
|
||||
WatcherModule,
|
||||
StorageModule.forRootAsync({
|
||||
imports: [EnvironmentModule],
|
||||
|
||||
@@ -52,6 +52,7 @@ describe('AuthenticationExtension.onAuthenticate', () => {
|
||||
let pageRepo: { findById: jest.Mock };
|
||||
let spaceMemberRepo: { getUserSpaceRoles: jest.Mock };
|
||||
let pagePermissionRepo: { canUserEditPage: jest.Mock };
|
||||
let apiKeyService: { validate: jest.Mock };
|
||||
|
||||
// Build the hocuspocus onAuthenticate payload. connectionConfig.readOnly
|
||||
// starts false; the extension flips it to true on a read-only downgrade.
|
||||
@@ -79,12 +80,15 @@ describe('AuthenticationExtension.onAuthenticate', () => {
|
||||
}),
|
||||
};
|
||||
|
||||
apiKeyService = { validate: jest.fn().mockResolvedValue({ user: {}, workspace: {} }) };
|
||||
|
||||
ext = new AuthenticationExtension(
|
||||
tokenService as any,
|
||||
userRepo as any,
|
||||
pageRepo as any,
|
||||
spaceMemberRepo as any,
|
||||
pagePermissionRepo as any,
|
||||
apiKeyService as any,
|
||||
);
|
||||
// Silence the extension's logger (it warns/debugs on denial branches).
|
||||
jest.spyOn(ext['logger'], 'warn').mockImplementation(() => undefined);
|
||||
@@ -231,4 +235,73 @@ describe('AuthenticationExtension.onAuthenticate', () => {
|
||||
// No internal ai_chats row for an MCP/service-account collab edit → null.
|
||||
expect(ctx.aiChatId).toBeNull();
|
||||
});
|
||||
|
||||
// --- #501: api-key laundering guard (fail-closed discriminator) ----------
|
||||
describe('api-key laundering guard', () => {
|
||||
it('api_key principal → row-checks the key on connect (valid key proceeds)', async () => {
|
||||
tokenService.verifyJwt.mockResolvedValue(
|
||||
buildJwt({ principal: 'api_key', apiKeyId: 'key-1' }),
|
||||
);
|
||||
const data = buildData();
|
||||
await ext.onAuthenticate(data as any);
|
||||
|
||||
expect(apiKeyService.validate).toHaveBeenCalledTimes(1);
|
||||
expect(apiKeyService.validate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ apiKeyId: 'key-1', type: JwtType.API_KEY }),
|
||||
);
|
||||
});
|
||||
|
||||
it('REVOKED api_key → Unauthorized on connect, BEFORE any page/user lookup', async () => {
|
||||
tokenService.verifyJwt.mockResolvedValue(
|
||||
buildJwt({ principal: 'api_key', apiKeyId: 'key-1' }),
|
||||
);
|
||||
// The shared validator denies a revoked key.
|
||||
apiKeyService.validate.mockRejectedValue(new UnauthorizedException());
|
||||
|
||||
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
// No new collab connection: the key check gates before page access.
|
||||
expect(pageRepo.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('api_key principal missing apiKeyId → Unauthorized (malformed)', async () => {
|
||||
tokenService.verifyJwt.mockResolvedValue(buildJwt({ principal: 'api_key' }));
|
||||
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(apiKeyService.validate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('session principal → NO api-key check (session-backed, incl. internal agent)', async () => {
|
||||
tokenService.verifyJwt.mockResolvedValue(buildJwt({ principal: 'session' }));
|
||||
await ext.onAuthenticate(buildData() as any);
|
||||
expect(apiKeyService.validate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('claimless token WITHIN the grace window → trusted (legacy pre-rollout)', async () => {
|
||||
// Default rolloutAt = now, so we are inside the grace window.
|
||||
tokenService.verifyJwt.mockResolvedValue(buildJwt()); // no principal
|
||||
await expect(ext.onAuthenticate(buildData() as any)).resolves.toBeDefined();
|
||||
expect(apiKeyService.validate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('claimless token AFTER the grace window → Unauthorized (fail-closed)', async () => {
|
||||
// Move the rollout reference far into the past so the grace has elapsed.
|
||||
(ext as any).rolloutAt = Date.now() - 25 * 60 * 60 * 1000;
|
||||
tokenService.verifyJwt.mockResolvedValue(buildJwt()); // no principal
|
||||
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('infra error from the api-key row-check propagates (not masked)', async () => {
|
||||
tokenService.verifyJwt.mockResolvedValue(
|
||||
buildJwt({ principal: 'api_key', apiKeyId: 'key-1' }),
|
||||
);
|
||||
const boom = new Error('db down');
|
||||
apiKeyService.validate.mockRejectedValue(boom);
|
||||
await expect(ext.onAuthenticate(buildData() as any)).rejects.toBe(boom);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,20 +14,37 @@ import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
|
||||
import { SpaceRole } from '../../common/helpers/types/permission';
|
||||
import { isUserDisabled } from '../../common/helpers';
|
||||
import { getPageId } from '../collaboration.util';
|
||||
import { JwtCollabPayload, JwtType } from '../../core/auth/dto/jwt-payload';
|
||||
import {
|
||||
JwtApiKeyPayload,
|
||||
JwtCollabPayload,
|
||||
JwtType,
|
||||
} from '../../core/auth/dto/jwt-payload';
|
||||
import { resolveProvenance } from '../../common/decorators/auth-provenance.decorator';
|
||||
import { observeCollabAuth } from '../../integrations/metrics/metrics.registry';
|
||||
import { ApiKeyService } from '../../core/api-key/api-key.service';
|
||||
|
||||
// Max lifetime of a collab token (generateCollabToken uses expiresIn '24h'). Used
|
||||
// as the rollout grace window below: once this long has elapsed since this
|
||||
// process started serving the #501 code, every STILL-VALID collab token was
|
||||
// necessarily minted post-rollout and MUST carry the `principal` discriminator,
|
||||
// so a claimless one is a bug and is rejected (fail-closed) rather than trusted.
|
||||
const COLLAB_TOKEN_GRACE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class AuthenticationExtension implements Extension {
|
||||
private readonly logger = new Logger(AuthenticationExtension.name);
|
||||
|
||||
// Reference instant for the claimless-rejection grace window. Overridable so a
|
||||
// unit test can drive the pre-/post-grace boundary without wall-clock waits.
|
||||
protected rolloutAt = Date.now();
|
||||
|
||||
constructor(
|
||||
private tokenService: TokenService,
|
||||
private userRepo: UserRepo,
|
||||
private pageRepo: PageRepo,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly apiKeyService: ApiKeyService,
|
||||
) {}
|
||||
|
||||
async onAuthenticate(data: onAuthenticatePayload) {
|
||||
@@ -54,6 +71,36 @@ export class AuthenticationExtension implements Extension {
|
||||
throw new UnauthorizedException('Invalid collab token');
|
||||
}
|
||||
|
||||
// #501 — fail-closed api-key laundering guard. A collab token minted by an
|
||||
// api-key principal carries principal='api_key' + apiKeyId; re-check the key
|
||||
// on connect so a REVOKED key gets NO new collab connections (a collab token
|
||||
// outlives its 24h, but a revoked key can no longer open fresh ones). An
|
||||
// api-key token missing its apiKeyId is malformed → reject. A claimless token
|
||||
// (no recognized principal) is trusted only DURING the rollout grace window
|
||||
// (a legacy pre-rollout session token, which api keys could never mint);
|
||||
// once the grace has elapsed every valid token must carry the discriminator,
|
||||
// so a claimless one is a bug and is rejected (not silently trusted for 24h).
|
||||
const principal = jwtPayload.principal;
|
||||
if (principal === 'api_key') {
|
||||
if (!jwtPayload.apiKeyId) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
// Row-check via the SHARED validator: throws Unauthorized on a revoked/
|
||||
// expired/disabled key; an infra error propagates (not masked). No new
|
||||
// connection for a dead key.
|
||||
await this.apiKeyService.validate({
|
||||
sub: jwtPayload.sub,
|
||||
workspaceId: jwtPayload.workspaceId,
|
||||
apiKeyId: jwtPayload.apiKeyId,
|
||||
type: JwtType.API_KEY,
|
||||
} as JwtApiKeyPayload);
|
||||
} else if (principal !== 'session') {
|
||||
// Unrecognized/absent discriminator: reject once past the grace window.
|
||||
if (Date.now() - this.rolloutAt >= COLLAB_TOKEN_GRACE_MS) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
}
|
||||
|
||||
const userId = jwtPayload.sub;
|
||||
const workspaceId = jwtPayload.workspaceId;
|
||||
|
||||
|
||||
@@ -1,42 +1,122 @@
|
||||
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* In-memory run-stream registry (#184 phase 1.5). A durable agent run tees its
|
||||
* SSE frames here (via `pipeUIMessageStreamToResponse({ consumeSseStream })`)
|
||||
* so a LATE tab — one that reloaded, or opened after the starter dropped — can
|
||||
* attach through `GET /ai-chat/runs/:chatId/stream`, replay the frames buffered
|
||||
* so far, and then follow the live tail as a normal streamer.
|
||||
* In-memory run-stream registry (#184 phase 1.5, step-aligned retention #491). A
|
||||
* durable agent run tees its SSE frames here (via
|
||||
* `pipeUIMessageStreamToResponse({ consumeSseStream })`) so a LATE tab — one that
|
||||
* reloaded, or opened after the starter dropped — can attach through
|
||||
* `GET /ai-chat/runs/:chatId/stream`, be handed the TAIL past the step it already
|
||||
* has persisted, and then follow the live tail as a normal streamer.
|
||||
*
|
||||
* This is deliberately single-process and best-effort: it holds nothing the DB
|
||||
* does not (the run + assistant row are the source of truth), so a process
|
||||
* restart simply drops in-flight entries and the client falls back to its
|
||||
* restore + degraded-poll path. The async `attach` return type is the seam for a
|
||||
* future phase-2 cross-process backend (Redis) — the interface does not change.
|
||||
*
|
||||
* ── #491 step-aligned retention (the OOM fix) ────────────────────────────────
|
||||
* The old registry buffered up to 32MB of raw SSE frames PER active run (V8 ~2×
|
||||
* in memory) and, on attach, blasted the WHOLE buffer to the socket synchronously
|
||||
* with no drain — a handful of marathon runs on a 1GB container OOM'd. #491 caps
|
||||
* the ring at a few MB (env-tunable, default 4MB) and keeps it there by ROTATING:
|
||||
*
|
||||
* - Every buffered frame is STAMPED with a step number at tee (see ingestFrame).
|
||||
* Convention: the stamp of a frame is the number of `finish-step` parts seen
|
||||
* BEFORE it (starting at 0). The finish-step frame itself carries the current
|
||||
* value, THEN the counter increments. So a frame stamped `s` is the content of
|
||||
* the (s+1)-th step — 0-based step index `s` — and the stamp aligns EXACTLY
|
||||
* with `metadata.stepsPersisted`: a client whose persisted `stepsPersisted` is
|
||||
* N has steps 0..N-1 on disk (and in its seed) and needs the tail `stamp >= N`.
|
||||
*
|
||||
* - The ring rotates ONLY on a CONFIRMED persist of step N
|
||||
* (`confirmPersistedStep`), dropping frames with `stamp < N` (those steps are
|
||||
* now on disk and a fresh client seed carries them). A NON-confirmed step is
|
||||
* never rotated away, so a persist FAILURE just makes the ring cover MORE
|
||||
* (auto-safe). This is the anti-inversion rule: a naive "rotate in .then()"
|
||||
* that rotated after an UNwritten step would drop a step nobody has → silent
|
||||
* hole. Rotation is gated on a real, successful persist.
|
||||
*
|
||||
* - If the ring still exceeds its byte cap after rotation (a single fat step, or
|
||||
* a lagging persist), the OLDEST frames are evicted to stay bounded. Evicting a
|
||||
* not-yet-persisted frame opens a GAP: an attach whose N falls at or below an
|
||||
* evicted step answers 204 and the client degrades to restore+poll. The gap is
|
||||
* NOT sticky — the coverage floor is recomputed from the ring, so a later
|
||||
* persist that rotates past the holey steps clears it.
|
||||
*
|
||||
* ── attach numbering / coverage (the wire convention) ────────────────────────
|
||||
* The step marker N comes ONLY FROM THE CLIENT (a query param). The server never
|
||||
* reads the row to derive N — a server-side N from a stale seed would open a
|
||||
* silent one-step hole. N is the client's persisted `stepsPersisted` (a COUNT):
|
||||
* - the tail it needs = frames with `stamp >= N`;
|
||||
* - coverage is OK ⟺ `coverageFloor(entry) <= N`, where coverageFloor is the
|
||||
* smallest step FULLY present in the ring (its smallest retained stamp, bumped
|
||||
* by one when that leading step was only partially evicted by overflow). If
|
||||
* `coverageFloor > N` the ring starts AFTER the client's frontier (a hole, or
|
||||
* the client's seed simply lagged behind a rotation) → 204 → the client
|
||||
* refetches (a larger N) and re-attaches.
|
||||
* The N cutoff is applied in ALL branches, INCLUDING the finished-retained replay.
|
||||
*
|
||||
* ── same-tick invariants (unchanged, still load-bearing) ─────────────────────
|
||||
* invariant 1: only the matching run may mutate/observe an entry (runId check).
|
||||
* invariant 2: retention deletes ONLY its own entry (a replacement may own the key).
|
||||
* invariant 3: open() over a live entry mirrors the done-path (subscribers released).
|
||||
* invariant 4: the tail SLICE + subscriber registration happen in ONE synchronous
|
||||
* tick inside attach() — no await between them — so a concurrently
|
||||
* ingested frame is EITHER in the snapshot (buffered before the sync
|
||||
* block, and the just-added subscriber never sees it) OR fanned out to
|
||||
* the paused subscriber's `pending` (ingested after) — never both and
|
||||
* never neither: no loss, no duplication. NOTE (#491): the controller
|
||||
* now AWAITS the drain-respecting tail write BEFORE calling start(), so
|
||||
* frames ingested during that await accumulate in `pending`; this is
|
||||
* bounded by the subscriber cap (an overflow degrades start() to an
|
||||
* end(), a 204-equivalent). It is the SYNCHRONOUS snapshot+registration
|
||||
* — not a same-tick start() — that makes this correct.
|
||||
* invariant 5: the controller wires close-cleanup BEFORE any write.
|
||||
* invariant 6: no cross-run replay — the `anchor` (the client's assistant row id)
|
||||
* must match this run's assistant id, or a foreign run's transcript
|
||||
* would be appended to the client's message.
|
||||
*/
|
||||
|
||||
/** How long a finished entry is retained for late attach (replay + immediate end). */
|
||||
export const RUN_STREAM_RETAIN_FINISHED_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Per-run replay buffer cap. Past this the buffer is dropped (attach -> 204, and
|
||||
* the client falls back to its restore + degraded-poll path, #430).
|
||||
*
|
||||
* Raised from 4MB to 32MB (#430): marathon autonomous runs (11-25 min observed)
|
||||
* stream far more than 4MB of SSE frames, so a live disconnect mid-run would find
|
||||
* an already-overflowed buffer and could only degrade-poll instead of re-attaching
|
||||
* to the live tail. 32MB comfortably covers those runs while staying bounded.
|
||||
*
|
||||
* Memory cost: this is the WORST-CASE retained size PER ACTIVE run (the buffer is
|
||||
* freed on finish + retention, or dropped immediately on overflow). With the small
|
||||
* number of concurrent autonomous runs a single workspace realistically has, 32MB
|
||||
* each is an acceptable ceiling; the overflow->204->degraded-poll fallback remains
|
||||
* the backstop for anything larger, so correctness never depends on this bound.
|
||||
* DEFAULT per-run replay ring cap (#491, down from 32MB). SSE frames carry
|
||||
* UNcompacted tool outputs + framing overhead (×1.5–2 vs the persisted parts), so
|
||||
* a "2–3 large reads + reasoning" step routinely blows past 2MB; 4MB comfortably
|
||||
* holds a step or two of TAIL, which is all a resuming client needs (steps below
|
||||
* its persisted frontier come from the seed, not the ring). The ring stays bounded
|
||||
* because it rotates on every confirmed persist; this cap is only the ceiling for
|
||||
* the un-persisted tail between rotations. Env-tunable via
|
||||
* AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES (bytes); a 0/invalid value falls back to this.
|
||||
*/
|
||||
export const RUN_STREAM_MAX_BUFFER_BYTES = 32 * 1024 * 1024;
|
||||
export const AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
// 2x the replay cap: a just-written full-replay burst alone can never trip the
|
||||
// per-subscriber cap (see controller); only a genuinely stalled socket can.
|
||||
export const SUBSCRIBER_MAX_BUFFERED_BYTES = 2 * RUN_STREAM_MAX_BUFFER_BYTES;
|
||||
// 2× the ring cap: a just-written full-tail burst alone can never trip the
|
||||
// per-subscriber cap (see controller); only a genuinely stalled socket can. This
|
||||
// derivative relationship is preserved even when the ring cap is env-overridden.
|
||||
export const SUBSCRIBER_MAX_BUFFERED_BYTES = 2 * AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES;
|
||||
|
||||
/**
|
||||
* A finish-step boundary frame is exactly `data: {"type":"finish-step"...}\n\n`
|
||||
* (verified empirically against ai@6.0.207 — each UI-message-stream part is a
|
||||
* single `data: {json}\n\n` event, never split across `data:` lines, and `type`
|
||||
* is always the first key). A prefix match is cheaper than JSON.parse-per-frame
|
||||
* and has no false positives: a literal `"type":"finish-step"` inside a text
|
||||
* delta is JSON-escaped (`\"type\":...`), and the frame would start with
|
||||
* `data: {"type":"text-delta"` anyway.
|
||||
*/
|
||||
const FINISH_STEP_FRAME_PREFIX = 'data: {"type":"finish-step"';
|
||||
|
||||
/** Resolve the ring cap from the environment, falling back to the default. */
|
||||
function resolveMaxBufferBytes(): number {
|
||||
const raw = process.env.AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES;
|
||||
if (!raw) return AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES;
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) && parsed > 0
|
||||
? Math.floor(parsed)
|
||||
: AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES;
|
||||
}
|
||||
|
||||
export interface RunStreamCallbacks {
|
||||
onFrame: (frame: string) => void;
|
||||
@@ -44,6 +124,9 @@ export interface RunStreamCallbacks {
|
||||
}
|
||||
|
||||
export interface RunStreamAttachment {
|
||||
// The synthetic `start` frame (carrying { runId, chatId }) followed by the
|
||||
// buffered TAIL filtered to `stamp >= N`. The controller writes these to the
|
||||
// socket in chunks respecting drain, then calls start().
|
||||
replay: string[];
|
||||
finished: boolean;
|
||||
start(): void; // drain pending frames (order preserved) and go live
|
||||
@@ -53,14 +136,19 @@ export interface RunStreamAttachment {
|
||||
interface Subscriber extends RunStreamCallbacks {
|
||||
started: boolean;
|
||||
pending: string[];
|
||||
// Byte size of `pending`, capped at SUBSCRIBER_MAX_BUFFERED_BYTES. `start()` is
|
||||
// called in the SAME tick as `attach()` today (see attach), so `pending` never
|
||||
// holds more than one microtask of frames — but the async `attach` signature is
|
||||
// a phase-2 seam: an await between attach and start would let a stalled paused
|
||||
// subscriber buffer the WHOLE run here. The cap is the structural backstop.
|
||||
// Byte size of `pending`, capped at the subscriber cap. `start()` is called in
|
||||
// the SAME tick as `attach()` today, so `pending` never holds more than one
|
||||
// microtask of frames — but the controller writes the (potentially large) tail
|
||||
// respecting drain BEFORE start(), so a stalled socket can accumulate here; the
|
||||
// cap is the structural backstop (an overflow degrades start() to an end()).
|
||||
pendingBytes: number;
|
||||
overflowed: boolean;
|
||||
pendingEnd: boolean;
|
||||
// The client's step frontier N: this subscriber only receives frames with
|
||||
// `stamp >= minStamp` (the tail past what it already persisted). Live frames
|
||||
// always satisfy this (their stamp is the current, highest step), so it only
|
||||
// filters the rare out-of-order below-frontier frame.
|
||||
minStamp: number;
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
@@ -68,8 +156,20 @@ interface Entry {
|
||||
// The persisted assistant row id of this run (set at bind; undefined if the
|
||||
// seed failed). Used by the attach anchor check (invariant 6).
|
||||
assistantMessageId?: string;
|
||||
// Parallel arrays: frames[i] is the SSE string, stamps[i] its step number.
|
||||
frames: string[];
|
||||
stamps: number[];
|
||||
bytes: number;
|
||||
// The running step counter used to stamp the NEXT frame (number of finish-step
|
||||
// frames seen so far).
|
||||
currentStamp: number;
|
||||
// The highest confirmed `stepsPersisted`: frames with stamp < persistedFloor are
|
||||
// on disk (safe to drop, never re-buffered). Monotonic (confirmPersistedStep).
|
||||
persistedFloor: number;
|
||||
// The highest stamp EVICTED by an overflow (unsafe) drop, -1 if none. Used to
|
||||
// detect a partially-evicted leading step when computing the coverage floor.
|
||||
overflowThroughStamp: number;
|
||||
// Sticky-for-logging only: at least one unsafe (overflow) eviction happened.
|
||||
overflowed: boolean;
|
||||
finished: boolean;
|
||||
subscribers: Set<Subscriber>;
|
||||
@@ -80,6 +180,10 @@ interface Entry {
|
||||
export class AiChatStreamRegistryService implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(AiChatStreamRegistryService.name);
|
||||
private readonly entries = new Map<string, Entry>(); // key: chatId
|
||||
// Env-resolved caps (per instance) so a deployment can tune the ceiling without
|
||||
// a code change. The subscriber cap keeps the documented 2× relationship.
|
||||
readonly maxBufferBytes = resolveMaxBufferBytes();
|
||||
readonly subscriberMaxBufferedBytes = 2 * this.maxBufferBytes;
|
||||
|
||||
/**
|
||||
* Register a fresh entry at the START of a run (before any frame), so a tab
|
||||
@@ -105,7 +209,11 @@ export class AiChatStreamRegistryService implements OnModuleDestroy {
|
||||
this.entries.set(chatId, {
|
||||
runId,
|
||||
frames: [],
|
||||
stamps: [],
|
||||
bytes: 0,
|
||||
currentStamp: 0,
|
||||
persistedFloor: 0,
|
||||
overflowThroughStamp: -1,
|
||||
overflowed: false,
|
||||
finished: false,
|
||||
subscribers: new Set<Subscriber>(),
|
||||
@@ -150,6 +258,34 @@ export class AiChatStreamRegistryService implements OnModuleDestroy {
|
||||
void pump();
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm that step `stepsPersisted` (a COUNT: steps 0..stepsPersisted-1) is on
|
||||
* disk for this run, and ROTATE the ring: drop the buffered frames of those
|
||||
* now-persisted steps (stamp < stepsPersisted). This is the ONLY thing that
|
||||
* rotates the ring, and it is called ONLY after a genuinely SUCCESSFUL per-step
|
||||
* persist (see ai-chat.service updateStreaming). A failed persist never calls
|
||||
* it, so the ring covers more (auto-safe). Identity-checked (invariant 1) and
|
||||
* monotonic (a stale lower count is ignored).
|
||||
*/
|
||||
confirmPersistedStep(
|
||||
chatId: string,
|
||||
runId: string,
|
||||
stepsPersisted: number,
|
||||
): void {
|
||||
const entry = this.entries.get(chatId);
|
||||
if (!entry || entry.runId !== runId) return;
|
||||
if (!Number.isFinite(stepsPersisted) || stepsPersisted <= entry.persistedFloor)
|
||||
return;
|
||||
entry.persistedFloor = stepsPersisted;
|
||||
// Clean rotation: drop the persisted steps from the head. These frames are on
|
||||
// disk + carried by a fresh client seed, so this NEVER opens a gap.
|
||||
while (entry.frames.length > 0 && entry.stamps[0] < stepsPersisted) {
|
||||
entry.bytes -= Buffer.byteLength(entry.frames[0]);
|
||||
entry.frames.shift();
|
||||
entry.stamps.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate a run's entry from the OUTER catch of the stream method (a failure
|
||||
* before/while wiring the pipe, so `done` will never arrive). Identity-checked
|
||||
@@ -162,36 +298,77 @@ export class AiChatStreamRegistryService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach to a run's stream. Async only for the phase-2 Redis seam — the body
|
||||
* runs synchronously so the replay snapshot and the subscriber registration
|
||||
* happen in ONE tick with no await between them (invariant 4): a frame ingested
|
||||
* concurrently cannot slip into the gap and be lost or duplicated.
|
||||
* Attach to a run's stream from the client's step frontier `n` (its persisted
|
||||
* `stepsPersisted`). Async only for the phase-2 Redis seam — the body runs
|
||||
* synchronously so the tail SLICE and the subscriber registration happen in ONE
|
||||
* tick with no await between them (invariant 4).
|
||||
*
|
||||
* Returns null (-> the caller answers 204) when:
|
||||
* - there is no entry, or it overflowed (replay is gone);
|
||||
* - expect=live with an anchor that does not match this run's assistant id
|
||||
* (invariant 6: a stripped tab must never replay a FOREIGN run's transcript);
|
||||
* - the run finished and the caller did not expect a live tail.
|
||||
* A finished run with expect=live yields a replay-only attachment (no
|
||||
* subscriber registered). Otherwise a paused subscriber is registered and the
|
||||
* caller replays `replay`, then calls start() to drain and go live.
|
||||
* - there is no entry;
|
||||
* - the `anchor` does not match this run's assistant id (invariant 6);
|
||||
* - the ring does not cover the client's frontier (coverageFloor > n): a hole
|
||||
* from overflow, or the client's seed simply lagged behind a rotation. The
|
||||
* client then refetches (a larger n) and re-attaches.
|
||||
*
|
||||
* Otherwise the attachment's `replay` is a synthetic `start` frame (the run-fact
|
||||
* on re-attach) followed by the buffered tail filtered to `stamp >= n`. For a
|
||||
* FINISHED run this is replay-only (no subscriber) and ends after the replay —
|
||||
* with n = N_final that tail is just the run's `finish` frame, so the client
|
||||
* closes the stream. For a LIVE run a paused subscriber is registered; the
|
||||
* caller writes the replay (respecting drain) then calls start() to drain the
|
||||
* pending frames and go live.
|
||||
*/
|
||||
async attach(
|
||||
chatId: string,
|
||||
expectLive: boolean,
|
||||
anchor: string | undefined,
|
||||
// The client's persisted step frontier. `null` = a NOT-tail-aware client (no
|
||||
// `n` query param) — a legacy/parameterless tab that expects the old
|
||||
// "finished -> 204 -> poll" contract; distinct from `0` (a tail-aware client
|
||||
// with nothing persisted yet).
|
||||
n: number | null,
|
||||
cb: RunStreamCallbacks,
|
||||
): Promise<RunStreamAttachment | null> {
|
||||
const entry = this.entries.get(chatId);
|
||||
if (!entry || entry.overflowed) return null;
|
||||
if (!entry) return null;
|
||||
// Invariant 6: cross-run replay is forbidden. Before bind, assistantMessageId
|
||||
// is undefined and mismatches any anchor -> 204 -> client restore+poll path.
|
||||
if (expectLive && anchor && entry.assistantMessageId !== anchor) return null;
|
||||
if (entry.finished && !expectLive) return null;
|
||||
if (entry.finished && expectLive) {
|
||||
if (anchor && entry.assistantMessageId !== anchor) return null;
|
||||
// #491 regression guard (#137/#161 dup): a NOT-tail-aware client (no `n`)
|
||||
// resuming a FINISHED run must 204 and poll — the old `finished && !expectLive`
|
||||
// gate. Without this, a missing `n` collapsing to frontier 0 would serve the
|
||||
// WHOLE tail of a finished, NON-rotated run (coverageFloor 0), and a
|
||||
// parameterless client that never stripped its transcript would APPEND that
|
||||
// full replay onto the steps it already shows -> duplicated text. A tail-aware
|
||||
// client (n present, incl. n=0) still gets the tail past its frontier.
|
||||
if (entry.finished && n === null) return null;
|
||||
// A finished entry with NOTHING in the ring (aborted before the first frame,
|
||||
// or fully overflowed) has no tail to deliver -> 204 -> the client polls.
|
||||
if (entry.finished && entry.frames.length === 0) return null;
|
||||
// A LIVE run with no `n` (legacy parameterless) replays from step 0 (the old
|
||||
// behavior); a tail-aware client resumes from its frontier.
|
||||
const frontier = n ?? 0;
|
||||
const floor = this.coverageFloor(entry);
|
||||
if (floor > frontier) {
|
||||
this.logger.warn(
|
||||
`run-stream attach gap for run=${entry.runId}: coverageFloor=${floor} ` +
|
||||
`> client frontier=${frontier} -> 204 (client refetches + re-attaches)`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const startFrame = this.buildStartFrame(chatId, entry.runId);
|
||||
const sliceTail = (): string[] => {
|
||||
const out: string[] = [startFrame];
|
||||
for (let i = 0; i < entry.frames.length; i++) {
|
||||
if (entry.stamps[i] >= frontier) out.push(entry.frames[i]);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
if (entry.finished) {
|
||||
// Replay-only: the run is done, no subscriber is registered.
|
||||
return {
|
||||
replay: entry.frames.slice(),
|
||||
replay: sliceTail(),
|
||||
finished: true,
|
||||
start: () => undefined,
|
||||
unsubscribe: () => undefined,
|
||||
@@ -206,15 +383,12 @@ export class AiChatStreamRegistryService implements OnModuleDestroy {
|
||||
pendingBytes: 0,
|
||||
overflowed: false,
|
||||
pendingEnd: false,
|
||||
minStamp: frontier,
|
||||
};
|
||||
// Register + snapshot in the SAME synchronous block (invariant 4). No await
|
||||
// separates them, so a concurrently ingested frame cannot be lost/duplicated.
|
||||
entry.subscribers.add(sub);
|
||||
// Snapshot in the SAME synchronous block as the registration (invariant 4).
|
||||
const replay = entry.frames.slice();
|
||||
// CONTRACT: the caller MUST call start() in the SAME tick as this attach()
|
||||
// returns — no await between them. While a subscriber is paused, every frame
|
||||
// is buffered in sub.pending; a delayed start() lets a whole run accumulate
|
||||
// there. The pendingBytes cap (see ingestFrame) is the structural backstop if
|
||||
// that contract is ever broken (e.g. the phase-2 Redis await seam).
|
||||
const replay = sliceTail();
|
||||
return {
|
||||
replay,
|
||||
finished: false,
|
||||
@@ -263,24 +437,83 @@ export class AiChatStreamRegistryService implements OnModuleDestroy {
|
||||
this.entries.clear();
|
||||
}
|
||||
|
||||
/** Buffer + fan-out a single frame. See invariant/overflow semantics inline. */
|
||||
/** The synthetic `start` frame the tail is prefixed with — the source of the
|
||||
* run-fact (runId/chatId) on re-attach. A `start` frame does NOT reset the
|
||||
* client's message parts (ai@6.0.207 createStreamingUIMessageState), so it is
|
||||
* safe to prepend even when the sliced tail begins mid-message. */
|
||||
private buildStartFrame(chatId: string, runId: string): string {
|
||||
return `data: ${JSON.stringify({
|
||||
type: 'start',
|
||||
messageMetadata: { runId, chatId },
|
||||
})}\n\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The smallest step FULLY present in the ring: its smallest retained stamp, or
|
||||
* (when the leading step was only partially evicted by an overflow) one past it.
|
||||
* When the ring is empty it is the current step (only the live tail is coming).
|
||||
* An attach at frontier `n` is covered ⟺ coverageFloor <= n.
|
||||
*/
|
||||
private coverageFloor(entry: Entry): number {
|
||||
// Empty ring: only the live tail is coming. The floor is the current step,
|
||||
// but never below persistedFloor — a confirmed persist can rotate the ring
|
||||
// empty while currentStamp still lags a beat behind on another connection, so
|
||||
// max() keeps the invariant STRUCTURAL (a client with n = persistedFloor is
|
||||
// always covered) rather than timing-dependent.
|
||||
if (entry.frames.length === 0)
|
||||
return Math.max(entry.currentStamp, entry.persistedFloor);
|
||||
const min = entry.stamps[0];
|
||||
return entry.overflowThroughStamp >= min ? min + 1 : min;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buffer (step-stamped) + fan-out a single frame. The stamp is the number of
|
||||
* finish-step frames seen BEFORE this one; a finish-step frame carries the
|
||||
* current value and THEN increments the counter (so its stamp equals the 0-based
|
||||
* index of the step it closes). Only frames at/above persistedFloor are buffered
|
||||
* (already-persisted steps are on disk); the ring is then trimmed to the byte
|
||||
* cap, an unsafe eviction opening a gap. Fan-out is always live (filtered per
|
||||
* subscriber by its frontier).
|
||||
*/
|
||||
private ingestFrame(entry: Entry, frame: string): void {
|
||||
entry.bytes += Buffer.byteLength(frame);
|
||||
if (!entry.overflowed) {
|
||||
const size = Buffer.byteLength(frame);
|
||||
const stamp = entry.currentStamp;
|
||||
if (frame.startsWith(FINISH_STEP_FRAME_PREFIX)) {
|
||||
entry.currentStamp = stamp + 1;
|
||||
}
|
||||
|
||||
// Buffer for replay only if this step is not already persisted+rotated away.
|
||||
if (stamp >= entry.persistedFloor) {
|
||||
entry.frames.push(frame);
|
||||
if (entry.bytes > RUN_STREAM_MAX_BUFFER_BYTES) {
|
||||
// The crossing frame was already counted AND (below) fanned out; only the
|
||||
// replay buffer is dropped. After overflow no more frames are buffered,
|
||||
// but live fan-out continues.
|
||||
entry.overflowed = true;
|
||||
entry.frames = [];
|
||||
this.logger.warn(
|
||||
`run-stream buffer overflow for run=${entry.runId}; ` +
|
||||
`late attach will 204 until the run ends`,
|
||||
);
|
||||
entry.stamps.push(stamp);
|
||||
entry.bytes += size;
|
||||
// Enforce the ring cap. Evicting a not-yet-persisted frame (stamp >=
|
||||
// persistedFloor) opens a GAP; a leftover persisted frame (< floor) is a
|
||||
// safe drop. Keep evicting until the ring is back under the cap.
|
||||
while (entry.bytes > this.maxBufferBytes && entry.frames.length > 0) {
|
||||
const evStamp = entry.stamps[0];
|
||||
entry.bytes -= Buffer.byteLength(entry.frames[0]);
|
||||
entry.frames.shift();
|
||||
entry.stamps.shift();
|
||||
if (evStamp >= entry.persistedFloor) {
|
||||
if (evStamp > entry.overflowThroughStamp)
|
||||
entry.overflowThroughStamp = evStamp;
|
||||
if (!entry.overflowed) {
|
||||
entry.overflowed = true;
|
||||
this.logger.warn(
|
||||
`run-stream ring overflow for run=${entry.runId}: an un-persisted ` +
|
||||
`step was evicted to stay under ${this.maxBufferBytes}B; a late ` +
|
||||
`attach at an evicted step will 204 until a later persist confirms`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fan out live, filtered to each subscriber's frontier (a subscriber only
|
||||
// wants the tail past the step it already persisted).
|
||||
for (const sub of entry.subscribers) {
|
||||
if (stamp < sub.minStamp) continue;
|
||||
if (sub.started) {
|
||||
try {
|
||||
sub.onFrame(frame);
|
||||
@@ -289,12 +522,12 @@ export class AiChatStreamRegistryService implements OnModuleDestroy {
|
||||
}
|
||||
} else {
|
||||
sub.pending.push(frame);
|
||||
sub.pendingBytes += Buffer.byteLength(frame);
|
||||
if (sub.pendingBytes > SUBSCRIBER_MAX_BUFFERED_BYTES) {
|
||||
sub.pendingBytes += size;
|
||||
if (sub.pendingBytes > this.subscriberMaxBufferedBytes) {
|
||||
// The paused subscriber's buffer overflowed — only possible if start()
|
||||
// was delayed past the same-tick contract (the phase-2 await seam).
|
||||
// Drop it rather than buffer the whole run; on start() it degrades to an
|
||||
// immediate end (a 204-equivalent) instead of replaying a partial.
|
||||
// was delayed (the controller's drain-respecting tail write, or the
|
||||
// phase-2 await seam). Drop it rather than buffer the whole run; on
|
||||
// start() it degrades to an immediate end (a 204-equivalent).
|
||||
sub.overflowed = true;
|
||||
sub.pending = [];
|
||||
entry.subscribers.delete(sub);
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import {
|
||||
AiChatStreamRegistryService,
|
||||
RUN_STREAM_MAX_BUFFER_BYTES,
|
||||
AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES,
|
||||
RUN_STREAM_RETAIN_FINISHED_MS,
|
||||
SUBSCRIBER_MAX_BUFFERED_BYTES,
|
||||
RunStreamCallbacks,
|
||||
} from './ai-chat-stream-registry.service';
|
||||
|
||||
/**
|
||||
* Unit tests for the in-memory run-stream registry (#184 phase 1.5). The registry
|
||||
* is the whole of the resumable-transport contract: replay ordering, paused ->
|
||||
* live hand-off, overflow, retention, the anchor check (invariant 6), and the
|
||||
* mirror-the-done-path replace semantics (invariant 3). Every enumerated case in
|
||||
* the issue's task 1.5 has a test here.
|
||||
* Unit tests for the in-memory run-stream registry (#184 phase 1.5, step-aligned
|
||||
* retention #491). The registry is the whole of the resumable-transport contract:
|
||||
* step-stamped retention, tail-only attach at the client's frontier N, the
|
||||
* confirmed-persist ring rotation (and the anti-inversion rule), the memory bound,
|
||||
* the overflow gap, paused -> live hand-off, retention, the anchor check
|
||||
* (invariant 6), and the mirror-the-done-path replace semantics (invariant 3).
|
||||
*/
|
||||
|
||||
// Real ai@6 UI-message-stream SSE frames are `data: {json}\n\n`, one part each.
|
||||
const sse = (part: Record<string, unknown>): string =>
|
||||
`data: ${JSON.stringify(part)}\n\n`;
|
||||
const finishStep = (): string => sse({ type: 'finish-step' });
|
||||
const textDelta = (id: string, delta: string): string =>
|
||||
sse({ type: 'text-delta', id, delta });
|
||||
const finish = (): string => sse({ type: 'finish' });
|
||||
|
||||
// A ReadableStream whose frames the test pushes explicitly, plus close/error.
|
||||
function makePushStream(): {
|
||||
stream: ReadableStream<string>;
|
||||
@@ -58,6 +66,9 @@ function collector(): {
|
||||
};
|
||||
}
|
||||
|
||||
// The tail past the synthetic start frame (replay[0] is always the start frame).
|
||||
const tail = (replay: string[]): string[] => replay.slice(1);
|
||||
|
||||
describe('AiChatStreamRegistryService', () => {
|
||||
const CHAT = 'chat-1';
|
||||
let registry: AiChatStreamRegistryService;
|
||||
@@ -71,7 +82,21 @@ describe('AiChatStreamRegistryService', () => {
|
||||
registry.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('replays frames in arrival order (live attach)', async () => {
|
||||
it('prepends a synthetic start frame carrying { runId, chatId }', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push('a');
|
||||
await flush();
|
||||
|
||||
const c = collector();
|
||||
const att = (await registry.attach(CHAT, 'assist-1', 0, c.cb))!;
|
||||
const start = JSON.parse(att.replay[0].replace(/^data: /, '').trim());
|
||||
expect(start.type).toBe('start');
|
||||
expect(start.messageMetadata).toEqual({ runId: 'run-1', chatId: CHAT });
|
||||
});
|
||||
|
||||
it('replays the buffered tail (from frontier 0) in arrival order (live attach)', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
@@ -81,13 +106,13 @@ describe('AiChatStreamRegistryService', () => {
|
||||
await flush();
|
||||
|
||||
const c = collector();
|
||||
const att = await registry.attach(CHAT, false, undefined, c.cb);
|
||||
const att = await registry.attach(CHAT, 'assist-1', 0, c.cb);
|
||||
expect(att).not.toBeNull();
|
||||
expect(att!.replay).toEqual(['a', 'b', 'c']);
|
||||
expect(tail(att!.replay)).toEqual(['a', 'b', 'c']);
|
||||
expect(att!.finished).toBe(false);
|
||||
});
|
||||
|
||||
it('late attach gets the full prefix as replay plus the live tail', async () => {
|
||||
it('late attach gets the buffered prefix as tail plus the live tail', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
@@ -96,17 +121,16 @@ describe('AiChatStreamRegistryService', () => {
|
||||
await flush();
|
||||
|
||||
const c = collector();
|
||||
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
|
||||
expect(att.replay).toEqual(['a', 'b']);
|
||||
const att = (await registry.attach(CHAT, 'assist-1', 0, c.cb))!;
|
||||
expect(tail(att.replay)).toEqual(['a', 'b']);
|
||||
att.start();
|
||||
// Live tail arrives after start().
|
||||
src.push('c');
|
||||
src.push('d');
|
||||
await flush();
|
||||
expect(c.frames).toEqual(['c', 'd']);
|
||||
});
|
||||
|
||||
it('a paused subscriber receives frames buffered during pause in order, then live (no loss/reorder)', async () => {
|
||||
it('a paused subscriber receives frames buffered during pause in order, then live', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
@@ -114,81 +138,45 @@ describe('AiChatStreamRegistryService', () => {
|
||||
await flush();
|
||||
|
||||
const c = collector();
|
||||
// Attach (paused). Frames that arrive BEFORE start() must queue, not drop.
|
||||
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
|
||||
expect(att.replay).toEqual(['a']);
|
||||
const att = (await registry.attach(CHAT, 'assist-1', 0, c.cb))!;
|
||||
expect(tail(att.replay)).toEqual(['a']);
|
||||
src.push('b'); // arrives while paused -> pending
|
||||
src.push('c');
|
||||
await flush();
|
||||
expect(c.frames).toEqual([]); // nothing delivered yet (paused)
|
||||
att.start(); // drains pending in order
|
||||
att.start();
|
||||
expect(c.frames).toEqual(['b', 'c']);
|
||||
src.push('d'); // now live
|
||||
src.push('d');
|
||||
await flush();
|
||||
expect(c.frames).toEqual(['b', 'c', 'd']);
|
||||
});
|
||||
|
||||
it('a run that finishes while a subscriber is paused ends it on start()', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', makePushStream().stream);
|
||||
const c = collector();
|
||||
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
|
||||
// Terminate the run while the subscriber is still paused.
|
||||
const att = (await registry.attach(CHAT, 'assist-1', 0, c.cb))!;
|
||||
registry.abortEntry(CHAT, 'run-1');
|
||||
expect(c.ended()).toBe(0); // paused: not ended yet
|
||||
att.start();
|
||||
expect(c.ended()).toBe(1); // start() drains + ends
|
||||
});
|
||||
|
||||
it('finished + expect=live returns a replay WITHOUT registering a subscriber', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push('a');
|
||||
src.push('b');
|
||||
src.close();
|
||||
await flush();
|
||||
|
||||
const c = collector();
|
||||
const att = (await registry.attach(CHAT, true, undefined, c.cb))!;
|
||||
expect(att.finished).toBe(true);
|
||||
expect(att.replay).toEqual(['a', 'b']);
|
||||
// No subscriber registered: start()/unsubscribe are no-ops and the entry has
|
||||
// zero subscribers.
|
||||
const entry = (registry as any).entries.get(CHAT);
|
||||
expect(entry.subscribers.size).toBe(0);
|
||||
att.start();
|
||||
expect(c.frames).toEqual([]);
|
||||
});
|
||||
|
||||
it('finished WITHOUT expect=live returns null', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push('a');
|
||||
src.close();
|
||||
await flush();
|
||||
|
||||
const c = collector();
|
||||
expect(await registry.attach(CHAT, false, undefined, c.cb)).toBeNull();
|
||||
});
|
||||
|
||||
it('anchor mismatch with expect=live returns null (and null before bind sets assistantMessageId)', async () => {
|
||||
it('anchor mismatch returns null (and null before bind sets assistantMessageId)', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const c = collector();
|
||||
// Before bind: assistantMessageId is undefined -> mismatches any anchor.
|
||||
expect(
|
||||
await registry.attach(CHAT, true, 'assist-1', c.cb),
|
||||
).toBeNull();
|
||||
expect(await registry.attach(CHAT, 'assist-1', 0, c.cb)).toBeNull();
|
||||
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push('a');
|
||||
await flush();
|
||||
// Wrong anchor -> null (cross-run replay forbidden, invariant 6).
|
||||
expect(await registry.attach(CHAT, true, 'other-id', c.cb)).toBeNull();
|
||||
expect(await registry.attach(CHAT, 'other-id', 0, c.cb)).toBeNull();
|
||||
});
|
||||
|
||||
it('matching anchor with expect=live attaches', async () => {
|
||||
it('matching anchor attaches', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
@@ -196,97 +184,60 @@ describe('AiChatStreamRegistryService', () => {
|
||||
await flush();
|
||||
|
||||
const c = collector();
|
||||
const att = await registry.attach(CHAT, true, 'assist-1', c.cb);
|
||||
const att = await registry.attach(CHAT, 'assist-1', 0, c.cb);
|
||||
expect(att).not.toBeNull();
|
||||
expect(att!.replay).toEqual(['a']);
|
||||
expect(tail(att!.replay)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('overflow: attach returns null, but the LIVE subscriber keeps receiving (incl. the crossing frame)', async () => {
|
||||
it('a throwing onFrame ejects only that subscriber; the ingest loop stays alive', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
|
||||
// A live (started) subscriber attached before the flood.
|
||||
const bad = collector();
|
||||
const badAtt = (await registry.attach(CHAT, 'assist-1', 0, {
|
||||
onFrame: () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
onEnd: bad.cb.onEnd,
|
||||
}))!;
|
||||
badAtt.start();
|
||||
|
||||
const good = collector();
|
||||
const goodAtt = (await registry.attach(CHAT, 'assist-1', 0, good.cb))!;
|
||||
goodAtt.start();
|
||||
|
||||
src.push('a'); // bad throws on this frame -> ejected
|
||||
src.push('b'); // good still receives both
|
||||
await flush();
|
||||
|
||||
const entry = (registry as any).entries.get(CHAT);
|
||||
expect(entry.subscribers.size).toBe(1);
|
||||
expect(good.frames).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('open() over a LIVE entry ends started subscribers once; a late done never touches the new entry (invariant 3)', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push('a');
|
||||
await flush();
|
||||
|
||||
const c = collector();
|
||||
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
|
||||
const att = (await registry.attach(CHAT, 'assist-1', 0, c.cb))!;
|
||||
att.start();
|
||||
|
||||
// Cap-relative so it survives a buffer-cap change (#430): a quarter-cap frame
|
||||
// means 5 frames comfortably exceed the replay cap; the last one crosses.
|
||||
const chunk = 'x'.repeat(Math.floor(RUN_STREAM_MAX_BUFFER_BYTES / 4));
|
||||
for (let i = 0; i < 5; i++) src.push(chunk + i);
|
||||
await flush();
|
||||
|
||||
const entry = (registry as any).entries.get(CHAT);
|
||||
expect(entry.overflowed).toBe(true);
|
||||
expect(entry.bytes).toBeGreaterThan(RUN_STREAM_MAX_BUFFER_BYTES);
|
||||
// The live subscriber received ALL 5 frames, including the crossing one.
|
||||
expect(c.frames).toHaveLength(5);
|
||||
expect(c.frames[4]).toBe(chunk + 4);
|
||||
|
||||
// A NEW attach after overflow gets null (replay buffer is gone).
|
||||
const c2 = collector();
|
||||
expect(await registry.attach(CHAT, false, undefined, c2.cb)).toBeNull();
|
||||
});
|
||||
|
||||
it('a paused subscriber whose pending buffer overflows is dropped and ends on start(); other subscribers keep receiving', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
|
||||
// A: paused (start() deliberately delayed to simulate the phase-2 await seam).
|
||||
const a = collector();
|
||||
const attA = (await registry.attach(CHAT, false, undefined, a.cb))!;
|
||||
// B: live (started) — its delivery must be unaffected by A's overflow.
|
||||
const b = collector();
|
||||
const attB = (await registry.attach(CHAT, false, undefined, b.cb))!;
|
||||
attB.start();
|
||||
|
||||
// Cap-relative so it survives a buffer-cap change (#430): a quarter-of-the-
|
||||
// per-subscriber-cap frame means 5 frames exceed A's paused-pending cap while
|
||||
// B streams every frame live.
|
||||
const chunk = 'x'.repeat(Math.floor(SUBSCRIBER_MAX_BUFFERED_BYTES / 4));
|
||||
for (let i = 0; i < 5; i++) src.push(chunk + i);
|
||||
await flush();
|
||||
|
||||
const entry = (registry as any).entries.get(CHAT);
|
||||
// A was dropped from the subscriber set on overflow; B (started) remains.
|
||||
expect(entry.subscribers.size).toBe(1);
|
||||
expect(a.frames).toEqual([]); // paused + overflowed: nothing was delivered
|
||||
// B received every frame live (delivery unaffected by A's overflow).
|
||||
expect(b.frames).toHaveLength(5);
|
||||
|
||||
// A's start() (arriving late) degrades to an immediate end, not a partial replay.
|
||||
attA.start();
|
||||
expect(a.frames).toEqual([]);
|
||||
expect(a.ended()).toBe(1);
|
||||
});
|
||||
|
||||
it('open() over a LIVE entry ends started subscribers exactly once and a late done does not touch the new entry (invariant 3)', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push('a');
|
||||
await flush();
|
||||
|
||||
const c = collector();
|
||||
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
|
||||
att.start(); // started subscriber on run-1
|
||||
|
||||
// run-2 starts on the same chat while run-1's tee is still reading.
|
||||
registry.open(CHAT, 'run-2');
|
||||
expect(c.ended()).toBe(1); // exactly one onEnd from the replace
|
||||
expect(c.ended()).toBe(1);
|
||||
|
||||
const newEntry = (registry as any).entries.get(CHAT);
|
||||
expect(newEntry.runId).toBe('run-2');
|
||||
expect(newEntry.finished).toBe(false);
|
||||
|
||||
// The old tee now completes: its late done must NOT double-end nor delete the
|
||||
// new entry.
|
||||
src.push('b');
|
||||
src.close();
|
||||
await flush();
|
||||
expect(c.ended()).toBe(1); // still exactly one
|
||||
expect(c.ended()).toBe(1);
|
||||
const still = (registry as any).entries.get(CHAT);
|
||||
expect(still).toBe(newEntry);
|
||||
expect(still.runId).toBe('run-2');
|
||||
@@ -299,7 +250,6 @@ describe('AiChatStreamRegistryService', () => {
|
||||
src.push('a');
|
||||
await flush();
|
||||
const entry = (registry as any).entries.get(CHAT);
|
||||
// Frames were NOT ingested (bind bailed), assistantMessageId untouched.
|
||||
expect(entry.frames).toEqual([]);
|
||||
expect(entry.assistantMessageId).toBeUndefined();
|
||||
});
|
||||
@@ -310,32 +260,276 @@ describe('AiChatStreamRegistryService', () => {
|
||||
const entry = (registry as any).entries.get(CHAT);
|
||||
expect(entry.finished).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('a throwing onFrame ejects only that subscriber; the ingest loop stays alive', async () => {
|
||||
/**
|
||||
* #491 step-stamped retention: the boundary detector, tail-only slicing at the
|
||||
* client's frontier N, the confirmed-persist rotation (+ anti-inversion), the
|
||||
* overflow gap, the memory bound, and the finished-retained tail. All observable
|
||||
* against the REAL registry driven through open/bind/ingest.
|
||||
*/
|
||||
describe('AiChatStreamRegistryService step-aligned retention (#491)', () => {
|
||||
const CHAT = 'chat-s';
|
||||
let registry: AiChatStreamRegistryService;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new AiChatStreamRegistryService();
|
||||
jest.spyOn((registry as any).logger, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => registry.onModuleDestroy());
|
||||
|
||||
const entryOf = () => (registry as any).entries.get(CHAT);
|
||||
|
||||
it('stamps frames by finish-step count, aligned with stepsPersisted', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
// step 0 content, its finish-step, step 1 content, its finish-step, finish.
|
||||
src.push(textDelta('t0', 'a')); // stamp 0
|
||||
src.push(finishStep()); // stamp 0 (the finish-step frame carries the pre value)
|
||||
src.push(textDelta('t1', 'b')); // stamp 1
|
||||
src.push(finishStep()); // stamp 1
|
||||
src.push(finish()); // stamp 2
|
||||
await flush();
|
||||
const e = entryOf();
|
||||
expect(e.stamps).toEqual([0, 0, 1, 1, 2]);
|
||||
expect(e.currentStamp).toBe(2);
|
||||
});
|
||||
|
||||
const bad = collector();
|
||||
const badAtt = (await registry.attach(CHAT, false, undefined, {
|
||||
onFrame: () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
onEnd: bad.cb.onEnd,
|
||||
}))!;
|
||||
badAtt.start();
|
||||
it('does NOT treat a text delta that merely quotes "finish-step" as a boundary', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
// A model that literally types "type":"finish-step" — JSON-escaped in the frame.
|
||||
src.push(textDelta('t0', '"type":"finish-step"'));
|
||||
await flush();
|
||||
expect(entryOf().currentStamp).toBe(0); // no false boundary
|
||||
});
|
||||
|
||||
const good = collector();
|
||||
const goodAtt = (await registry.attach(CHAT, false, undefined, good.cb))!;
|
||||
goodAtt.start();
|
||||
|
||||
src.push('a'); // bad throws on this frame -> ejected
|
||||
src.push('b'); // good still receives both
|
||||
it('tail-only: attach at N slices frames with stamp >= N', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push(textDelta('t0', 'a')); // 0
|
||||
src.push(finishStep()); // 0
|
||||
src.push(textDelta('t1', 'b')); // 1
|
||||
src.push(finishStep()); // 1
|
||||
src.push(textDelta('t2', 'c')); // 2 (in-progress)
|
||||
await flush();
|
||||
|
||||
const entry = (registry as any).entries.get(CHAT);
|
||||
expect(entry.subscribers.size).toBe(1); // bad ejected, good remains
|
||||
expect(good.frames).toEqual(['a', 'b']);
|
||||
const c = collector();
|
||||
// Client persisted 2 steps -> wants the tail from step 2.
|
||||
const att = (await registry.attach(CHAT, 'assist-1', 2, c.cb))!;
|
||||
expect(tail(att.replay)).toEqual([textDelta('t2', 'c')]);
|
||||
});
|
||||
|
||||
it('attach in the MIDDLE of a step (N between finish-steps) slices from that step', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push(textDelta('t0', 'a')); // 0
|
||||
src.push(finishStep()); // 0
|
||||
src.push(textDelta('t1', 'b1')); // 1
|
||||
src.push(textDelta('t1', 'b2')); // 1 (still step 1, no finish-step yet)
|
||||
await flush();
|
||||
|
||||
const c = collector();
|
||||
const att = (await registry.attach(CHAT, 'assist-1', 1, c.cb))!;
|
||||
// Step 0's frames are dropped from the tail; the whole in-progress step 1 is kept.
|
||||
expect(tail(att.replay)).toEqual([textDelta('t1', 'b1'), textDelta('t1', 'b2')]);
|
||||
});
|
||||
|
||||
it('rotates the ring ONLY on a confirmed persist (drops stamp < N)', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push(textDelta('t0', 'a')); // 0
|
||||
src.push(finishStep()); // 0
|
||||
src.push(textDelta('t1', 'b')); // 1
|
||||
await flush();
|
||||
expect(entryOf().stamps).toEqual([0, 0, 1]);
|
||||
|
||||
// Confirm step 0 persisted (stepsPersisted = 1) -> drop stamp < 1.
|
||||
registry.confirmPersistedStep(CHAT, 'run-1', 1);
|
||||
expect(entryOf().stamps).toEqual([1]);
|
||||
expect(entryOf().persistedFloor).toBe(1);
|
||||
});
|
||||
|
||||
it('persist FAILED but the ring still fits -> attach SUCCEEDS and the tail includes step N', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push(textDelta('t0', 'a')); // 0
|
||||
src.push(finishStep()); // 0
|
||||
src.push(textDelta('t1', 'b')); // 1 (step 1's persist FAILED -> no confirm)
|
||||
await flush();
|
||||
// No confirmPersistedStep for step 1: the ring still holds step 1.
|
||||
|
||||
const c = collector();
|
||||
// Client's last successful persist was step 0 -> stepsPersisted = 1.
|
||||
const att = await registry.attach(CHAT, 'assist-1', 1, c.cb);
|
||||
expect(att).not.toBeNull();
|
||||
expect(tail(att!.replay)).toEqual([textDelta('t1', 'b')]); // includes step 1
|
||||
});
|
||||
|
||||
it('persist failed AND the ring overflowed past N -> 204 (coverage gap)', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
// Step 0: a fat step that blows past the cap with NO persist confirmation.
|
||||
const big = 'x'.repeat(Math.floor(AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES / 2));
|
||||
src.push(textDelta('t0', big)); // 0
|
||||
src.push(textDelta('t0', big)); // 0
|
||||
src.push(textDelta('t0', big)); // 0 -> overflow evicts stamp-0 frames
|
||||
await flush();
|
||||
const e = entryOf();
|
||||
expect(e.overflowed).toBe(true);
|
||||
expect(e.bytes).toBeLessThanOrEqual(registry.maxBufferBytes);
|
||||
|
||||
// A client at frontier 0 falls at/below an evicted step -> gap -> null.
|
||||
const c = collector();
|
||||
expect(await registry.attach(CHAT, 'assist-1', 0, c.cb)).toBeNull();
|
||||
});
|
||||
|
||||
it('stale N (client seed lagged behind a rotation) -> 204; after a refetch (larger N) -> success', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push(textDelta('t0', 'a')); // 0
|
||||
src.push(finishStep()); // 0
|
||||
src.push(textDelta('t1', 'b')); // 1
|
||||
src.push(finishStep()); // 1
|
||||
src.push(textDelta('t2', 'c')); // 2
|
||||
await flush();
|
||||
// Server confirmed steps 0 and 1 -> rotate away stamp < 2.
|
||||
registry.confirmPersistedStep(CHAT, 'run-1', 2);
|
||||
expect(entryOf().stamps).toEqual([2]);
|
||||
|
||||
// A client whose seed still says stepsPersisted = 1 -> below minStamp -> 204.
|
||||
const stale = collector();
|
||||
expect(await registry.attach(CHAT, 'assist-1', 1, stale.cb)).toBeNull();
|
||||
|
||||
// It refetches (now stepsPersisted = 2) and re-attaches -> success.
|
||||
const fresh = collector();
|
||||
const att = await registry.attach(CHAT, 'assist-1', 2, fresh.cb);
|
||||
expect(att).not.toBeNull();
|
||||
expect(tail(att!.replay)).toEqual([textDelta('t2', 'c')]);
|
||||
});
|
||||
|
||||
it('overflow gap CLEARS once a later persist rotates out the holey steps', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
const big = 'x'.repeat(Math.floor(AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES / 2));
|
||||
src.push(textDelta('t0', big)); // 0
|
||||
src.push(textDelta('t0', big)); // 0
|
||||
src.push(finishStep()); // 0 (still stamp 0)
|
||||
src.push(textDelta('t1', 'small')); // 1
|
||||
src.push(finishStep()); // 1
|
||||
src.push(textDelta('t2', 'c')); // 2
|
||||
await flush();
|
||||
expect(entryOf().overflowed).toBe(true);
|
||||
|
||||
// Late persist confirms steps 0..1 -> rotates out the holey step-0 frames.
|
||||
registry.confirmPersistedStep(CHAT, 'run-1', 2);
|
||||
// A client at frontier 2 is now cleanly covered (the hole was below it).
|
||||
const c = collector();
|
||||
const att = await registry.attach(CHAT, 'assist-1', 2, c.cb);
|
||||
expect(att).not.toBeNull();
|
||||
expect(tail(att!.replay)).toEqual([textDelta('t2', 'c')]);
|
||||
});
|
||||
|
||||
it('finished-retained + N = N_final -> empty tail plus the finish frame', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push(textDelta('t0', 'a')); // 0
|
||||
src.push(finishStep()); // 0
|
||||
src.push(finish()); // 1 (N_final = 1)
|
||||
src.close();
|
||||
await flush();
|
||||
// The last step's per-step persist confirmed stepsPersisted = 1.
|
||||
registry.confirmPersistedStep(CHAT, 'run-1', 1);
|
||||
|
||||
const c = collector();
|
||||
const att = (await registry.attach(CHAT, 'assist-1', 1, c.cb))!;
|
||||
expect(att.finished).toBe(true);
|
||||
// Empty step tail; just the finish frame so the client's SDK closes the stream.
|
||||
expect(tail(att.replay)).toEqual([finish()]);
|
||||
// No subscriber registered for a finished run.
|
||||
expect(entryOf().subscribers.size).toBe(0);
|
||||
});
|
||||
|
||||
it('#491 regression (#137/#161 dup): a PARAMETERLESS attach (n=null) to a finished NON-rotated run -> 204, but n=0 still gets the tail', async () => {
|
||||
// A finished, non-rotated run: frames present, coverageFloor 0. A missing `n`
|
||||
// (null — a legacy/parameterless tab that never stripped its transcript) must
|
||||
// 204 -> poll, NOT receive the whole tail it would append (duplicate). A
|
||||
// tail-aware client (n=0 present) still resumes.
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push(textDelta('t0', 'a')); // 0
|
||||
src.push(finishStep()); // 0
|
||||
src.push(finish()); // 1
|
||||
src.close();
|
||||
await flush();
|
||||
// NOT rotated (no confirmPersistedStep) -> stamps[0]=0, coverageFloor=0.
|
||||
// MUTATION-VERIFY: revert the `finished && n === null -> null` gate (default n
|
||||
// to 0) and the parameterless attach below serves the full tail instead of 204.
|
||||
expect(await registry.attach(CHAT, 'assist-1', null, collector().cb)).toBeNull();
|
||||
// A tail-aware client at frontier 0 IS served (the distinction: null != 0).
|
||||
const tailAware = await registry.attach(CHAT, 'assist-1', 0, collector().cb);
|
||||
expect(tailAware).not.toBeNull();
|
||||
expect(tailAware!.finished).toBe(true);
|
||||
});
|
||||
|
||||
it('confirmPersistedStep is monotonic and identity-checked', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push(textDelta('t0', 'a'));
|
||||
src.push(finishStep());
|
||||
src.push(textDelta('t1', 'b'));
|
||||
await flush();
|
||||
registry.confirmPersistedStep(CHAT, 'run-1', 1);
|
||||
expect(entryOf().persistedFloor).toBe(1);
|
||||
// A stale lower count is ignored.
|
||||
registry.confirmPersistedStep(CHAT, 'run-1', 0);
|
||||
expect(entryOf().persistedFloor).toBe(1);
|
||||
// A foreign runId is ignored.
|
||||
registry.confirmPersistedStep(CHAT, 'WRONG', 5);
|
||||
expect(entryOf().persistedFloor).toBe(1);
|
||||
});
|
||||
|
||||
it('MEMORY BOUND: 5 parallel marathon runs each stream well past 32MB; each ring stays <= the cap', async () => {
|
||||
const cap = registry.maxBufferBytes;
|
||||
const chats = ['m0', 'm1', 'm2', 'm3', 'm4'];
|
||||
const srcs = chats.map((chat) => {
|
||||
registry.open(chat, `run-${chat}`);
|
||||
const s = makePushStream();
|
||||
registry.bind(chat, `run-${chat}`, `assist-${chat}`, s.stream);
|
||||
return s;
|
||||
});
|
||||
// ~256KB frames; 160 per chat = 40MB streamed each, well past the old 32MB.
|
||||
// Interleave a finish-step every 8 frames so steps advance realistically. No
|
||||
// persist confirmation -> the ONLY thing keeping memory bounded is the cap.
|
||||
const frame = 'y'.repeat(256 * 1024);
|
||||
for (let batch = 0; batch < 20; batch++) {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
for (const s of srcs) s.push(textDelta('t', frame));
|
||||
}
|
||||
for (const s of srcs) s.push(finishStep());
|
||||
await flush(); // drain the pump so queues never hold a whole run
|
||||
}
|
||||
let total = 0;
|
||||
for (const chat of chats) {
|
||||
const e = (registry as any).entries.get(chat);
|
||||
expect(e.bytes).toBeLessThanOrEqual(cap);
|
||||
total += e.bytes;
|
||||
}
|
||||
// Total retained across all 5 runs is bounded by 5x the per-run cap — the old
|
||||
// registry would have retained ~5x40MB = 200MB here.
|
||||
expect(total).toBeLessThanOrEqual(cap * chats.length);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -361,7 +555,7 @@ describe('AiChatStreamRegistryService retention timers', () => {
|
||||
|
||||
it('a finished entry is removed after the retention window', () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
registry.abortEntry(CHAT, 'run-1'); // finalize -> retention armed
|
||||
registry.abortEntry(CHAT, 'run-1');
|
||||
expect((registry as any).entries.get(CHAT)).toBeDefined();
|
||||
jest.advanceTimersByTime(RUN_STREAM_RETAIN_FINISHED_MS + 1);
|
||||
expect((registry as any).entries.get(CHAT)).toBeUndefined();
|
||||
@@ -369,20 +563,18 @@ describe('AiChatStreamRegistryService retention timers', () => {
|
||||
|
||||
it('retention deletes ONLY its own entry (invariant 2)', () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
registry.abortEntry(CHAT, 'run-1'); // arm retention for entry A
|
||||
// Simulate the race where the key was replaced without clearing A's timer.
|
||||
registry.abortEntry(CHAT, 'run-1');
|
||||
const sentinel = { marker: true };
|
||||
(registry as any).entries.set(CHAT, sentinel);
|
||||
jest.advanceTimersByTime(RUN_STREAM_RETAIN_FINISHED_MS + 1);
|
||||
// A's timer saw entries.get(CHAT) !== A, so it did NOT delete the successor.
|
||||
expect((registry as any).entries.get(CHAT)).toBe(sentinel);
|
||||
});
|
||||
|
||||
it('open() over a retained entry clears its timer and the successor survives', () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
registry.abortEntry(CHAT, 'run-1'); // retained, timer armed
|
||||
registry.abortEntry(CHAT, 'run-1');
|
||||
const clearSpy = jest.spyOn(global, 'clearTimeout');
|
||||
registry.open(CHAT, 'run-2'); // must clear run-1's retain timer
|
||||
registry.open(CHAT, 'run-2');
|
||||
expect(clearSpy).toHaveBeenCalled();
|
||||
jest.advanceTimersByTime(RUN_STREAM_RETAIN_FINISHED_MS + 1);
|
||||
const entry = (registry as any).entries.get(CHAT);
|
||||
|
||||
@@ -8,10 +8,12 @@ import { SUBSCRIBER_MAX_BUFFERED_BYTES } from './ai-chat-stream-registry.service
|
||||
import type { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
|
||||
/**
|
||||
* Wiring spec for the #184 phase 1.5 attach endpoint
|
||||
* Wiring spec for the #184 phase 1.5 attach endpoint (tail-only #491)
|
||||
* (`GET /ai-chat/runs/:chatId/stream`). Owner-gated via assertOwnedChat; the
|
||||
* registry is mocked so this exercises ONLY the controller's replay/live/204/
|
||||
* cleanup wiring against a fake raw socket. Constructor order is (aiChatService,
|
||||
* registry is mocked so this exercises ONLY the controller's tail-write/live/204/
|
||||
* cleanup wiring against a fake raw socket. The attach signature is now
|
||||
* `(chatId, anchor, n, cb)` — the client hands its persisted step frontier `n`
|
||||
* and its assistant row id `anchor`. Constructor order is (aiChatService,
|
||||
* aiChatRunService, aiChatRepo, aiChatMessageRepo, aiTranscription, pageRepo,
|
||||
* streamRegistry, environment).
|
||||
*/
|
||||
@@ -86,8 +88,8 @@ describe('AiChatController attach endpoint (#184 phase 1.5)', () => {
|
||||
attach: jest.fn(
|
||||
(
|
||||
_chatId: string,
|
||||
_live: boolean,
|
||||
_anchor: string | undefined,
|
||||
_n: number,
|
||||
cb: RunStreamCallbacks,
|
||||
) => {
|
||||
capturedCb = cb;
|
||||
@@ -156,7 +158,7 @@ describe('AiChatController attach endpoint (#184 phase 1.5)', () => {
|
||||
expect(res.hijack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('threads expect=live and anchor through to the registry', async () => {
|
||||
it('threads anchor and the numeric frontier n through to the registry', async () => {
|
||||
const { controller, streamRegistry } = makeController({
|
||||
chat: owned,
|
||||
attachment: null,
|
||||
@@ -165,8 +167,8 @@ describe('AiChatController attach endpoint (#184 phase 1.5)', () => {
|
||||
const { req } = makeReq();
|
||||
await controller.attachRunStream(
|
||||
'c1',
|
||||
'live',
|
||||
'anchor-1',
|
||||
'2',
|
||||
req,
|
||||
res,
|
||||
user,
|
||||
@@ -174,13 +176,44 @@ describe('AiChatController attach endpoint (#184 phase 1.5)', () => {
|
||||
);
|
||||
expect(streamRegistry.attach).toHaveBeenCalledWith(
|
||||
'c1',
|
||||
true,
|
||||
'anchor-1',
|
||||
2, // parsed to a number
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes expect=false when the query is absent', async () => {
|
||||
it('#491: an ABSENT/invalid n passes null (not 0) so a finished run 204s (not-tail-aware)', async () => {
|
||||
// Distinguishing a MISSING `n` from `n=0` is the #137/#161 dup guard: a
|
||||
// parameterless/legacy tab must be handed null (-> the registry 204s a finished
|
||||
// run) rather than frontier 0 (which would serve a finished non-rotated run's
|
||||
// whole tail). MUTATION-VERIFY: revert to `Number(n) || 0` and this asserts 0.
|
||||
const { controller, streamRegistry } = makeController({
|
||||
chat: owned,
|
||||
attachment: null,
|
||||
});
|
||||
for (const bad of [undefined, '', 'abc']) {
|
||||
streamRegistry.attach.mockClear();
|
||||
const { res } = makeRawRes();
|
||||
const { req } = makeReq();
|
||||
await controller.attachRunStream(
|
||||
'c1',
|
||||
undefined,
|
||||
bad,
|
||||
req,
|
||||
res,
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
expect(streamRegistry.attach).toHaveBeenCalledWith(
|
||||
'c1',
|
||||
undefined,
|
||||
null,
|
||||
expect.anything(),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('#491: a PRESENT n=0 passes 0 (tail-aware, distinct from absent)', async () => {
|
||||
const { controller, streamRegistry } = makeController({
|
||||
chat: owned,
|
||||
attachment: null,
|
||||
@@ -190,7 +223,7 @@ describe('AiChatController attach endpoint (#184 phase 1.5)', () => {
|
||||
await controller.attachRunStream(
|
||||
'c1',
|
||||
undefined,
|
||||
undefined,
|
||||
'0',
|
||||
req,
|
||||
res,
|
||||
user,
|
||||
@@ -198,8 +231,8 @@ describe('AiChatController attach endpoint (#184 phase 1.5)', () => {
|
||||
);
|
||||
expect(streamRegistry.attach).toHaveBeenCalledWith(
|
||||
'c1',
|
||||
false,
|
||||
undefined,
|
||||
0,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
@@ -245,8 +278,8 @@ describe('AiChatController attach endpoint (#184 phase 1.5)', () => {
|
||||
const { req } = makeReq();
|
||||
await controller.attachRunStream(
|
||||
'c1',
|
||||
'live',
|
||||
'a1',
|
||||
'1',
|
||||
req,
|
||||
res,
|
||||
user,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { AiChatController } from './ai-chat.controller';
|
||||
import type { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
|
||||
/**
|
||||
* Wiring spec for the #491 delta-poll endpoint (`POST /ai-chat/messages/delta`).
|
||||
* Owner-gated via assertOwnedChat (same gate as the other reads), NOT flag-gated.
|
||||
* The run fact rides IN the delta response (no separate /run poll). Hand-rolled
|
||||
* mocks — no Nest graph, no DB. Constructor order: (aiChatService,
|
||||
* aiChatRunService, aiChatRepo, aiChatMessageRepo, aiTranscription, pageRepo).
|
||||
*/
|
||||
describe('AiChatController POST /ai-chat/messages/delta (#491)', () => {
|
||||
const user = { id: 'u1' } as User;
|
||||
const workspace = { id: 'ws1' } as Workspace;
|
||||
|
||||
function makeController(opts: {
|
||||
chat?: unknown;
|
||||
delta?: { rows: unknown[]; cursor: string };
|
||||
run?: unknown;
|
||||
}) {
|
||||
const aiChatRunService = {
|
||||
getLatestForChat: jest.fn().mockResolvedValue(opts.run),
|
||||
};
|
||||
const aiChatRepo = {
|
||||
findById: jest.fn().mockResolvedValue(opts.chat),
|
||||
};
|
||||
const aiChatMessageRepo = {
|
||||
findByChatUpdatedAfter: jest
|
||||
.fn()
|
||||
.mockResolvedValue(opts.delta ?? { rows: [], cursor: 'C1' }),
|
||||
};
|
||||
const controller = new AiChatController(
|
||||
{} as never,
|
||||
aiChatRunService as never,
|
||||
aiChatRepo as never,
|
||||
aiChatMessageRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { controller, aiChatRunService, aiChatRepo, aiChatMessageRepo };
|
||||
}
|
||||
|
||||
it('owner-gates: a chat the user does not own throws, never reaching the repo', async () => {
|
||||
const { controller, aiChatMessageRepo, aiChatRunService } = makeController({
|
||||
chat: { id: 'c1', creatorId: 'someone-else' },
|
||||
});
|
||||
await expect(
|
||||
controller.getMessagesDelta({ chatId: 'c1' }, user, workspace),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(aiChatMessageRepo.findByChatUpdatedAfter).not.toHaveBeenCalled();
|
||||
expect(aiChatRunService.getLatestForChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns { rows, cursor, run:{id,status} } with the run fact inlined', async () => {
|
||||
const rows = [{ id: 'm1' }];
|
||||
const { controller } = makeController({
|
||||
chat: { id: 'c1', creatorId: 'u1' },
|
||||
delta: { rows, cursor: 'C2' },
|
||||
run: { id: 'r1', status: 'running', error: 'ignored', stepCount: 3 },
|
||||
});
|
||||
const res = await controller.getMessagesDelta(
|
||||
{ chatId: 'c1', cursor: 'C1' },
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
expect(res).toEqual({
|
||||
rows,
|
||||
cursor: 'C2',
|
||||
// ONLY id + status — never the whole run row.
|
||||
run: { id: 'r1', status: 'running' },
|
||||
});
|
||||
});
|
||||
|
||||
it('run is null when the chat has never had a run', async () => {
|
||||
const { controller } = makeController({
|
||||
chat: { id: 'c1', creatorId: 'u1' },
|
||||
run: undefined,
|
||||
});
|
||||
const res = await controller.getMessagesDelta(
|
||||
{ chatId: 'c1' },
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
expect(res.run).toBeNull();
|
||||
});
|
||||
|
||||
it('passes cursor through, defaulting a missing cursor to null (first poll)', async () => {
|
||||
const { controller, aiChatMessageRepo } = makeController({
|
||||
chat: { id: 'c1', creatorId: 'u1' },
|
||||
});
|
||||
await controller.getMessagesDelta({ chatId: 'c1' }, user, workspace);
|
||||
expect(aiChatMessageRepo.findByChatUpdatedAfter).toHaveBeenCalledWith(
|
||||
'c1',
|
||||
'ws1',
|
||||
null,
|
||||
);
|
||||
await controller.getMessagesDelta(
|
||||
{ chatId: 'c1', cursor: 'CX' },
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
expect(aiChatMessageRepo.findByChatUpdatedAfter).toHaveBeenLastCalledWith(
|
||||
'c1',
|
||||
'ws1',
|
||||
'CX',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ 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';
|
||||
@@ -43,6 +44,8 @@ import {
|
||||
AiChatRunHooks,
|
||||
AiChatService,
|
||||
AiChatStreamBody,
|
||||
rowHasInlineParts,
|
||||
hydrateAssistantParts,
|
||||
} from './ai-chat.service';
|
||||
import { AiChatRunService } from './ai-chat-run.service';
|
||||
import { AiTranscriptionService } from './ai-transcription.service';
|
||||
@@ -51,6 +54,7 @@ import {
|
||||
ChatIdDto,
|
||||
ExportChatDto,
|
||||
GeneratePageTitleDto,
|
||||
GetChatDeltaDto,
|
||||
GetChatMessagesDto,
|
||||
GetRunDto,
|
||||
RenameChatDto,
|
||||
@@ -63,6 +67,47 @@ import {
|
||||
SUBSCRIBER_MAX_BUFFERED_BYTES,
|
||||
} from './ai-chat-stream-registry.service';
|
||||
import { startSseHeartbeat } from './sse-resilience';
|
||||
|
||||
/**
|
||||
* Write the attach TAIL to the hijacked socket in chunks that RESPECT drain
|
||||
* (#491): each `write()` that returns false (the kernel buffer is full) is awaited
|
||||
* on the next 'drain' before continuing. The old code wrote the whole buffer
|
||||
* synchronously, which — with the pre-#491 32MB ring — spiked memory (half the
|
||||
* OOM). Bails immediately if the socket ended/errored mid-write. Frames that the
|
||||
* paused registry subscriber buffers while this awaits are delivered by start().
|
||||
*/
|
||||
async function writeTailRespectingDrain(
|
||||
raw: {
|
||||
write(chunk: string): boolean;
|
||||
writableEnded?: boolean;
|
||||
destroyed?: boolean;
|
||||
once(event: string, cb: () => void): unknown;
|
||||
removeListener?(event: string, cb: () => void): unknown;
|
||||
},
|
||||
frames: string[],
|
||||
): Promise<void> {
|
||||
for (const frame of frames) {
|
||||
if (raw.writableEnded || raw.destroyed) return;
|
||||
const ok = raw.write(frame);
|
||||
if (!ok) {
|
||||
// Kernel buffer full — wait for drain (or an early close/error) before the
|
||||
// next chunk, so a slow reader never forces the whole tail into memory.
|
||||
// Remove ALL three listeners once any fires, so a many-chunk tail with
|
||||
// repeated backpressure never leaks (MaxListenersExceededWarning).
|
||||
await new Promise<void>((resolve) => {
|
||||
const finish = (): void => {
|
||||
raw.removeListener?.('drain', finish);
|
||||
raw.removeListener?.('close', finish);
|
||||
raw.removeListener?.('error', finish);
|
||||
resolve();
|
||||
};
|
||||
raw.once('drain', finish);
|
||||
raw.once('close', finish);
|
||||
raw.once('error', finish);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
|
||||
/**
|
||||
@@ -87,8 +132,39 @@ 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')
|
||||
@@ -142,11 +218,60 @@ export class AiChatController {
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
await this.assertOwnedChat(dto.chatId, user, workspace);
|
||||
return this.aiChatMessageRepo.findByChat(
|
||||
const page = await 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),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delta poll (#491) — the degraded-poll fallback's payload. Returns the chat's
|
||||
* message rows changed since `cursor` (a DB-clock timestamp from the previous
|
||||
* poll), a FRESH cursor, AND the current run fact `{ id, status } | null`. This
|
||||
* replaces the old degraded poll that refetched ALL infinite-query pages (full
|
||||
* parts) every 2.5s: the client seeds once and thereafter merges only the
|
||||
* deltas by id (the overlap window guarantees repeats — the merge is idempotent,
|
||||
* see mergeById). The run fact rides IN the delta (a separate /run poll would
|
||||
* double the poll QPS), so the client FSM gets the run's status on the same tick.
|
||||
* Owner-gated via assertOwnedChat (same gate as the other read endpoints).
|
||||
*/
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('messages/delta')
|
||||
async getMessagesDelta(
|
||||
@Body() dto: GetChatDeltaDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<{
|
||||
rows: AiChatMessage[];
|
||||
cursor: string;
|
||||
run: { id: string; status: string } | null;
|
||||
}> {
|
||||
await this.assertOwnedChat(dto.chatId, user, workspace);
|
||||
const { rows, cursor } =
|
||||
await this.aiChatMessageRepo.findByChatUpdatedAfter(
|
||||
dto.chatId,
|
||||
workspace.id,
|
||||
dto.cursor ?? null,
|
||||
);
|
||||
const run = await this.aiChatRunService.getLatestForChat(
|
||||
dto.chatId,
|
||||
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),
|
||||
cursor,
|
||||
run: run ? { id: run.id, status: run.status } : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,8 +290,10 @@ export class AiChatController {
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<{ markdown: string }> {
|
||||
const chat = await this.assertOwnedChat(dto.chatId, user, workspace);
|
||||
const rows = await this.aiChatMessageRepo.findAllByChat(
|
||||
dto.chatId,
|
||||
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.
|
||||
workspace.id,
|
||||
);
|
||||
const markdown = buildChatMarkdown({
|
||||
@@ -206,7 +333,13 @@ export class AiChatController {
|
||||
workspace.id,
|
||||
)
|
||||
: undefined;
|
||||
return { run, message: message ?? null };
|
||||
// #492: reconnect to an IN-FLIGHT run reconstructs the projection row's parts
|
||||
// from the steps table (the row itself carries only the step marker mid-run);
|
||||
// a finished run's row already has inline parts, so this is a no-op.
|
||||
const [hydrated] = message
|
||||
? await this.withReconstructedParts([message], workspace.id)
|
||||
: [undefined];
|
||||
return { run, message: hydrated ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -249,19 +382,25 @@ export class AiChatController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach to a chat's live run stream (#184 phase 1.5). A late/reloaded tab
|
||||
* replays the frames buffered so far and then follows the live tail as a normal
|
||||
* streamer. Owner-gated via assertOwnedChat (same gate as getRun). When there is
|
||||
* nothing to resume — no entry, a finished run without expect=live, an
|
||||
* overflowed buffer, or an anchor that pins a DIFFERENT run — the endpoint
|
||||
* answers 204, the ONLY "nothing to resume" signal the AI SDK's reconnect
|
||||
* accepts (it maps 204 to a silent no-op). With AI_CHAT_RESUMABLE_STREAM off the
|
||||
* registry is never populated, so attach always 204s.
|
||||
* Attach to a chat's live run stream from the client's step frontier (#184 phase
|
||||
* 1.5, tail-only #491). A late/reloaded tab hands the server the step count it
|
||||
* has PERSISTED (`n` = the seeded row's `metadata.stepsPersisted`) and its
|
||||
* assistant row id (`anchor`); the registry answers with the TAIL past step `n`
|
||||
* (a synthetic `start` frame + the buffered frames stamped >= n) and then the
|
||||
* live tail. Owner-gated via assertOwnedChat (same gate as getRun). When there
|
||||
* is nothing to resume — no entry, a ring that does not cover the client's
|
||||
* frontier (overflow gap, or the client's seed lagged a rotation), or an anchor
|
||||
* that pins a DIFFERENT run (invariant 6) — the endpoint answers 204, the ONLY
|
||||
* "nothing to resume" signal the AI SDK's reconnect accepts (it maps 204 to a
|
||||
* silent no-op); the client then refetches (a larger n) and re-attaches. With
|
||||
* AI_CHAT_RESUMABLE_STREAM off the registry is never populated, so attach always
|
||||
* 204s.
|
||||
*
|
||||
* `expect=live` opts into replaying a finished-but-retained run (safe only when
|
||||
* the client stripped the streaming tail); `anchor` is the client's assistant
|
||||
* row id, which must match this run's (invariant 6) or a foreign run's
|
||||
* transcript would be replayed into the store.
|
||||
* The step marker `n` comes ONLY from the client — the server never reads the
|
||||
* row to derive it, because a server-side n from a stale seed would open a
|
||||
* silent one-step hole. The tail is written to the socket in CHUNKS respecting
|
||||
* drain (writeTailRespectingDrain): the old code synchronously blasted the whole
|
||||
* buffer, which — with the old 32MB cap — was half the OOM.
|
||||
*/
|
||||
@SkipTransform()
|
||||
@UseGuards(JwtAuthGuard, UserThrottlerGuard)
|
||||
@@ -269,39 +408,49 @@ export class AiChatController {
|
||||
@Get('runs/:chatId/stream')
|
||||
async attachRunStream(
|
||||
@Param('chatId', new ParseUUIDPipe()) chatId: string,
|
||||
@Query('expect') expect: string | undefined,
|
||||
@Query('anchor') anchor: string | undefined,
|
||||
@Query('n') n: string | undefined,
|
||||
@Req() req: FastifyRequest,
|
||||
@Res() res: FastifyReply,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<void> {
|
||||
await this.assertOwnedChat(chatId, user, workspace); // same gate as getRun
|
||||
// The client's persisted step frontier. #491: distinguish a MISSING/invalid `n`
|
||||
// (null — a NOT-tail-aware, legacy/parameterless tab expecting the old
|
||||
// "finished -> 204 -> poll" contract) from `n=0` (a tail-aware client with
|
||||
// nothing persisted yet). Passing 0 for a missing `n` would serve a finished,
|
||||
// non-rotated run's WHOLE tail and a parameterless client would append it onto
|
||||
// the steps it already shows -> #137/#161 duplicate. null makes the registry
|
||||
// 204 such a finished run (see attach); a tail-aware n=0 still resumes.
|
||||
const frontier: number | null =
|
||||
n === undefined || n === '' || !Number.isFinite(Number(n))
|
||||
? null
|
||||
: Math.max(0, Number(n));
|
||||
// The per-subscriber backpressure cap tracks the (env-tunable) ring cap.
|
||||
const subscriberCap =
|
||||
this.streamRegistry?.subscriberMaxBufferedBytes ??
|
||||
SUBSCRIBER_MAX_BUFFERED_BYTES;
|
||||
let stopHeartbeat: () => void = () => undefined;
|
||||
const attachment = await this.streamRegistry?.attach(
|
||||
chatId,
|
||||
expect === 'live',
|
||||
anchor,
|
||||
{
|
||||
onFrame: (frame) => {
|
||||
// Backpressure guard: 2x the replay cap, so the initial replay burst
|
||||
// alone can never trip it; only a genuinely stalled socket can.
|
||||
try {
|
||||
if (res.raw.writableLength > SUBSCRIBER_MAX_BUFFERED_BYTES) {
|
||||
res.raw.destroy(); // 'close' fires -> unsubscribe below
|
||||
return;
|
||||
}
|
||||
if (!res.raw.writableEnded) res.raw.write(frame);
|
||||
} catch {
|
||||
res.raw.destroy();
|
||||
const attachment = await this.streamRegistry?.attach(chatId, anchor, frontier, {
|
||||
onFrame: (frame) => {
|
||||
// Backpressure guard: 2x the ring cap, so the initial tail burst alone
|
||||
// can never trip it; only a genuinely stalled socket can.
|
||||
try {
|
||||
if (res.raw.writableLength > subscriberCap) {
|
||||
res.raw.destroy(); // 'close' fires -> unsubscribe below
|
||||
return;
|
||||
}
|
||||
},
|
||||
onEnd: () => {
|
||||
stopHeartbeat();
|
||||
if (!res.raw.writableEnded) res.raw.end();
|
||||
},
|
||||
if (!res.raw.writableEnded) res.raw.write(frame);
|
||||
} catch {
|
||||
res.raw.destroy();
|
||||
}
|
||||
},
|
||||
);
|
||||
onEnd: () => {
|
||||
stopHeartbeat();
|
||||
if (!res.raw.writableEnded) res.raw.end();
|
||||
},
|
||||
});
|
||||
if (!attachment) {
|
||||
res.status(204).send(); // the ONLY "nothing to resume" signal the SDK accepts
|
||||
return;
|
||||
@@ -330,13 +479,16 @@ export class AiChatController {
|
||||
// deliberately NO Connection/Keep-Alive (hop-by-hop; Safari/HTTP2)
|
||||
});
|
||||
res.raw.flushHeaders?.();
|
||||
for (const frame of attachment.replay) res.raw.write(frame);
|
||||
// Write the tail in chunks respecting drain (not a synchronous blast, which
|
||||
// was half the OOM). Frames the paused subscriber buffers meanwhile are
|
||||
// drained by start() below; its cap is the backstop for a stalled socket.
|
||||
await writeTailRespectingDrain(res.raw, attachment.replay);
|
||||
if (attachment.finished) {
|
||||
res.raw.end();
|
||||
if (!res.raw.writableEnded) res.raw.end();
|
||||
return;
|
||||
}
|
||||
stopHeartbeat = startSseHeartbeat(res.raw, 15_000);
|
||||
attachment.start(); // drain pending accumulated during replay, go live
|
||||
attachment.start(); // drain pending accumulated during the tail write, go live
|
||||
} catch {
|
||||
attachment.unsubscribe();
|
||||
stopHeartbeat();
|
||||
|
||||
@@ -181,7 +181,7 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
|
||||
{} as never, // pageAccess
|
||||
{ isAiChatDeferredToolsEnabled: () => false, isAiChatFinalStepLockdownEnabled: () => false } as never, // environment
|
||||
);
|
||||
return { svc };
|
||||
return { svc, aiChatMessageRepo };
|
||||
}
|
||||
|
||||
const body = {
|
||||
@@ -287,7 +287,7 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
|
||||
// Drive stream() to the point streamText is called, capturing the options object
|
||||
// (which carries onStepFinish/onFinish/onError/onAbort) and the run hooks.
|
||||
async function captureStreamCallbacks() {
|
||||
const { svc } = makeService();
|
||||
const { svc, aiChatMessageRepo } = makeService();
|
||||
let capturedOpts: any;
|
||||
streamTextMock.mockImplementation((opts: any) => {
|
||||
capturedOpts = opts;
|
||||
@@ -314,7 +314,7 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
|
||||
runHooks: runHooks as never,
|
||||
});
|
||||
expect(capturedOpts).toBeDefined();
|
||||
return { capturedOpts, runHooks };
|
||||
return { capturedOpts, runHooks, aiChatMessageRepo };
|
||||
}
|
||||
|
||||
it('F9: onStepFinish bumps the run step count, onFinish settles the run "completed" (the dominant autonomous-run path)', async () => {
|
||||
@@ -369,6 +369,51 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
|
||||
expect.stringContaining('provider exploded'),
|
||||
);
|
||||
});
|
||||
|
||||
// #490 reactive branch: a provider CONTEXT-OVERFLOW 400 in onError is classified,
|
||||
// records a distinguishable cause, and stamps metadata.replayOverflow so the NEXT
|
||||
// turn's budgeter trims aggressively (the recovery that un-bricks the chat).
|
||||
it('#490: a context-overflow 400 stamps replayOverflow on the finalized row', async () => {
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined as never);
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined as never);
|
||||
const { capturedOpts, aiChatMessageRepo } = await captureStreamCallbacks();
|
||||
|
||||
const overflow = Object.assign(new Error('too large'), {
|
||||
statusCode: 400,
|
||||
message:
|
||||
"This model's maximum context length is 128000 tokens. However, your messages resulted in 214000 tokens. Please reduce the length.",
|
||||
});
|
||||
await capturedOpts.onError({ error: overflow });
|
||||
|
||||
// The seed row exists (finalizeOwner is the owner-write path).
|
||||
expect(aiChatMessageRepo.finalizeOwner).toHaveBeenCalled();
|
||||
const calls = aiChatMessageRepo.finalizeOwner.mock.calls as any[][];
|
||||
const patch = calls[calls.length - 1][2] as {
|
||||
status: string;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
expect(patch.status).toBe('error');
|
||||
expect(patch.metadata.replayOverflow).toBe(true);
|
||||
expect(patch.metadata.error).toContain('контекстное окно');
|
||||
});
|
||||
|
||||
it('#490: a non-overflow error does NOT stamp replayOverflow', async () => {
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined as never);
|
||||
const { capturedOpts, aiChatMessageRepo } = await captureStreamCallbacks();
|
||||
await capturedOpts.onError({ error: new Error('network reset') });
|
||||
const calls = aiChatMessageRepo.finalizeOwner.mock.calls as any[][];
|
||||
const patch = calls[calls.length - 1][2] as {
|
||||
status: string;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
expect('replayOverflow' in patch.metadata).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
compactToolOutput,
|
||||
assistantParts,
|
||||
serializeSteps,
|
||||
type StepPartsCache,
|
||||
rowToUiMessage,
|
||||
prepareAgentStep,
|
||||
stepBudgetWarning,
|
||||
@@ -28,10 +29,14 @@ import {
|
||||
FINAL_STEP_NUDGE,
|
||||
STEP_LIMIT_NO_ANSWER_MARKER,
|
||||
OUTPUT_DEGENERATION_ERROR,
|
||||
lastAssistantContextTokens,
|
||||
lastAssistantReplayOverflow,
|
||||
seedActivatedTools,
|
||||
} from './ai-chat.service';
|
||||
import type { AiChatMessage, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { buildSystemPrompt } from './ai-chat.prompt';
|
||||
import type { McpClientsService } from './external-mcp/mcp-clients.service';
|
||||
import { resolveEffectiveReplayThreshold } from './history-budget';
|
||||
|
||||
/**
|
||||
* Unit tests for compactToolOutput: the pure helper that shrinks tool outputs
|
||||
@@ -114,6 +119,54 @@ describe('compactToolOutput', () => {
|
||||
describe('assistantParts', () => {
|
||||
type AnyPart = Record<string, unknown>;
|
||||
|
||||
// #490 memoization: assistantParts builds each step's parts once and caches
|
||||
// them by the step OBJECT's identity, so a mid-stream flush does not
|
||||
// re-stringify every prior step's (large) output. Observable property: with a
|
||||
// shared cache, the second call over the SAME step object returns the cached
|
||||
// (identical) part array even if the step's underlying output was swapped —
|
||||
// proving the work was memoized, not redone.
|
||||
it('memoizes a step by identity (shared cache => one build per step)', () => {
|
||||
const cache: StepPartsCache = new WeakMap();
|
||||
const step = {
|
||||
text: 'x',
|
||||
toolCalls: [{ toolCallId: 'c1', toolName: 'getPage', input: {} }],
|
||||
toolResults: [{ toolCallId: 'c1', toolName: 'getPage', output: { v: 1 } }],
|
||||
};
|
||||
const first = assistantParts([step], '', cache) as AnyPart[];
|
||||
expect((first.find((p) => p.type === 'tool-getPage')!.output as any).v).toBe(
|
||||
1,
|
||||
);
|
||||
// Swap the output for a NEW value; a re-build would pick it up, a cache hit
|
||||
// keeps the first result.
|
||||
step.toolResults[0] = {
|
||||
toolCallId: 'c1',
|
||||
toolName: 'getPage',
|
||||
output: { v: 2 },
|
||||
};
|
||||
const second = assistantParts([step], '', cache) as AnyPart[];
|
||||
expect((second.find((p) => p.type === 'tool-getPage')!.output as any).v).toBe(
|
||||
1,
|
||||
);
|
||||
// Same cached part objects are reused.
|
||||
expect(second.find((p) => p.type === 'tool-getPage')).toBe(
|
||||
first.find((p) => p.type === 'tool-getPage'),
|
||||
);
|
||||
});
|
||||
|
||||
it('without a cache, each call rebuilds (no stale memo)', () => {
|
||||
const step = {
|
||||
text: 'x',
|
||||
toolCalls: [{ toolCallId: 'c1', toolName: 'getPage', input: {} }],
|
||||
toolResults: [{ toolCallId: 'c1', toolName: 'getPage', output: { v: 1 } }],
|
||||
};
|
||||
const first = assistantParts([step], '') as AnyPart[];
|
||||
step.toolResults[0].output = { v: 2 };
|
||||
const second = assistantParts([step], '') as AnyPart[];
|
||||
expect((second.find((p) => p.type === 'tool-getPage')!.output as any).v).toBe(
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it('emits output-available for a tool-call WITH a paired result', () => {
|
||||
const steps = [
|
||||
{
|
||||
@@ -231,61 +284,320 @@ describe('assistantParts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeSteps', () => {
|
||||
// #490 trace format v2: per call the trace stores { input } for the call and an
|
||||
// OUTCOME element — { ok: true } on success, { error, kind: 'thrown' } on a
|
||||
// thrown tool-error, { error, kind: 'interrupted' } on a mid-step abort. The tool
|
||||
// OUTPUT is no longer duplicated here (it lives once in metadata.parts).
|
||||
describe('serializeSteps (trace v2)', () => {
|
||||
it('returns null when there are no calls or results', () => {
|
||||
expect(serializeSteps([])).toBeNull();
|
||||
});
|
||||
|
||||
it('flattens calls and results into a compact trace', () => {
|
||||
it('pairs a successful call with an { ok: true } outcome and NO output', () => {
|
||||
const trace = serializeSteps([
|
||||
{
|
||||
toolCalls: [{ toolName: 'getPage', input: { id: 'p1' } }],
|
||||
toolResults: [{ toolName: 'getPage', output: { title: 'T' } }],
|
||||
toolCalls: [{ toolCallId: 'c1', toolName: 'getPage', input: { id: 'p1' } }],
|
||||
toolResults: [{ toolCallId: 'c1', toolName: 'getPage' }],
|
||||
},
|
||||
]) as Array<Record<string, unknown>>;
|
||||
expect(trace).toHaveLength(2);
|
||||
expect(trace[0]).toEqual({ toolName: 'getPage', input: { id: 'p1' } });
|
||||
expect(trace[1]).toEqual({ toolName: 'getPage', output: { title: 'T' } });
|
||||
expect(trace[1]).toEqual({ toolName: 'getPage', ok: true });
|
||||
// The output is NOT stored in the trace any more (dedup: it lives in parts).
|
||||
expect(trace.some((e) => 'output' in e)).toBe(false);
|
||||
});
|
||||
|
||||
it('records a THROWN tool failure (tool-error part) with its error message', () => {
|
||||
it('records a THROWN failure with { error, kind: "thrown" }', () => {
|
||||
const trace = serializeSteps([
|
||||
{
|
||||
toolCalls: [{ toolName: 'editPageText', input: { id: 'p1' } }],
|
||||
toolCalls: [
|
||||
{ toolCallId: 'c1', toolName: 'editPageText', input: { id: 'p1' } },
|
||||
],
|
||||
toolResults: [],
|
||||
content: [
|
||||
{
|
||||
type: 'tool-error',
|
||||
toolCallId: 'c1',
|
||||
toolName: 'editPageText',
|
||||
error: new Error('page is locked'),
|
||||
},
|
||||
],
|
||||
},
|
||||
]) as Array<Record<string, unknown>>;
|
||||
// The call element is followed by a paired error element (mirroring how a
|
||||
// successful result is appended), so the failure survives in the trace.
|
||||
expect(trace).toHaveLength(2);
|
||||
expect(trace[0]).toEqual({ toolName: 'editPageText', input: { id: 'p1' } });
|
||||
expect(trace[1]).toEqual({
|
||||
toolName: 'editPageText',
|
||||
error: 'page is locked',
|
||||
kind: 'thrown',
|
||||
});
|
||||
});
|
||||
|
||||
it('truncates a very long tool-error message to the tool-output limit', () => {
|
||||
it('marks an interrupted call (no result, no throw) with kind "interrupted"', () => {
|
||||
const trace = serializeSteps([
|
||||
{
|
||||
toolCalls: [
|
||||
{ toolCallId: 'c1', toolName: 'createComment', input: { x: 1 } },
|
||||
],
|
||||
toolResults: [],
|
||||
content: [],
|
||||
},
|
||||
]) as Array<Record<string, unknown>>;
|
||||
expect(trace).toHaveLength(2);
|
||||
expect(trace[1]).toEqual({
|
||||
toolName: 'createComment',
|
||||
error: 'Tool call did not complete.',
|
||||
kind: 'interrupted',
|
||||
});
|
||||
// Structurally distinct from a thrown hard-fail so it never inflates an
|
||||
// error-rate scan.
|
||||
expect((trace[1] as { kind: string }).kind).not.toBe('thrown');
|
||||
});
|
||||
|
||||
it('truncates a very long thrown-error message to the tool-output limit', () => {
|
||||
const long = 'x'.repeat(5000);
|
||||
const trace = serializeSteps([
|
||||
{
|
||||
toolCalls: [{ toolName: 'editPageText', input: {} }],
|
||||
toolCalls: [{ toolCallId: 'c1', toolName: 'editPageText', input: {} }],
|
||||
toolResults: [],
|
||||
content: [{ type: 'tool-error', toolName: 'editPageText', error: long }],
|
||||
content: [
|
||||
{
|
||||
type: 'tool-error',
|
||||
toolCallId: 'c1',
|
||||
toolName: 'editPageText',
|
||||
error: long,
|
||||
},
|
||||
],
|
||||
},
|
||||
]) as Array<Record<string, unknown>>;
|
||||
const errorText = trace[1].error as string;
|
||||
// Truncated (not the full 5000 chars) and carries the omission marker.
|
||||
expect(errorText.length).toBeLessThan(long.length);
|
||||
expect(errorText).toContain('chars omitted');
|
||||
});
|
||||
|
||||
it('pairs parallel calls in one step with their outcomes by id', () => {
|
||||
const trace = serializeSteps([
|
||||
{
|
||||
toolCalls: [
|
||||
{ toolCallId: 'a', toolName: 'getPage', input: {} },
|
||||
{ toolCallId: 'b', toolName: 'searchPages', input: {} },
|
||||
],
|
||||
toolResults: [{ toolCallId: 'b', toolName: 'searchPages' }],
|
||||
content: [
|
||||
{ type: 'tool-error', toolCallId: 'a', toolName: 'getPage', error: 'nope' },
|
||||
],
|
||||
},
|
||||
]) as Array<Record<string, unknown>>;
|
||||
// call a, outcome a (thrown), call b, outcome b (ok)
|
||||
expect(trace).toHaveLength(4);
|
||||
expect(trace[1]).toEqual({ toolName: 'getPage', error: 'nope', kind: 'thrown' });
|
||||
expect(trace[3]).toEqual({ toolName: 'searchPages', ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
// #490: every assistant row flushAssistant writes carries the v2 era marker so a
|
||||
// dual-shape diagnostic query can branch on the trace shape without inspecting it.
|
||||
describe('toolTraceVersion era marker (#490)', () => {
|
||||
it('stamps metadata.toolTraceVersion = 2 on every flushed row', () => {
|
||||
const seed = flushAssistant([], '', 'streaming');
|
||||
expect(seed.metadata.toolTraceVersion).toBe(2);
|
||||
const done = flushAssistant(
|
||||
[
|
||||
{
|
||||
text: 'ok',
|
||||
toolCalls: [{ toolCallId: 'c1', toolName: 'getPage', input: {} }],
|
||||
toolResults: [{ toolCallId: 'c1', toolName: 'getPage' }],
|
||||
},
|
||||
],
|
||||
'',
|
||||
'completed',
|
||||
{ finishReason: 'stop' },
|
||||
);
|
||||
expect(done.metadata.toolTraceVersion).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// #490 replay-budget signal helpers over persisted history.
|
||||
describe('lastAssistantContextTokens', () => {
|
||||
const row = (
|
||||
role: string,
|
||||
metadata: Record<string, unknown> | null,
|
||||
): AiChatMessage => ({ role, metadata }) as unknown as AiChatMessage;
|
||||
|
||||
it('reads the most recent assistant turn contextTokens (provider fact)', () => {
|
||||
const hist = [
|
||||
row('user', null),
|
||||
row('assistant', { contextTokens: 12000 }),
|
||||
row('user', null),
|
||||
row('assistant', { contextTokens: 41000 }),
|
||||
];
|
||||
expect(lastAssistantContextTokens(hist)).toBe(41000);
|
||||
});
|
||||
|
||||
it('returns undefined when the last assistant turn recorded no usage', () => {
|
||||
const hist = [row('assistant', { error: 'boom' }), row('user', null)];
|
||||
expect(lastAssistantContextTokens(hist)).toBeUndefined();
|
||||
expect(lastAssistantContextTokens([])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// #490 snapshotOpenPage fast-path: skip the full Markdown export + upsert when a
|
||||
// snapshot already exists at the page's CURRENT version (same updated_at instant).
|
||||
describe('snapshotOpenPage fast-path (#490)', () => {
|
||||
function makeSvc(existingSnapshot: unknown, pageUpdatedAt: Date) {
|
||||
const exportPageMarkdown = jest.fn(async () => '# md');
|
||||
const upsert = jest.fn(async () => undefined);
|
||||
const findByChatPage = jest.fn(async () => existingSnapshot);
|
||||
const pageRepo = {
|
||||
findById: jest.fn(async () => ({
|
||||
id: 'p1',
|
||||
workspaceId: 'ws1',
|
||||
updatedAt: pageUpdatedAt,
|
||||
})),
|
||||
};
|
||||
const svc = new AiChatService(
|
||||
{} as never, // ai
|
||||
{} as never, // aiChatRepo
|
||||
{} as never, // aiChatMessageRepo
|
||||
{ findByChatPage, upsert } as never, // aiChatPageSnapshotRepo
|
||||
{} as never, // aiSettings
|
||||
{ exportPageMarkdown } as never, // tools
|
||||
{} as never, // mcpClients
|
||||
{} as never, // aiAgentRoleRepo
|
||||
pageRepo as never, // pageRepo
|
||||
{} as never, // pageAccess
|
||||
{} as never, // environment
|
||||
);
|
||||
return { svc, exportPageMarkdown, upsert, findByChatPage };
|
||||
}
|
||||
|
||||
const args = () =>
|
||||
[
|
||||
'chat1',
|
||||
'p1',
|
||||
{ id: 'ws1' } as never,
|
||||
{ id: 'u1' } as never,
|
||||
'sess',
|
||||
] as const;
|
||||
|
||||
it('skips export + upsert when the snapshot is already at this page version', async () => {
|
||||
const t = new Date('2026-07-07T10:00:00Z');
|
||||
const { svc, exportPageMarkdown, upsert } = makeSvc(
|
||||
{ pageUpdatedAt: t, contentMd: '# md' },
|
||||
t,
|
||||
);
|
||||
await (svc as unknown as { snapshotOpenPage: (...a: unknown[]) => Promise<void> })
|
||||
.snapshotOpenPage(...args());
|
||||
expect(exportPageMarkdown).not.toHaveBeenCalled();
|
||||
expect(upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exports + upserts when the page advanced since the snapshot', async () => {
|
||||
const { svc, exportPageMarkdown, upsert } = makeSvc(
|
||||
{ pageUpdatedAt: new Date('2026-07-07T10:00:00Z'), contentMd: 'old' },
|
||||
new Date('2026-07-07T11:00:00Z'),
|
||||
);
|
||||
await (svc as unknown as { snapshotOpenPage: (...a: unknown[]) => Promise<void> })
|
||||
.snapshotOpenPage(...args());
|
||||
expect(exportPageMarkdown).toHaveBeenCalledTimes(1);
|
||||
expect(upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('seeds (exports + upserts) on the first turn (no snapshot yet)', async () => {
|
||||
const { svc, exportPageMarkdown, upsert } = makeSvc(
|
||||
undefined,
|
||||
new Date('2026-07-07T10:00:00Z'),
|
||||
);
|
||||
await (svc as unknown as { snapshotOpenPage: (...a: unknown[]) => Promise<void> })
|
||||
.snapshotOpenPage(...args());
|
||||
expect(exportPageMarkdown).toHaveBeenCalledTimes(1);
|
||||
expect(upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// #490 deferred-tool activation persisted across turns.
|
||||
describe('seedActivatedTools', () => {
|
||||
const valid = new Set(['Search_web', 'getPageJson', 'diffPageVersions']);
|
||||
|
||||
it('seeds from persisted metadata, intersected with current valid names', () => {
|
||||
expect(
|
||||
seedActivatedTools(
|
||||
{ activatedTools: ['Search_web', 'getPageJson'] },
|
||||
valid,
|
||||
),
|
||||
).toEqual(['Search_web', 'getPageJson']);
|
||||
});
|
||||
|
||||
it('drops a stored tool that is no longer valid (allowlist/role changed)', () => {
|
||||
// 'Habr_publish' was activated before but is not in the current allowlist.
|
||||
expect(
|
||||
seedActivatedTools({ activatedTools: ['Search_web', 'Habr_publish'] }, valid),
|
||||
).toEqual(['Search_web']);
|
||||
});
|
||||
|
||||
it('is empty/robust for missing, non-array, or unknown-shaped metadata', () => {
|
||||
expect(seedActivatedTools(undefined, valid)).toEqual([]);
|
||||
expect(seedActivatedTools({}, valid)).toEqual([]);
|
||||
expect(seedActivatedTools({ activatedTools: 'nope' }, valid)).toEqual([]);
|
||||
expect(
|
||||
seedActivatedTools({ activatedTools: [1, 'getPageJson', null] }, valid),
|
||||
).toEqual(['getPageJson']);
|
||||
});
|
||||
|
||||
it('de-duplicates stored names', () => {
|
||||
expect(
|
||||
seedActivatedTools(
|
||||
{ activatedTools: ['getPageJson', 'getPageJson'] },
|
||||
valid,
|
||||
),
|
||||
).toEqual(['getPageJson']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lastAssistantReplayOverflow', () => {
|
||||
const row = (
|
||||
role: string,
|
||||
metadata: Record<string, unknown> | null,
|
||||
): AiChatMessage => ({ role, metadata }) as unknown as AiChatMessage;
|
||||
|
||||
it('is true only when the LAST assistant turn overflowed', () => {
|
||||
expect(
|
||||
lastAssistantReplayOverflow([
|
||||
row('assistant', { replayOverflow: true }),
|
||||
row('user', null),
|
||||
]),
|
||||
).toBe(true);
|
||||
// A recovered (later, non-overflow) assistant turn clears it.
|
||||
expect(
|
||||
lastAssistantReplayOverflow([
|
||||
row('assistant', { replayOverflow: true }),
|
||||
row('user', null),
|
||||
row('assistant', { contextTokens: 5 }),
|
||||
]),
|
||||
).toBe(false);
|
||||
expect(lastAssistantReplayOverflow([])).toBe(false);
|
||||
});
|
||||
|
||||
// #490 reactive recovery: a prior turn stamped `replayOverflow` must make the
|
||||
// NEXT turn's effective budget the AGGRESSIVE 0.5x cut — that harder trim is
|
||||
// what un-bricks a chat that just 400'd on the context window. This exercises
|
||||
// the exact wiring the service uses: read the stamp, then scale the threshold.
|
||||
it('#490: a prior replayOverflow drives the next turn to the 0.5x aggressive budget', () => {
|
||||
const history = [
|
||||
row('assistant', { replayOverflow: true }),
|
||||
row('user', null),
|
||||
];
|
||||
const priorOverflowed = lastAssistantReplayOverflow(history);
|
||||
expect(priorOverflowed).toBe(true);
|
||||
// Base budget 100k -> aggressive recovery halves it to 50k this turn.
|
||||
expect(resolveEffectiveReplayThreshold(100_000, priorOverflowed)).toBe(50_000);
|
||||
// Odd base floors, not rounds.
|
||||
expect(resolveEffectiveReplayThreshold(99_999, true)).toBe(49_999);
|
||||
// No prior overflow -> the base budget is used verbatim (no aggressive cut).
|
||||
expect(resolveEffectiveReplayThreshold(100_000, false)).toBe(100_000);
|
||||
// An explicit off-switch (null) is never overridden, even on recovery.
|
||||
expect(resolveEffectiveReplayThreshold(null, true)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rowToUiMessage', () => {
|
||||
@@ -618,6 +930,23 @@ describe('flushAssistant', () => {
|
||||
expect(flushed.metadata.error).toBe('boom');
|
||||
});
|
||||
|
||||
// #490 observability: the replay budgeter's decision is stamped on the turn.
|
||||
it('records replayTrimmedToTokens + replayOverflow when provided', () => {
|
||||
const f = flushAssistant([], '', 'error', {
|
||||
error: 'ctx',
|
||||
replayTrimmedToTokens: 42_000,
|
||||
replayOverflow: true,
|
||||
});
|
||||
expect(f.metadata.replayTrimmedToTokens).toBe(42_000);
|
||||
expect(f.metadata.replayOverflow).toBe(true);
|
||||
});
|
||||
|
||||
it('omits the replay metadata when not provided', () => {
|
||||
const f = flushAssistant([], '', 'completed', { finishReason: 'stop' });
|
||||
expect('replayTrimmedToTokens' in f.metadata).toBe(false);
|
||||
expect('replayOverflow' in f.metadata).toBe(false);
|
||||
});
|
||||
|
||||
// #274 observability: the page-change diff the agent saw this turn is persisted
|
||||
// to metadata.pageChanged when a non-empty diff was injected, and omitted when
|
||||
// the diff is empty/whitespace or the arg is not supplied.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
import { flushAssistant } from './ai-chat.service';
|
||||
|
||||
/**
|
||||
* #491 STEP MARKER — `metadata.stepsPersisted` is written by the SAME flush that
|
||||
* builds `metadata.parts`, so the marker can never disagree with the persisted
|
||||
* parts (the step-alignment anchor the resume stack builds on). These are
|
||||
* PROPERTY tests: they assert the marker tracks the number of FINISHED steps for
|
||||
* every flush shape.
|
||||
*/
|
||||
|
||||
// A finished step carrying one line of text and one tool call/result.
|
||||
function step(i: number) {
|
||||
return {
|
||||
text: `step ${i}`,
|
||||
toolCalls: [
|
||||
{ toolCallId: `c${i}`, toolName: 'getPage', input: { id: `p${i}` } },
|
||||
],
|
||||
toolResults: [
|
||||
{ toolCallId: `c${i}`, toolName: 'getPage', output: { title: `T${i}` } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('flushAssistant step marker (#491)', () => {
|
||||
it('seed (no steps) → stepsPersisted 0', () => {
|
||||
const f = flushAssistant([], '', 'streaming');
|
||||
expect(f.metadata.stepsPersisted).toBe(0);
|
||||
});
|
||||
|
||||
it('PROPERTY: stepsPersisted equals the number of FINISHED steps, for any N', () => {
|
||||
for (let n = 0; n <= 6; n++) {
|
||||
const steps = Array.from({ length: n }, (_, i) => step(i));
|
||||
const f = flushAssistant(steps, '', 'streaming');
|
||||
expect(f.metadata.stepsPersisted).toBe(n);
|
||||
// ...and the parts actually contain those N steps' text (marker agrees with
|
||||
// the persisted parts — the atomicity the whole design relies on).
|
||||
const parts = f.metadata.parts as Array<Record<string, unknown>>;
|
||||
const textParts = parts.filter((p) => p.type === 'text');
|
||||
expect(textParts).toHaveLength(n);
|
||||
}
|
||||
});
|
||||
|
||||
it('an in-progress trailing partial does NOT increment the marker', () => {
|
||||
// 2 finished steps + a partial (not-yet-finished) trailing text: the marker
|
||||
// counts only the CONFIRMED step boundaries, not the partial.
|
||||
const f = flushAssistant([step(0), step(1)], 'partial third step', 'error', {
|
||||
error: 'boom',
|
||||
});
|
||||
expect(f.metadata.stepsPersisted).toBe(2);
|
||||
// The partial text IS persisted in parts (so the user sees it), but it is not a
|
||||
// counted step.
|
||||
const parts = f.metadata.parts as Array<Record<string, unknown>>;
|
||||
expect(parts[parts.length - 1]).toEqual({
|
||||
type: 'text',
|
||||
text: 'partial third step',
|
||||
});
|
||||
});
|
||||
|
||||
it('terminal completed flush counts all finished steps', () => {
|
||||
const f = flushAssistant([step(0), step(1), step(2)], '', 'completed', {
|
||||
finishReason: 'stop',
|
||||
});
|
||||
expect(f.metadata.stepsPersisted).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import { Client } from 'pg';
|
||||
import { flushAssistant, serializeSteps } from './ai-chat.service';
|
||||
|
||||
/**
|
||||
* #490 write-volume regression — an OBSERVABLE-PROPERTY test on a LIVE Postgres,
|
||||
* not "bytes through a mock repo" (a mock measures exactly the thing that does not
|
||||
* hurt). It drives a realistic 50-step run where each step returns a ~100 KB tool
|
||||
* output and, at every `onStepFinish`, UPDATEs the assistant row the way the
|
||||
* service does — then reads the REAL write volume via the `pg_current_wal_lsn()`
|
||||
* delta around the run.
|
||||
*
|
||||
* The property proven: v2 stores each tool OUTPUT only in `metadata.parts`, no
|
||||
* longer ALSO in the `tool_calls` trace. So:
|
||||
* 1. the trace (`tool_calls`) column's write volume is now O(Σ steps) — tiny,
|
||||
* linear outcome flags — vs the pre-#490 O(N²) that re-persisted every prior
|
||||
* output on every step; and
|
||||
* 2. the FULL-row write volume drops sharply (the duplicated output copy is gone).
|
||||
*
|
||||
* Connects to the local gitmost test Postgres (docker `gitmost-test-pg` on :5432);
|
||||
* SKIPS cleanly when that DB is not reachable so it never breaks a DB-less CI.
|
||||
*/
|
||||
const CONN =
|
||||
process.env.WAL_TEST_DATABASE_URL ??
|
||||
'postgresql://docmost:docmost_dev_pw@localhost:5432/docmost';
|
||||
|
||||
// A step whose tool output is ~100 KB (a page read), in the SDK StepLike shape.
|
||||
// The body is INCOMPRESSIBLE random text — a `'x'.repeat()` filler would TOAST-
|
||||
// compress to nothing and hide the real write volume (a page body does not).
|
||||
function makeStep(i: number, outputBytes = 100_000) {
|
||||
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 },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// The pre-#490 (v1) trace: outputs stored a SECOND time in `tool_calls`
|
||||
// (the duplication #490 removed). Mirrors the OLD serializeSteps shape.
|
||||
function v1Trace(steps: ReturnType<typeof makeStep>[]): unknown {
|
||||
const calls: unknown[] = [];
|
||||
for (const s of steps) {
|
||||
for (const c of s.toolCalls) calls.push({ toolName: c.toolName, input: c.input });
|
||||
for (const r of s.toolResults)
|
||||
calls.push({ toolName: r.toolName, output: r.output });
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
async function walDelta(
|
||||
client: Client,
|
||||
fn: () => Promise<void>,
|
||||
): Promise<number> {
|
||||
const before = (await client.query('SELECT pg_current_wal_lsn() AS l')).rows[0]
|
||||
.l as string;
|
||||
await fn();
|
||||
// NOTE: do NOT pg_switch_wal() here — a segment switch pads the LSN to the next
|
||||
// 16 MB boundary and would swamp the actual write delta. The raw LSN advances by
|
||||
// the bytes of WAL emitted, which is exactly what we want to measure.
|
||||
const after = (await client.query('SELECT pg_current_wal_lsn() AS l')).rows[0]
|
||||
.l as string;
|
||||
return Number(
|
||||
(await client.query('SELECT pg_wal_lsn_diff($1,$2) AS d', [after, before]))
|
||||
.rows[0].d,
|
||||
);
|
||||
}
|
||||
|
||||
describe('#490 write-volume on a live Postgres (pg_current_wal_lsn delta)', () => {
|
||||
let client: Client | undefined;
|
||||
let available = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
try {
|
||||
client = new Client(CONN);
|
||||
await client.connect();
|
||||
await client.query('SELECT pg_current_wal_lsn()');
|
||||
available = true;
|
||||
} catch {
|
||||
available = false;
|
||||
client = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await client?.end().catch(() => undefined);
|
||||
});
|
||||
|
||||
const STEPS = 50;
|
||||
|
||||
it('v2 trace write volume is O(Σ steps) — a tiny fraction of the v1 duplicate', async () => {
|
||||
if (!available || !client) {
|
||||
console.warn('SKIP: gitmost-test-pg not reachable; skipping WAL test.');
|
||||
return;
|
||||
}
|
||||
const c = client;
|
||||
// Isolated table so we measure only the tool_calls (trace) column's writes.
|
||||
await c.query('DROP TABLE IF EXISTS _wal_trace');
|
||||
await c.query('CREATE TABLE _wal_trace(id int primary key, tool_calls jsonb)');
|
||||
await c.query("INSERT INTO _wal_trace VALUES (1, '[]'::jsonb)");
|
||||
|
||||
const steps: ReturnType<typeof makeStep>[] = [];
|
||||
|
||||
// v1: each step re-persists ALL prior outputs into the trace (the O(N²) churn).
|
||||
const v1 = await walDelta(c, async () => {
|
||||
const acc: ReturnType<typeof makeStep>[] = [];
|
||||
for (let i = 0; i < STEPS; i++) {
|
||||
acc.push(makeStep(i));
|
||||
await c.query('UPDATE _wal_trace SET tool_calls=$1 WHERE id=1', [
|
||||
JSON.stringify(v1Trace(acc)),
|
||||
]);
|
||||
}
|
||||
steps.push(...acc);
|
||||
});
|
||||
|
||||
await c.query("UPDATE _wal_trace SET tool_calls='[]'::jsonb WHERE id=1");
|
||||
|
||||
// v2: the REAL serializeSteps — outcome flags only, NO outputs.
|
||||
const v2 = await walDelta(c, async () => {
|
||||
const acc: ReturnType<typeof makeStep>[] = [];
|
||||
for (let i = 0; i < STEPS; i++) {
|
||||
acc.push(makeStep(i));
|
||||
await c.query('UPDATE _wal_trace SET tool_calls=$1 WHERE id=1', [
|
||||
JSON.stringify(serializeSteps(acc)),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
await c.query('DROP TABLE IF EXISTS _wal_trace');
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[#490 WAL] trace column over ${STEPS} steps: v1=${(v1 / 1e6).toFixed(1)}MB ` +
|
||||
`v2=${(v2 / 1e6).toFixed(2)}MB (${(v1 / v2).toFixed(0)}x smaller)`,
|
||||
);
|
||||
|
||||
// The trace no longer carries outputs: v2 is a tiny fraction of v1's WAL.
|
||||
expect(v2).toBeLessThan(v1 * 0.1);
|
||||
// And v2's trace WAL is small in absolute terms — O(Σ steps) of flags, not
|
||||
// O(N² × output). 50 steps of ~40-byte flags is well under a few MB of WAL.
|
||||
expect(v2).toBeLessThan(5_000_000);
|
||||
// v1's duplicate alone is huge (≈ the 100 KB output re-written N² times).
|
||||
expect(v1).toBeGreaterThan(50_000_000);
|
||||
}, 120_000);
|
||||
|
||||
it('the full assistant row write drops sharply once the duplicate is gone', async () => {
|
||||
if (!available || !client) return;
|
||||
const c = client;
|
||||
await c.query('DROP TABLE IF EXISTS _wal_full');
|
||||
await c.query(
|
||||
'CREATE TABLE _wal_full(id int primary key, content text, tool_calls jsonb, metadata jsonb, status text)',
|
||||
);
|
||||
await c.query("INSERT INTO _wal_full VALUES (1, '', '[]'::jsonb, '{}'::jsonb, 'streaming')");
|
||||
|
||||
const writeRow = async (patch: {
|
||||
content: string;
|
||||
toolCalls: unknown;
|
||||
metadata: unknown;
|
||||
status: string;
|
||||
}) =>
|
||||
c.query(
|
||||
'UPDATE _wal_full SET content=$1, tool_calls=$2, metadata=$3, status=$4 WHERE id=1',
|
||||
[
|
||||
patch.content,
|
||||
JSON.stringify(patch.toolCalls ?? null),
|
||||
JSON.stringify(patch.metadata),
|
||||
patch.status,
|
||||
],
|
||||
);
|
||||
|
||||
// v2 (real flushAssistant): outputs live once, in metadata.parts.
|
||||
const v2 = await walDelta(c, async () => {
|
||||
const acc: ReturnType<typeof makeStep>[] = [];
|
||||
for (let i = 0; i < STEPS; i++) {
|
||||
acc.push(makeStep(i));
|
||||
await writeRow(flushAssistant(acc as never, '', 'streaming'));
|
||||
}
|
||||
});
|
||||
|
||||
await c.query("UPDATE _wal_full SET content='', tool_calls='[]'::jsonb, metadata='{}'::jsonb WHERE id=1");
|
||||
|
||||
// v1: same row PLUS the duplicated outputs in the trace column.
|
||||
const v1 = await walDelta(c, async () => {
|
||||
const acc: ReturnType<typeof makeStep>[] = [];
|
||||
for (let i = 0; i < STEPS; i++) {
|
||||
acc.push(makeStep(i));
|
||||
const f = flushAssistant(acc as never, '', 'streaming');
|
||||
await writeRow({ ...f, toolCalls: v1Trace(acc) });
|
||||
}
|
||||
});
|
||||
|
||||
await c.query('DROP TABLE IF EXISTS _wal_full');
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[#490 WAL] full row over ${STEPS} steps: v1=${(v1 / 1e6).toFixed(1)}MB ` +
|
||||
`v2=${(v2 / 1e6).toFixed(1)}MB (saved ${((1 - v2 / v1) * 100).toFixed(0)}%)`,
|
||||
);
|
||||
|
||||
// Removing the duplicated trace copy is a large, real write-volume reduction.
|
||||
expect(v2).toBeLessThan(v1 * 0.75);
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
import { buildChatMarkdown, normalizeLang } from './chat-markdown.util';
|
||||
import {
|
||||
buildChatMarkdown,
|
||||
normalizeLang,
|
||||
labelledToolNames,
|
||||
} from './chat-markdown.util';
|
||||
import type { AiChatMessage } from '@docmost/db/types/entity.types';
|
||||
import { SHARED_TOOL_SPECS } from '../../../../../packages/mcp/src/tool-specs';
|
||||
|
||||
/**
|
||||
* normalizeLang: the client sends `i18n.language` — a FULL locale tag like
|
||||
@@ -455,3 +460,43 @@ describe('buildChatMarkdown (server) — structure', () => {
|
||||
expect(md).toContain('````');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #494 — REVERSE drift-guard for the export's friendly tool labels. A label keyed
|
||||
* by a tool name that no longer exists silently degrades to the generic
|
||||
* "Ran tool <name>" line; nothing reddened before. This asserts every labelled
|
||||
* name is a real in-app tool and that both languages label the same set.
|
||||
*/
|
||||
describe('tool-label parity (#494)', () => {
|
||||
// In-app tool names come from the shared registry (inAppKey, excluding
|
||||
// mcpOnly specs) PLUS the inline in-app-only tools that carry a friendly label.
|
||||
// The only labelled inline tool is the hybrid semantic search.
|
||||
const INLINE_INAPP_LABELLED = new Set(['searchPages']);
|
||||
|
||||
function validInAppToolNames(): Set<string> {
|
||||
const names = new Set<string>(INLINE_INAPP_LABELLED);
|
||||
for (const spec of Object.values(SHARED_TOOL_SPECS)) {
|
||||
if ((spec as { mcpOnly?: boolean }).mcpOnly) continue;
|
||||
names.add((spec as { inAppKey: string }).inAppKey);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
it('en and ru label the SAME set of tools', () => {
|
||||
expect(labelledToolNames('en').sort()).toEqual(
|
||||
labelledToolNames('ru').sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('every labelled tool name is a real in-app tool', () => {
|
||||
const valid = validInAppToolNames();
|
||||
const dead = labelledToolNames('en').filter((n) => !valid.has(n));
|
||||
expect(dead).toEqual([]);
|
||||
});
|
||||
|
||||
it('the guard REDDENS for an unknown label key (mutation check)', () => {
|
||||
const valid = validInAppToolNames();
|
||||
// A hypothetical renamed-away label must be caught.
|
||||
expect(valid.has('getPageRenamedAway')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,6 +154,17 @@ function toolLabel(name: string, lang: ExportLang): string {
|
||||
return LABELS[lang].tools[name] ?? LABELS[lang].ranTool(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tool names that carry a hand-written friendly export label, per language.
|
||||
* Exported for the drift-guard (#494): a label keyed by a tool name that no
|
||||
* longer exists is a DEAD entry (the tool was renamed and now silently falls back
|
||||
* to the generic `ranTool(name)` line). The guard asserts every key here is a
|
||||
* real in-app tool AND that the two languages label the SAME set of tools.
|
||||
*/
|
||||
export function labelledToolNames(lang: ExportLang): string[] {
|
||||
return Object.keys(LABELS[lang].tools);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stringify an arbitrary tool input/output value for a fenced block. Strings
|
||||
* pass through as-is; everything else is pretty-printed JSON, falling back to
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
import {
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
/** Identify a chat by id (workspace-scoped on the server). */
|
||||
export class ChatIdDto {
|
||||
@@ -37,6 +43,24 @@ export class GetChatMessagesDto {
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delta poll (#491): pull the chat's rows changed since `cursor` (a DB-clock
|
||||
* timestamp from the previous poll) plus the current run fact — the degraded-poll
|
||||
* fallback's payload, replacing the full infinite-query refetch. Omit `cursor` on
|
||||
* the first poll (returns just a fresh cursor to start the chain).
|
||||
*/
|
||||
export class GetChatDeltaDto {
|
||||
@IsString()
|
||||
chatId: string;
|
||||
|
||||
// ISO-8601 timestamp echoed from the previous poll's response. Validated as
|
||||
// ISO-8601 (not a bare string): a malformed cursor would otherwise reach the
|
||||
// `::timestamptz` cast in findByChatUpdatedAfter and 500 instead of a clean 400.
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
/** Resolve the chat bound to a document (the page's most-recent owned chat). */
|
||||
export class BoundChatDto {
|
||||
@IsString()
|
||||
|
||||
@@ -37,10 +37,13 @@ export class CreateMcpServerDto {
|
||||
@IsObject()
|
||||
headers?: Record<string, string>;
|
||||
|
||||
// Omit/null => no restriction; `[]` is persisted verbatim and means deny-all
|
||||
// (zero tools) since #476. @IsOptional() skips validation for null as well,
|
||||
// so an explicit null is accepted.
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
toolAllowlist?: string[];
|
||||
toolAllowlist?: string[] | null;
|
||||
|
||||
// Admin-authored guidance ("how/when to use this server's tools") injected
|
||||
// into the agent system prompt next to the tool descriptions (#180). Trusted,
|
||||
|
||||
@@ -38,10 +38,13 @@ export class UpdateMcpServerDto {
|
||||
@IsObject()
|
||||
headers?: Record<string, string>;
|
||||
|
||||
// Absent => unchanged; null => no restriction; `[]` is persisted verbatim
|
||||
// and means deny-all (zero tools) since #476. @IsOptional() skips validation
|
||||
// for null as well, so an explicit null is accepted.
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
toolAllowlist?: string[];
|
||||
toolAllowlist?: string[] | null;
|
||||
|
||||
// Admin-authored prompt guidance (#180). Absent => unchanged; blank => cleared
|
||||
// (stored as null by the repo). Capped to bound prompt/token size.
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { type Tool } from 'ai';
|
||||
import { McpClientsService } from './mcp-clients.service';
|
||||
|
||||
/**
|
||||
* Tool-allowlist filtering semantics on the merged external toolset (#476).
|
||||
*
|
||||
* COVERAGE CHOICE (documented per issue #476): the full corrupt-row chain
|
||||
* (DB value -> repo normalizeRow -> toolsFor filter) is covered on TWO levels
|
||||
* instead of one live-stub-MCP-server integration test:
|
||||
* (a) apps/server/test/integration/ai-mcp-server-repo.int-spec.ts pins the
|
||||
* repo read/write semantics against a real Postgres — `[]` round-trips
|
||||
* as jsonb `[]`, a present-but-corrupt value fails CLOSED to `[]` with
|
||||
* an error log;
|
||||
* (b) THIS spec pins what the toolset builder does with the repo's output —
|
||||
* null = unrestricted, `['alpha']` = only alpha, `[]` (including the
|
||||
* corrupt-row fallback) = ZERO tools.
|
||||
* Together they prove the end-to-end property "corrupt/empty allowlist can
|
||||
* never widen to all tools" without a live stub HTTP MCP server.
|
||||
*
|
||||
* The drive path mirrors mcp-namespacing.spec.ts: stub the repo's listEnabled,
|
||||
* spy the private `connect` to return a fake client, inspect the merged keys.
|
||||
*/
|
||||
|
||||
function fakeTool(): Tool {
|
||||
return { description: 'x', inputSchema: undefined } as unknown as Tool;
|
||||
}
|
||||
|
||||
interface FakeServer {
|
||||
id: string;
|
||||
name: string;
|
||||
transport: string;
|
||||
url: string;
|
||||
headersEnc: string | null;
|
||||
toolAllowlist: string[] | null;
|
||||
}
|
||||
|
||||
function server(
|
||||
over: Partial<FakeServer> & { id: string; name: string },
|
||||
): FakeServer {
|
||||
return {
|
||||
transport: 'http',
|
||||
url: 'https://example.com/mcp',
|
||||
headersEnc: null,
|
||||
toolAllowlist: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a service whose repo returns `servers` and whose fake clients expose
|
||||
* `rawTools` from tools(). Returns the merged tool keys produced by toolsFor.
|
||||
*/
|
||||
async function mergedKeysFor(
|
||||
servers: FakeServer[],
|
||||
rawTools: Record<string, Tool>,
|
||||
): Promise<string[]> {
|
||||
const repoStub = {
|
||||
listEnabled: jest.fn().mockResolvedValue(servers),
|
||||
};
|
||||
const service = new McpClientsService(repoStub as never, {} as never);
|
||||
|
||||
jest
|
||||
.spyOn(
|
||||
service as unknown as { connect: (s: FakeServer) => unknown },
|
||||
'connect',
|
||||
)
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
tools: () => Promise.resolve(rawTools),
|
||||
close: () => Promise.resolve(),
|
||||
}),
|
||||
);
|
||||
|
||||
const toolset = await service.toolsFor('ws-1');
|
||||
// Release the lease so the service does not hold the fake clients open.
|
||||
await Promise.all(toolset.clients.map((c) => c.close()));
|
||||
return Object.keys(toolset.tools);
|
||||
}
|
||||
|
||||
describe('external MCP tool-allowlist filtering (via toolsFor, #476)', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
const RAW = () => ({
|
||||
alpha: fakeTool(),
|
||||
beta: fakeTool(),
|
||||
gamma: fakeTool(),
|
||||
});
|
||||
|
||||
it("['alpha'] lets ONLY alpha through", async () => {
|
||||
const keys = await mergedKeysFor(
|
||||
[server({ id: 'id-1', name: 'srv', toolAllowlist: ['alpha'] })],
|
||||
RAW(),
|
||||
);
|
||||
expect(keys).toEqual(['srv_alpha']);
|
||||
});
|
||||
|
||||
it('null (no restriction) lets every tool through', async () => {
|
||||
const keys = await mergedKeysFor(
|
||||
[server({ id: 'id-1', name: 'srv', toolAllowlist: null })],
|
||||
RAW(),
|
||||
);
|
||||
expect(keys.sort()).toEqual(['srv_alpha', 'srv_beta', 'srv_gamma']);
|
||||
});
|
||||
|
||||
it('[] (deny-all) yields ZERO tools — an empty array is authoritative, not falsy (#476)', async () => {
|
||||
// This is the regression the #476 change guards: `[]` used to fall through
|
||||
// the old `allow.length > 0` check and expose ALL tools. It must expose NONE.
|
||||
const keys = await mergedKeysFor(
|
||||
[server({ id: 'id-1', name: 'srv', toolAllowlist: [] })],
|
||||
RAW(),
|
||||
);
|
||||
expect(keys).toEqual([]);
|
||||
});
|
||||
|
||||
it('the corrupt-row fallback ([] from the repo) also yields ZERO tools (#476)', async () => {
|
||||
// The repo turns a present-but-corrupt tool_allowlist into `[]` (fail-closed,
|
||||
// see normalizeRow in ai-mcp-server.repo.ts + the int-spec); this pins that
|
||||
// the toolset builder honours that fallback as deny-all rather than allow-all.
|
||||
const corruptFallback: string[] = [];
|
||||
const keys = await mergedKeysFor(
|
||||
[server({ id: 'id-1', name: 'srv', toolAllowlist: corruptFallback })],
|
||||
RAW(),
|
||||
);
|
||||
expect(keys).toEqual([]);
|
||||
});
|
||||
|
||||
it('allowlisted names not exposed by the server are ignored (no phantom tools)', async () => {
|
||||
const keys = await mergedKeysFor(
|
||||
[
|
||||
server({
|
||||
id: 'id-1',
|
||||
name: 'srv',
|
||||
toolAllowlist: ['alpha', 'does-not-exist'],
|
||||
}),
|
||||
],
|
||||
RAW(),
|
||||
);
|
||||
expect(keys).toEqual(['srv_alpha']);
|
||||
});
|
||||
|
||||
it('a deny-all server contributes no prompt instructions (0 tools merged)', async () => {
|
||||
const repoStub = {
|
||||
listEnabled: jest.fn().mockResolvedValue([
|
||||
{
|
||||
...server({ id: 'id-1', name: 'srv', toolAllowlist: [] }),
|
||||
instructions: 'use the tools wisely',
|
||||
},
|
||||
]),
|
||||
};
|
||||
const service = new McpClientsService(repoStub as never, {} as never);
|
||||
jest
|
||||
.spyOn(
|
||||
service as unknown as { connect: (s: FakeServer) => unknown },
|
||||
'connect',
|
||||
)
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
tools: () => Promise.resolve(RAW()),
|
||||
close: () => Promise.resolve(),
|
||||
}),
|
||||
);
|
||||
|
||||
const toolset = await service.toolsFor('ws-1');
|
||||
await Promise.all(toolset.clients.map((c) => c.close()));
|
||||
expect(Object.keys(toolset.tools)).toEqual([]);
|
||||
// mergeNamespaced reported 0 contributed tools, so no guidance is attached.
|
||||
expect(toolset.instructions).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -444,9 +444,13 @@ export class McpClientsService {
|
||||
try {
|
||||
client = await this.connectWithTimeout(server, CONNECT_TIMEOUT_MS);
|
||||
const raw = await withTimeout(client.tools(), CONNECT_TIMEOUT_MS);
|
||||
// Allowlist semantics (#476): null/absent = no restriction (all tools);
|
||||
// ANY array — including `[]` — is authoritative, so an EMPTY allowlist
|
||||
// yields ZERO tools (deny-all). Do NOT add a `.length > 0` escape here:
|
||||
// that read `[]` as falsy and silently widened deny-all to allow-all
|
||||
// (the repo also fails corrupt rows closed to `[]` for the same reason).
|
||||
const allow = server.toolAllowlist;
|
||||
const picked =
|
||||
Array.isArray(allow) && allow.length > 0 ? pick(raw, allow) : raw;
|
||||
const picked = Array.isArray(allow) ? pick(raw, allow) : raw;
|
||||
// Bound each tool's execute with a per-call total-timeout guard before
|
||||
// merging, so a single chatty-but-stuck call is aborted after the cap.
|
||||
const guarded = wrapToolsWithCallTimeout(picked, callTimeoutMs);
|
||||
|
||||
@@ -100,7 +100,8 @@ export class McpServersService {
|
||||
transport: dto.transport,
|
||||
url: dto.url,
|
||||
headersEnc,
|
||||
// undefined => unchanged; [] / value handled by repo (empty => null).
|
||||
// undefined => unchanged; null => no restriction; `[]` is persisted
|
||||
// verbatim and means deny-all (#476).
|
||||
toolAllowlist: dto.toolAllowlist,
|
||||
// undefined => unchanged; blank => cleared (null) by the repo.
|
||||
instructions: dto.instructions,
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import type { ModelMessage } from 'ai';
|
||||
import {
|
||||
resolveReplayBudget,
|
||||
isContextOverflowError,
|
||||
estimateMessagesTokens,
|
||||
trimHistoryForReplay,
|
||||
REPLAY_BUDGET_DEFAULT_TOKENS,
|
||||
REPLAY_TRUNCATION_MARKER,
|
||||
REPLAY_TURN_COLLAPSED_MARKER,
|
||||
} from './history-budget';
|
||||
|
||||
describe('resolveReplayBudget', () => {
|
||||
it('uses floor(0.7 x window) for a configured window (no cap)', () => {
|
||||
// 0.7 x 60k = 42k
|
||||
expect(resolveReplayBudget(60_000)).toEqual({
|
||||
thresholdTokens: 42_000,
|
||||
usedDefault: false,
|
||||
});
|
||||
// 0.7 x 1M = 700k — NOT capped (anti-brick vs the window, not a cost limiter).
|
||||
expect(resolveReplayBudget(1_000_000)).toEqual({
|
||||
thresholdTokens: 700_000,
|
||||
usedDefault: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts the raw ::text stored form', () => {
|
||||
expect(resolveReplayBudget('60000').thresholdTokens).toBe(42_000);
|
||||
});
|
||||
|
||||
// The crux (#490): a chat with NO context window configured must STILL be
|
||||
// budgeted — those are exactly the installs that hit terminal overflow.
|
||||
it('applies the flat default when the window is unset/empty', () => {
|
||||
expect(resolveReplayBudget(undefined)).toEqual({
|
||||
thresholdTokens: REPLAY_BUDGET_DEFAULT_TOKENS,
|
||||
usedDefault: true,
|
||||
});
|
||||
expect(resolveReplayBudget('')).toEqual({
|
||||
thresholdTokens: REPLAY_BUDGET_DEFAULT_TOKENS,
|
||||
usedDefault: true,
|
||||
});
|
||||
expect(resolveReplayBudget(' ')).toEqual({
|
||||
thresholdTokens: REPLAY_BUDGET_DEFAULT_TOKENS,
|
||||
usedDefault: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an explicit 0 as the off-switch (distinct from unset)', () => {
|
||||
expect(resolveReplayBudget(0)).toEqual({
|
||||
thresholdTokens: null,
|
||||
usedDefault: false,
|
||||
});
|
||||
expect(resolveReplayBudget('0')).toEqual({
|
||||
thresholdTokens: null,
|
||||
usedDefault: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the default on a negative/garbage value', () => {
|
||||
expect(resolveReplayBudget(-5).usedDefault).toBe(true);
|
||||
expect(resolveReplayBudget('abc').usedDefault).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isContextOverflowError', () => {
|
||||
it('classifies a real provider 400 context-overflow shape', () => {
|
||||
// OpenAI-compatible shape.
|
||||
expect(
|
||||
isContextOverflowError({
|
||||
statusCode: 400,
|
||||
message:
|
||||
"This model's maximum context length is 128000 tokens. However, your messages resulted in 214000 tokens. Please reduce the length of the messages.",
|
||||
}),
|
||||
).toBe(true);
|
||||
// Anthropic-style wording.
|
||||
expect(
|
||||
isContextOverflowError({
|
||||
status: 400,
|
||||
message: 'prompt is too long: 250000 tokens > 200000 maximum',
|
||||
}),
|
||||
).toBe(true);
|
||||
// Nested body + string status.
|
||||
expect(
|
||||
isContextOverflowError({
|
||||
response: { status: '400' },
|
||||
message: 'input is too long for the requested model',
|
||||
}),
|
||||
).toBe(true);
|
||||
// Error instance with the cause carrying the body.
|
||||
const e = new Error('Bad request');
|
||||
(e as any).statusCode = 400;
|
||||
(e as any).cause = new Error('maximum context window exceeded');
|
||||
expect(isContextOverflowError(e)).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT classify unrelated 400s or auth/rate-limit errors', () => {
|
||||
expect(
|
||||
isContextOverflowError({ statusCode: 400, message: 'invalid tool schema' }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isContextOverflowError({
|
||||
statusCode: 429,
|
||||
message: 'context length exceeded but rate limited',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(isContextOverflowError({ statusCode: 500, message: 'server error' })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isContextOverflowError(undefined)).toBe(false);
|
||||
expect(isContextOverflowError('some random string')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Helpers to build ModelMessage fixtures in the ai@6 shape.
|
||||
const userMsg = (text: string): ModelMessage =>
|
||||
({ role: 'user', content: [{ type: 'text', text }] }) as ModelMessage;
|
||||
const assistantMsg = (
|
||||
text: string,
|
||||
toolCallId?: string,
|
||||
toolName?: string,
|
||||
): ModelMessage =>
|
||||
({
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text },
|
||||
...(toolCallId
|
||||
? [{ type: 'tool-call', toolCallId, toolName, input: {} }]
|
||||
: []),
|
||||
],
|
||||
}) as ModelMessage;
|
||||
const toolMsg = (
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
value: unknown,
|
||||
): ModelMessage =>
|
||||
({
|
||||
role: 'tool',
|
||||
content: [
|
||||
{ type: 'tool-result', toolCallId, toolName, output: { type: 'json', value } },
|
||||
],
|
||||
}) as ModelMessage;
|
||||
|
||||
describe('trimHistoryForReplay', () => {
|
||||
it('null budget disables trimming (returns the same reference)', () => {
|
||||
const msgs = [userMsg('hi'), assistantMsg('yo')];
|
||||
const r = trimHistoryForReplay(msgs, null);
|
||||
expect(r.trimmed).toBe(false);
|
||||
expect(r.messages).toBe(msgs);
|
||||
});
|
||||
|
||||
it('leaves history under budget untouched (same reference)', () => {
|
||||
const msgs = [userMsg('hi'), assistantMsg('a short answer')];
|
||||
const r = trimHistoryForReplay(msgs, 100_000);
|
||||
expect(r.trimmed).toBe(false);
|
||||
expect(r.messages).toBe(msgs);
|
||||
});
|
||||
|
||||
it('truncates OLD tool outputs but keeps recent turns full', () => {
|
||||
const big = 'X'.repeat(40_000); // ~16k tokens on its own
|
||||
const msgs: ModelMessage[] = [];
|
||||
// 6 OLD turns (indices 0..5), each with a huge tool output.
|
||||
for (let i = 0; i < 6; i++) {
|
||||
msgs.push(userMsg(`old q${i}`));
|
||||
msgs.push(assistantMsg('looking', `c${i}`, 'getPage'));
|
||||
msgs.push(toolMsg(`c${i}`, 'getPage', { body: big }));
|
||||
msgs.push(assistantMsg(`old a${i}`));
|
||||
}
|
||||
// 3 small recent turns, then the CURRENT turn with its own huge tool output.
|
||||
// With REPLAY_KEEP_RECENT_TURNS=4 the last 4 user-turns stay full, so only
|
||||
// these small recent turns + the current big one are kept full; the 6 old
|
||||
// turns above fall in the trim region.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
msgs.push(userMsg(`recent q${i}`));
|
||||
msgs.push(assistantMsg(`recent a${i}`));
|
||||
}
|
||||
msgs.push(userMsg('current q'));
|
||||
msgs.push(assistantMsg('looking', 'cR', 'getPage'));
|
||||
msgs.push(toolMsg('cR', 'getPage', { body: big }));
|
||||
msgs.push(assistantMsg('current a'));
|
||||
|
||||
// Budget large enough that phase-1 tool truncation alone brings it under.
|
||||
const r = trimHistoryForReplay(msgs, 30_000);
|
||||
expect(r.trimmed).toBe(true);
|
||||
const flat = JSON.stringify(r.messages);
|
||||
// The CURRENT turn's tool output survives in full.
|
||||
expect(flat).toContain(big);
|
||||
// Old outputs were truncated with the marker.
|
||||
expect(flat).toContain(REPLAY_TRUNCATION_MARKER);
|
||||
// Phase 1 sufficed: the oldest turns were NOT collapsed.
|
||||
expect(flat).not.toContain(REPLAY_TURN_COLLAPSED_MARKER);
|
||||
expect(estimateMessagesTokens(r.messages)).toBeLessThan(
|
||||
estimateMessagesTokens(msgs),
|
||||
);
|
||||
});
|
||||
|
||||
it('collapses the oldest turns when tool truncation is not enough', () => {
|
||||
// Many turns with LARGE assistant TEXT (not tool output) so phase 1 can't help.
|
||||
const bigText = 'слово '.repeat(8_000); // large Cyrillic text per turn
|
||||
const msgs: ModelMessage[] = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
msgs.push(userMsg(`q${i}`));
|
||||
msgs.push(assistantMsg(bigText));
|
||||
}
|
||||
const r = trimHistoryForReplay(msgs, 30_000);
|
||||
expect(r.trimmed).toBe(true);
|
||||
// Oldest turns collapsed; result fits (best-effort) and is much smaller.
|
||||
expect(estimateMessagesTokens(r.messages)).toBeLessThan(
|
||||
estimateMessagesTokens(msgs),
|
||||
);
|
||||
// The LAST turn's text is preserved in full (recent turns stay full).
|
||||
expect(JSON.stringify(r.messages[r.messages.length - 1])).toContain(bigText);
|
||||
});
|
||||
|
||||
it('is deterministic / byte-stable for identical inputs', () => {
|
||||
const big = 'Y'.repeat(30_000);
|
||||
const build = (): ModelMessage[] => {
|
||||
const m: ModelMessage[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
m.push(userMsg(`q${i}`));
|
||||
m.push(assistantMsg('t', `c${i}`, 'getPage'));
|
||||
m.push(toolMsg(`c${i}`, 'getPage', { body: big }));
|
||||
}
|
||||
return m;
|
||||
};
|
||||
const a = trimHistoryForReplay(build(), 15_000);
|
||||
const b = trimHistoryForReplay(build(), 15_000);
|
||||
expect(JSON.stringify(a.messages)).toBe(JSON.stringify(b.messages));
|
||||
});
|
||||
|
||||
it('never leaves an unpaired tool-call after collapsing (balanced history)', () => {
|
||||
const big = 'Z'.repeat(40_000);
|
||||
const msgs: ModelMessage[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
msgs.push(userMsg(`q${i}`));
|
||||
msgs.push(assistantMsg('t', `c${i}`, 'getPage'));
|
||||
msgs.push(toolMsg(`c${i}`, 'getPage', { body: big }));
|
||||
}
|
||||
const r = trimHistoryForReplay(msgs, 8_000);
|
||||
// Count tool-call vs tool-result parts in the trimmed output.
|
||||
let calls = 0;
|
||||
let results = 0;
|
||||
for (const m of r.messages) {
|
||||
if (!Array.isArray(m.content)) continue;
|
||||
for (const p of m.content as Array<{ type?: string }>) {
|
||||
if (p.type === 'tool-call') calls++;
|
||||
if (p.type === 'tool-result' || p.type === 'tool-error') results++;
|
||||
}
|
||||
}
|
||||
// Every surviving tool-call has a surviving result (collapsing drops BOTH).
|
||||
expect(calls).toBe(results);
|
||||
// Collapsed turns carry the marker.
|
||||
expect(JSON.stringify(r.messages)).toContain(REPLAY_TURN_COLLAPSED_MARKER);
|
||||
});
|
||||
|
||||
it('respects the provider fact: under-budget contextTokens skips trimming', () => {
|
||||
const big = 'W'.repeat(60_000);
|
||||
const msgs = [
|
||||
userMsg('q'),
|
||||
assistantMsg('t', 'c1', 'getPage'),
|
||||
toolMsg('c1', 'getPage', { body: big }),
|
||||
];
|
||||
// char-estimate is high, but the provider says we are well under budget.
|
||||
const r = trimHistoryForReplay(msgs, 100_000, 5_000);
|
||||
expect(r.trimmed).toBe(false);
|
||||
expect(r.messages).toBe(msgs);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* History-replay token budget (#490).
|
||||
*
|
||||
* The whole persisted conversation is replayed to the provider on EVERY turn, so
|
||||
* a long chat eventually exceeds the model's context window and the provider 400s
|
||||
* on every turn — terminally (the chat "bricks"). This module bounds the replayed
|
||||
* history at REPLAY TIME only: it never mutates what is persisted (the DB stays
|
||||
* the full record), and its output is a deterministic, byte-stable function of its
|
||||
* input so the trimmed prefix is identical turn to turn (provider prompt-cache
|
||||
* friendliness — real money on long chats).
|
||||
*
|
||||
* The PRIMARY signal is the provider's own fact: `metadata.contextTokens` from the
|
||||
* last turn. The chars-based {@link estimateTokens} (shared with the client) is
|
||||
* used only for the DELTA of not-yet-sent messages, to decide WHAT to trim, and as
|
||||
* the fallback for chats with no usage yet.
|
||||
*/
|
||||
import type { ModelMessage } from 'ai';
|
||||
import { estimateTokens } from '@docmost/token-estimate';
|
||||
|
||||
/** Flat default budget when no context window is configured (tokens). */
|
||||
export const REPLAY_BUDGET_DEFAULT_TOKENS = 100_000;
|
||||
/** Fraction of a configured context window used as the budget. */
|
||||
export const REPLAY_BUDGET_WINDOW_FRACTION = 0.7;
|
||||
/**
|
||||
* Fraction of the normal budget used for the REACTIVE re-trim after a provider
|
||||
* context-overflow 400 — the preventive estimate under-counted, so cut harder.
|
||||
*/
|
||||
export const REPLAY_AGGRESSIVE_FRACTION = 0.5;
|
||||
/**
|
||||
* Turns (a user message + its assistant/tool replies) kept FULL at the tail,
|
||||
* including the current one — never trimmed. Older turns are compacted first.
|
||||
*/
|
||||
export const REPLAY_KEEP_RECENT_TURNS = 4;
|
||||
/** Leading chars kept from a truncated old tool output. */
|
||||
export const REPLAY_TOOL_OUTPUT_HEAD = 800;
|
||||
/** Trailing chars kept from a truncated old tool output. */
|
||||
export const REPLAY_TOOL_OUTPUT_TAIL = 300;
|
||||
/** Marker inserted where an old tool output was truncated for replay. */
|
||||
export const REPLAY_TRUNCATION_MARKER =
|
||||
'[…truncated for replay; call the tool again to read the full output]';
|
||||
/** Marker for a whole old turn collapsed to its text. */
|
||||
export const REPLAY_TURN_COLLAPSED_MARKER =
|
||||
'[earlier tool activity omitted for replay]';
|
||||
|
||||
export interface ReplayBudget {
|
||||
/** Token threshold above which replay history is trimmed; `null` = OFF. */
|
||||
thresholdTokens: number | null;
|
||||
/** True when the flat default was used (no context window configured). */
|
||||
usedDefault: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the replay budget from the RAW stored `chatContextWindow` (text/number).
|
||||
* - a positive value -> `floor(fraction × window)` (NO cap — the budgeter is
|
||||
* anti-brick protection against the window itself, not a cost/economy limiter,
|
||||
* exactly as the codebase already treats maxOutputTokens; the reactive branch
|
||||
* still guarantees anti-brick regardless of how high this budget is)
|
||||
* - explicit `0` -> OFF (admin opt-out; `null` threshold)
|
||||
* - unset/empty/invalid-> the flat default (still protects — the installations
|
||||
* that hit terminal overflow are exactly the ones that never set a window)
|
||||
*
|
||||
* Note the raw value is needed because the parsed `chatContextWindow` collapses
|
||||
* both `0` and unset to `undefined`, which would erase the explicit off-switch.
|
||||
*/
|
||||
export function resolveReplayBudget(rawContextWindow: unknown): ReplayBudget {
|
||||
let n: number | undefined;
|
||||
if (typeof rawContextWindow === 'number') {
|
||||
n = rawContextWindow;
|
||||
} else if (typeof rawContextWindow === 'string') {
|
||||
const t = rawContextWindow.trim();
|
||||
n = t === '' ? undefined : Number(t);
|
||||
}
|
||||
// Unset / empty / non-numeric / negative -> flat default (the protective case).
|
||||
if (n === undefined || !Number.isFinite(n) || n < 0) {
|
||||
return { thresholdTokens: REPLAY_BUDGET_DEFAULT_TOKENS, usedDefault: true };
|
||||
}
|
||||
// Explicit 0 -> off-switch.
|
||||
if (n === 0) {
|
||||
return { thresholdTokens: null, usedDefault: false };
|
||||
}
|
||||
return {
|
||||
thresholdTokens: Math.floor(REPLAY_BUDGET_WINDOW_FRACTION * n),
|
||||
usedDefault: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The effective replay threshold for THIS turn, given the base budget and whether
|
||||
* the PREVIOUS turn hit a context-overflow 400 (the reactive-recovery signal,
|
||||
* `metadata.replayOverflow`). On recovery the base budget is scaled down by
|
||||
* {@link REPLAY_AGGRESSIVE_FRACTION}: the overflowing turn produced no usage
|
||||
* signal, so the preventive estimate under-counted and a normal-threshold trim may
|
||||
* not shrink enough to fit — this harder cut is what un-bricks the chat.
|
||||
*
|
||||
* A `null` base budget (trimming OFF) is passed through unchanged: an explicit
|
||||
* off-switch is never overridden by the recovery path.
|
||||
*/
|
||||
export function resolveEffectiveReplayThreshold(
|
||||
thresholdTokens: number | null,
|
||||
priorOverflowed: boolean,
|
||||
): number | null {
|
||||
if (!priorOverflowed || thresholdTokens == null) return thresholdTokens;
|
||||
return Math.floor(thresholdTokens * REPLAY_AGGRESSIVE_FRACTION);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a provider error is a CONTEXT-OVERFLOW rejection (the prompt exceeds
|
||||
* the model's window). Providers surface this as an HTTP 400 with a recognizable
|
||||
* message; match both the status and the message patterns robustly across
|
||||
* OpenAI-compatible / Anthropic / Gemini wordings, since the exact shape varies.
|
||||
*/
|
||||
export function isContextOverflowError(error: unknown): boolean {
|
||||
const status = extractStatus(error);
|
||||
const msg = extractMessage(error).toLowerCase();
|
||||
// Message patterns seen across providers for "prompt too long".
|
||||
const overflowPattern =
|
||||
/context (?:length|window)|maximum context|too many tokens|too large for|reduce the length|prompt is too long|input (?:is )?too long|exceeds? the (?:maximum )?(?:context|token)|maximum.*tokens|string too long/;
|
||||
if (!overflowPattern.test(msg)) return false;
|
||||
// A 400/413 with an overflow-shaped message is an overflow. Some providers
|
||||
// omit/rewrite the status, so accept the message match when the status is
|
||||
// unknown, but reject it for auth/rate-limit statuses that never mean overflow.
|
||||
if (status === 400 || status === 413) return true;
|
||||
if (status === 401 || status === 403 || status === 429) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function extractStatus(error: unknown): number | undefined {
|
||||
if (!error || typeof error !== 'object') return undefined;
|
||||
const e = error as Record<string, unknown>;
|
||||
for (const k of ['statusCode', 'status']) {
|
||||
const v = e[k];
|
||||
if (typeof v === 'number') return v;
|
||||
if (typeof v === 'string' && /^\d+$/.test(v)) return Number(v);
|
||||
}
|
||||
// Nested (e.g. { response: { status } } / { cause: { statusCode } }).
|
||||
for (const k of ['response', 'cause', 'data']) {
|
||||
const nested = e[k];
|
||||
if (nested && typeof nested === 'object') {
|
||||
const s = extractStatus(nested);
|
||||
if (s !== undefined) return s;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractMessage(error: unknown): string {
|
||||
if (error == null) return '';
|
||||
if (typeof error === 'string') return error;
|
||||
if (error instanceof Error) {
|
||||
// Include nested causes (provider libs wrap the real body in `cause`).
|
||||
const cause = (error as { cause?: unknown }).cause;
|
||||
return `${error.message} ${cause ? extractMessage(cause) : ''}`;
|
||||
}
|
||||
if (typeof error === 'object') {
|
||||
const e = error as Record<string, unknown>;
|
||||
const parts: string[] = [];
|
||||
for (const k of ['message', 'error', 'body', 'responseBody', 'data']) {
|
||||
const v = e[k];
|
||||
if (typeof v === 'string') parts.push(v);
|
||||
else if (v && typeof v === 'object') parts.push(extractMessage(v));
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
/** Rough token size of a ModelMessage array via the shared chars estimator. */
|
||||
export function estimateMessagesTokens(
|
||||
messages: ReadonlyArray<ModelMessage>,
|
||||
): number {
|
||||
let total = 0;
|
||||
for (const m of messages) {
|
||||
total += estimateTokens(serializeContent(m.content));
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function serializeContent(content: unknown): string {
|
||||
if (typeof content === 'string') return content;
|
||||
try {
|
||||
return JSON.stringify(content) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Deep JSON string of an arbitrary value, bounded so estimation never throws. */
|
||||
function stringifyValue(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
try {
|
||||
return JSON.stringify(value) ?? String(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export interface TrimResult {
|
||||
messages: ModelMessage[];
|
||||
/** Whether any trimming was applied. */
|
||||
trimmed: boolean;
|
||||
/** Estimated tokens of the returned messages (chars-based). */
|
||||
estimatedTokens: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound the replayed history to `budgetTokens`, deterministically. Returns the
|
||||
* SAME array reference (no copy) when nothing needs trimming, so the common case
|
||||
* is free and byte-identical. Trimming order (spec #490):
|
||||
* 1. truncate OLD turns' tool outputs (head+tail + marker) — the bulk of the size
|
||||
* 2. mechanically collapse the OLDEST turns to their text (concatenation, no LLM)
|
||||
* 3. the current + last {@link REPLAY_KEEP_RECENT_TURNS} turns stay FULL
|
||||
*
|
||||
* `budgetTokens === null` disables trimming. `priorContextTokens` (the provider's
|
||||
* fact from last turn) short-circuits the decision: when it is known and already
|
||||
* under budget we skip trimming even if the char-estimate is higher (the provider
|
||||
* count is authoritative). The char-estimate drives WHAT to cut.
|
||||
*/
|
||||
export function trimHistoryForReplay(
|
||||
messages: ModelMessage[],
|
||||
budgetTokens: number | null,
|
||||
priorContextTokens?: number,
|
||||
): TrimResult {
|
||||
if (budgetTokens == null) {
|
||||
return { messages, trimmed: false, estimatedTokens: 0 };
|
||||
}
|
||||
const estimated = estimateMessagesTokens(messages);
|
||||
// Decision signal: prefer the provider's fact (last turn's contextTokens) plus
|
||||
// the estimated delta of the messages appended since; fall back to the pure
|
||||
// char-estimate for a chat with no usage yet.
|
||||
const projected =
|
||||
priorContextTokens != null
|
||||
? Math.max(priorContextTokens, estimated)
|
||||
: estimated;
|
||||
if (projected <= budgetTokens) {
|
||||
return { messages, trimmed: false, estimatedTokens: estimated };
|
||||
}
|
||||
|
||||
// The tail we always keep full: from the Nth-from-last user message onward.
|
||||
const boundary = recentBoundaryIndex(messages, REPLAY_KEEP_RECENT_TURNS);
|
||||
const tail = messages.slice(boundary);
|
||||
let head = messages.slice(0, boundary).map(cloneMessage);
|
||||
|
||||
// Phase 1: truncate old tool outputs.
|
||||
for (const m of head) {
|
||||
if (m.role === 'tool') truncateToolMessage(m);
|
||||
}
|
||||
let out = [...head, ...tail];
|
||||
let est = estimateMessagesTokens(out);
|
||||
if (est <= budgetTokens) {
|
||||
return { messages: out, trimmed: true, estimatedTokens: est };
|
||||
}
|
||||
|
||||
// Phase 2: collapse the oldest turns (in `head`) to their text, one at a time,
|
||||
// from the oldest, until we fit or the whole head is collapsed.
|
||||
const turns = splitTurns(head);
|
||||
const collapsed: ModelMessage[] = [];
|
||||
let i = 0;
|
||||
for (; i < turns.length; i++) {
|
||||
if (est <= budgetTokens) break;
|
||||
collapsed.push(...collapseTurn(turns[i]));
|
||||
// Re-estimate the whole prospective output.
|
||||
const remaining = turns.slice(i + 1).flat();
|
||||
out = [...collapsed, ...remaining, ...tail];
|
||||
est = estimateMessagesTokens(out);
|
||||
}
|
||||
// Include any turns we didn't need to collapse.
|
||||
const remaining = turns.slice(i).flat();
|
||||
out = [...collapsed, ...remaining, ...tail];
|
||||
est = estimateMessagesTokens(out);
|
||||
return { messages: out, trimmed: true, estimatedTokens: est };
|
||||
}
|
||||
|
||||
/** Index of the first message of the Nth-from-last user turn (0 if fewer). */
|
||||
function recentBoundaryIndex(
|
||||
messages: ReadonlyArray<ModelMessage>,
|
||||
keepTurns: number,
|
||||
): number {
|
||||
const userIdx: number[] = [];
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
if (messages[i].role === 'user') userIdx.push(i);
|
||||
}
|
||||
if (userIdx.length <= keepTurns) return 0;
|
||||
return userIdx[userIdx.length - keepTurns];
|
||||
}
|
||||
|
||||
/** Split a message list into turns; each turn starts at a `user` message. */
|
||||
function splitTurns(messages: ModelMessage[]): ModelMessage[][] {
|
||||
const turns: ModelMessage[][] = [];
|
||||
for (const m of messages) {
|
||||
if (m.role === 'user' || turns.length === 0) turns.push([m]);
|
||||
else turns[turns.length - 1].push(m);
|
||||
}
|
||||
return turns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a whole turn to its plain text (mechanical concatenation, not an LLM
|
||||
* summary). Keeps the user message; replaces the assistant/tool messages with a
|
||||
* single assistant text message = the assistant's concatenated text + a marker
|
||||
* when tool activity was dropped. Dropping BOTH the tool-call and tool-result
|
||||
* parts together keeps the rebuilt history balanced (no unpaired calls).
|
||||
*/
|
||||
function collapseTurn(turn: ModelMessage[]): ModelMessage[] {
|
||||
const out: ModelMessage[] = [];
|
||||
let assistantText = '';
|
||||
let hadTools = false;
|
||||
for (const m of turn) {
|
||||
if (m.role === 'user') {
|
||||
out.push(m);
|
||||
} else if (m.role === 'assistant') {
|
||||
const { text, tools } = extractAssistantText(m.content);
|
||||
assistantText += text;
|
||||
hadTools = hadTools || tools;
|
||||
} else if (m.role === 'tool') {
|
||||
hadTools = true;
|
||||
} else {
|
||||
out.push(m);
|
||||
}
|
||||
}
|
||||
const note =
|
||||
(assistantText ? assistantText.trimEnd() : '') +
|
||||
(hadTools
|
||||
? `${assistantText ? '\n\n' : ''}${REPLAY_TURN_COLLAPSED_MARKER}`
|
||||
: '');
|
||||
if (note) out.push({ role: 'assistant', content: note } as ModelMessage);
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractAssistantText(content: unknown): {
|
||||
text: string;
|
||||
tools: boolean;
|
||||
} {
|
||||
if (typeof content === 'string') return { text: content, tools: false };
|
||||
if (!Array.isArray(content)) return { text: '', tools: false };
|
||||
let text = '';
|
||||
let tools = false;
|
||||
for (const part of content) {
|
||||
const type = (part as { type?: string })?.type;
|
||||
if (type === 'text') text += (part as { text?: string }).text ?? '';
|
||||
else if (type === 'tool-call') tools = true;
|
||||
}
|
||||
return { text, tools };
|
||||
}
|
||||
|
||||
/** Truncate every tool-result output in a `tool` message to head+tail+marker. */
|
||||
function truncateToolMessage(message: ModelMessage): void {
|
||||
const content = message.content;
|
||||
if (!Array.isArray(content)) return;
|
||||
for (const part of content) {
|
||||
const p = part as { type?: string; output?: { type?: string; value?: unknown } };
|
||||
if (p.type !== 'tool-result' && p.type !== 'tool-error') continue;
|
||||
if (!p.output) continue;
|
||||
const raw = stringifyValue(p.output.value);
|
||||
const budget = REPLAY_TOOL_OUTPUT_HEAD + REPLAY_TOOL_OUTPUT_TAIL;
|
||||
if (raw.length <= budget + REPLAY_TRUNCATION_MARKER.length) continue;
|
||||
const truncated =
|
||||
raw.slice(0, REPLAY_TOOL_OUTPUT_HEAD) +
|
||||
`\n${REPLAY_TRUNCATION_MARKER}\n` +
|
||||
raw.slice(raw.length - REPLAY_TOOL_OUTPUT_TAIL);
|
||||
// Represent the shrunk output as a text output (a valid tool-result output).
|
||||
p.output = { type: 'text', value: truncated };
|
||||
}
|
||||
}
|
||||
|
||||
/** Shallow-ish clone so trimming never mutates the caller's (persisted-derived)
|
||||
* message objects — only the OLD region is cloned before it is edited. */
|
||||
function cloneMessage(m: ModelMessage): ModelMessage {
|
||||
if (typeof m.content === 'string') return { ...m };
|
||||
return {
|
||||
...m,
|
||||
content: (m.content as unknown[]).map((p) =>
|
||||
p && typeof p === 'object' ? { ...(p as object) } : p,
|
||||
),
|
||||
} as ModelMessage;
|
||||
}
|
||||
@@ -9,9 +9,73 @@ import {
|
||||
DEGENERATION_CHECK_STEP,
|
||||
REPEATED_LINES_THRESHOLD,
|
||||
MIN_PERIOD_REPEATS,
|
||||
degenerationThresholds,
|
||||
} from './output-degeneration';
|
||||
import { AiChatService } from './ai-chat.service';
|
||||
|
||||
// Part A (#495 iter10): the detector thresholds are env-tunable. These drive the
|
||||
// resolver against real repeat-count shapes and mutation-verify that the env
|
||||
// override actually changes the trigger point (not a vacuous read).
|
||||
describe('degeneration thresholds are env-configurable', () => {
|
||||
const VARS = [
|
||||
'AI_CHAT_DEGENERATION_REPEATED_LINES',
|
||||
'AI_CHAT_DEGENERATION_PERIOD_MAX_LEN',
|
||||
'AI_CHAT_DEGENERATION_PERIOD_MIN_REPEATS',
|
||||
'AI_CHAT_DEGENERATION_CHECK_STEP',
|
||||
];
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
beforeEach(() => {
|
||||
for (const v of VARS) saved[v] = process.env[v];
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const v of VARS) {
|
||||
if (saved[v] === undefined) delete process.env[v];
|
||||
else process.env[v] = saved[v];
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults to the compiled constants when unset', () => {
|
||||
for (const v of VARS) delete process.env[v];
|
||||
expect(degenerationThresholds()).toEqual({
|
||||
repeatedLines: REPEATED_LINES_THRESHOLD,
|
||||
maxPeriodLen: 150,
|
||||
minPeriodRepeats: MIN_PERIOD_REPEATS,
|
||||
checkStep: DEGENERATION_CHECK_STEP,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the default on blank / invalid / non-positive values', () => {
|
||||
for (const bad of ['', ' ', 'abc', '0', '-3', '1.5']) {
|
||||
process.env.AI_CHAT_DEGENERATION_REPEATED_LINES = bad;
|
||||
// '1.5' floors to 1 (still ≥1, valid); every other bad value → default.
|
||||
const expected = bad === '1.5' ? 1 : REPEATED_LINES_THRESHOLD;
|
||||
expect(degenerationThresholds().repeatedLines).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('a RAISED check-step suppresses a burst the default would have flagged', () => {
|
||||
// A ~3.3KB periodic burst is periodic-degenerate, but shouldCheckDegeneration
|
||||
// is the throttle gate. Default checkStep=2000 arms on it; raising the step
|
||||
// above the burst size means the throttle never re-fires for it.
|
||||
const burstLen = 'loadTools.\n'.repeat(300).length; // ~3300
|
||||
delete process.env.AI_CHAT_DEGENERATION_CHECK_STEP;
|
||||
expect(shouldCheckDegeneration(burstLen, 0)).toBe(true); // default 2000
|
||||
process.env.AI_CHAT_DEGENERATION_CHECK_STEP = String(burstLen + 1);
|
||||
expect(shouldCheckDegeneration(burstLen, 0)).toBe(false); // raised gate
|
||||
});
|
||||
|
||||
it('a LOWERED repeated-lines threshold trips on a shorter identical-line run', () => {
|
||||
// 8 identical lines: below the default 25 (rule 1) and below the periodic
|
||||
// rule's 20 repeats — so isDegenerateOutput is false by default.
|
||||
const shortRun = 'x\n'.repeat(8);
|
||||
delete process.env.AI_CHAT_DEGENERATION_REPEATED_LINES;
|
||||
expect(isDegenerateOutput(shortRun)).toBe(false);
|
||||
// Lower rule 1 to 5 → the 8-line run now trips.
|
||||
process.env.AI_CHAT_DEGENERATION_REPEATED_LINES = '5';
|
||||
expect(isDegenerateOutput(shortRun)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Mock ONLY streamText so we can capture the onChunk/onStepFinish callbacks the
|
||||
// service registers and drive them by hand; every other `ai` export the service
|
||||
// uses (convertToModelMessages, stepCountIs, …) stays real.
|
||||
|
||||
@@ -23,6 +23,54 @@ export const MAX_PERIOD_LEN = 150;
|
||||
/** Rule 2: minimum number of consecutive block repeats to trigger. */
|
||||
export const MIN_PERIOD_REPEATS = 20;
|
||||
|
||||
/**
|
||||
* Read a positive-integer threshold from an env var, falling back to `fallback`
|
||||
* on unset/blank/invalid/non-positive. Mirrors the `AI_STREAM_PRE_RESPONSE_RETRIES`
|
||||
* resolver in `ai-streaming-fetch.ts`: read the RAW string first so a blank value
|
||||
* is treated as "unset" (→ fallback) rather than coercing to 0. Thresholds must
|
||||
* stay ≥ 1 — a 0/negative would make the detector fire on any text (or never), so
|
||||
* a bad value degrades to the safe compiled default instead. Env-tunable so an
|
||||
* operator can retune the anti-babble guard (#444) without a redeploy, following
|
||||
* the `AI_CHAT_FINAL_STEP_LOCKDOWN` toggle convention.
|
||||
*/
|
||||
function envThreshold(name: string, fallback: number): number {
|
||||
const rawStr = process.env[name];
|
||||
if (rawStr === undefined || rawStr.trim() === '') return fallback;
|
||||
const raw = Number(rawStr);
|
||||
return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the degeneration-detector thresholds from the environment, each
|
||||
* defaulting to the compiled constant above. Read fresh per call (not cached at
|
||||
* import) so a test — or a runtime env change — takes effect deterministically.
|
||||
*/
|
||||
export function degenerationThresholds(): {
|
||||
repeatedLines: number;
|
||||
maxPeriodLen: number;
|
||||
minPeriodRepeats: number;
|
||||
checkStep: number;
|
||||
} {
|
||||
return {
|
||||
repeatedLines: envThreshold(
|
||||
'AI_CHAT_DEGENERATION_REPEATED_LINES',
|
||||
REPEATED_LINES_THRESHOLD,
|
||||
),
|
||||
maxPeriodLen: envThreshold(
|
||||
'AI_CHAT_DEGENERATION_PERIOD_MAX_LEN',
|
||||
MAX_PERIOD_LEN,
|
||||
),
|
||||
minPeriodRepeats: envThreshold(
|
||||
'AI_CHAT_DEGENERATION_PERIOD_MIN_REPEATS',
|
||||
MIN_PERIOD_REPEATS,
|
||||
),
|
||||
checkStep: envThreshold(
|
||||
'AI_CHAT_DEGENERATION_CHECK_STEP',
|
||||
DEGENERATION_CHECK_STEP,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rule 1 — ≥`REPEATED_LINES_THRESHOLD` consecutive IDENTICAL non-empty lines at
|
||||
* the tail. Catches the classic newline-delimited loop ("loadTools.\n" ×N).
|
||||
@@ -128,7 +176,11 @@ export function hasPeriodicTail(
|
||||
* Pure — the caller owns the abort side effect.
|
||||
*/
|
||||
export function isDegenerateOutput(text: string): boolean {
|
||||
return hasRepeatedLineRun(text) || hasPeriodicTail(text);
|
||||
const cfg = degenerationThresholds();
|
||||
return (
|
||||
hasRepeatedLineRun(text, cfg.repeatedLines) ||
|
||||
hasPeriodicTail(text, cfg.maxPeriodLen, cfg.minPeriodRepeats)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,7 +206,7 @@ export function shouldCheckDegeneration(
|
||||
textLen: number,
|
||||
lastCheckLen: number,
|
||||
): boolean {
|
||||
return textLen - lastCheckLen >= DEGENERATION_CHECK_STEP;
|
||||
return textLen - lastCheckLen >= degenerationThresholds().checkStep;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -307,6 +307,10 @@ export class PublicShareChatService {
|
||||
system,
|
||||
messages: modelMessages,
|
||||
tools,
|
||||
// Pin the AI SDK per-request retry budget explicitly (matches the SDK
|
||||
// default of 2). Connection arithmetic: (1 + maxRetries) × (1 +
|
||||
// AI_STREAM_PRE_RESPONSE_RETRIES) worst-case connects per turn.
|
||||
maxRetries: 2,
|
||||
// Bound the agent loop for anonymous callers.
|
||||
stopWhen: stepCountIs(5),
|
||||
// Cap per-request output so one anonymous call cannot run up the provider
|
||||
|
||||
@@ -426,6 +426,7 @@ export class AiChatToolsService {
|
||||
const {
|
||||
sharedToolSpecs,
|
||||
createCommentSignalTracker,
|
||||
createListCommentsProbe,
|
||||
searchShapes,
|
||||
getGuideSection,
|
||||
} = await loadDocmostMcp();
|
||||
@@ -718,7 +719,11 @@ export class AiChatToolsService {
|
||||
if (spec.mcpOnly) continue;
|
||||
if (spec.inlineBothHosts) continue;
|
||||
const run = spec.inAppExecute ?? spec.execute;
|
||||
if (!run) continue; // defensive: a shared spec always carries one of them.
|
||||
// Guaranteed present by assertEverySpecIsRegisterable() (#494), which runs
|
||||
// at tool-specs module load and throws if a non-inline spec the in-app host
|
||||
// registers carries neither inAppExecute nor execute — so this can no longer
|
||||
// silently drop a mis-declared tool. Kept as a type-narrowing guard.
|
||||
if (!run) continue;
|
||||
tools[spec.inAppKey] = sharedTool(
|
||||
spec,
|
||||
(async (args) =>
|
||||
@@ -764,35 +769,21 @@ export class AiChatToolsService {
|
||||
// wrapper below) so the race governs the whole call. The client carries the
|
||||
// per-call composite signal via setToolAbortSignal.
|
||||
const capMs = inAppToolCallCapMs();
|
||||
if (!createCommentSignalTracker) {
|
||||
// The signal needs BOTH the tracker factory AND the shared count-source probe
|
||||
// factory (#494). Either being absent (a stale @docmost/mcp build or a mocked
|
||||
// loader) => signal disabled, tool results byte-identical.
|
||||
if (!createCommentSignalTracker || !createListCommentsProbe) {
|
||||
return wrapInAppToolsWithCap(tools, client, capMs);
|
||||
}
|
||||
|
||||
// Shared probe (#494): the SAME factory the standalone MCP host uses, so the
|
||||
// in-app probe body is no longer a hand-mirror that could drift (counting the
|
||||
// full feed newer than the watermark, labelling a hit with the light page
|
||||
// title). `client` supplies the loopback listComments/getPageRaw reads.
|
||||
const tracker = createCommentSignalTracker({
|
||||
probe: async (pageId: string, sinceMs: number) => {
|
||||
const { items } = await client.listComments(pageId, true);
|
||||
const count = (items as Array<{ createdAt?: string }>).filter((c) => {
|
||||
const created = c?.createdAt ? new Date(c.createdAt).getTime() : NaN;
|
||||
return Number.isFinite(created) && created > sinceMs;
|
||||
}).length;
|
||||
let title: string | undefined;
|
||||
if (count > 0) {
|
||||
// Title labels the signal; untrusted, defanged by the shared builder.
|
||||
// Fetched only on a hit so the no-signal path never pays for it. Uses
|
||||
// the LIGHT raw page info (title only) — mirroring the standalone MCP
|
||||
// probe's getPageRaw — instead of the heavy getPage (which also renders
|
||||
// Markdown + subpages) just to read one field.
|
||||
try {
|
||||
const res = (await client.getPageRaw(pageId)) as {
|
||||
title?: string;
|
||||
} | null;
|
||||
title = res?.title ?? undefined;
|
||||
} catch {
|
||||
// Title is optional — omit it when the page can't be fetched.
|
||||
}
|
||||
}
|
||||
return { count, title };
|
||||
},
|
||||
probe: createListCommentsProbe(
|
||||
client as unknown as Parameters<typeof createListCommentsProbe>[0],
|
||||
),
|
||||
});
|
||||
|
||||
return wrapInAppToolsWithCap(
|
||||
|
||||
@@ -21,7 +21,10 @@ import { SHARED_TOOL_SPECS } from '../../../../../../packages/mcp/src/tool-specs
|
||||
// The REAL shared tracker factory, imported from source (same cross-boundary
|
||||
// approach the tool-specs spec uses) so the in-app wiring is exercised against
|
||||
// exactly the watermark/debounce/injection-safe logic the package ships.
|
||||
import { createCommentSignalTracker } from '../../../../../../packages/mcp/src/comment-signal';
|
||||
import {
|
||||
createCommentSignalTracker,
|
||||
createListCommentsProbe,
|
||||
} from '../../../../../../packages/mcp/src/comment-signal';
|
||||
// The REAL client-side citation extractor: proves that the passive signal does
|
||||
// NOT strip a tool's citations (the #417 in-app regression this spec guards).
|
||||
import { toolCitations } from '../../../../../../apps/client/src/features/ai-chat/utils/tool-parts';
|
||||
@@ -284,9 +287,13 @@ describe('AiChatToolsService forUser + comment signal (real tracker)', () => {
|
||||
return fakeClient as DocmostClientLike;
|
||||
} as unknown as loader.DocmostClientCtor,
|
||||
sharedToolSpecs: SHARED_TOOL_SPECS as unknown as Record<string, loader.SharedToolSpec>,
|
||||
// Wire the REAL factory so the in-app path is exercised end to end.
|
||||
// Wire the REAL factories so the in-app path is exercised end to end —
|
||||
// including the shared count-source probe (#494) the service now builds the
|
||||
// tracker's `probe` from.
|
||||
createCommentSignalTracker:
|
||||
createCommentSignalTracker as unknown as loader.CommentSignalTrackerFactory,
|
||||
createListCommentsProbe:
|
||||
createListCommentsProbe as unknown as loader.CreateListCommentsProbeFn,
|
||||
// Pure no-network draw.io helpers (#424) — required on the loader return;
|
||||
// this comment-signal test doesn't exercise them, so no-op stubs suffice.
|
||||
searchShapes: (() => []) as unknown as loader.SearchShapesFn,
|
||||
|
||||
@@ -150,6 +150,27 @@ export type CommentSignalTrackerFactory = (options: {
|
||||
debounceMs?: number;
|
||||
}) => CommentSignalTrackerLike;
|
||||
|
||||
/**
|
||||
* Local mirror of `@docmost/mcp`'s `createListCommentsProbe` (#494): the SHARED
|
||||
* count-source probe both hosts use, so the in-app probe body is no longer a
|
||||
* hand-copy of the standalone MCP one. Given a client with the light comment feed
|
||||
* + raw-page-title reads, it returns the tracker's `probe` (count comments newer
|
||||
* than the watermark, label a hit with the page title). Loosely typed at this
|
||||
* cross-package boundary, like the rest of this loader.
|
||||
*/
|
||||
export type CreateListCommentsProbeFn = (client: {
|
||||
listComments(
|
||||
pageId: string,
|
||||
includeResolved: boolean,
|
||||
): Promise<{ items: Array<{ createdAt?: string | null }> }>;
|
||||
getPageRaw(
|
||||
pageId: string,
|
||||
): Promise<{ title?: string | null } | null | undefined>;
|
||||
}) => (
|
||||
pageId: string,
|
||||
sinceMs: number,
|
||||
) => Promise<CommentSignalProbeResultLike>;
|
||||
|
||||
// Pure, no-network draw.io helpers (#424). These are plain functions on the
|
||||
// module (NOT DocmostClient methods) — the in-app AI-SDK service calls them
|
||||
// directly to wire drawioShapes / drawioGuide, mirroring the MCP server.
|
||||
@@ -170,6 +191,10 @@ interface DocmostMcpModule {
|
||||
// loader in unit tests. The in-app layer treats an absent factory as "signal
|
||||
// disabled" — a pure no-op that leaves tool results byte-identical.
|
||||
createCommentSignalTracker?: CommentSignalTrackerFactory;
|
||||
// Optional (#494): the shared count-source probe factory. Absent on a pre-#494
|
||||
// build or a mocked loader; the in-app layer only builds a probe when the
|
||||
// signal factory above is also present.
|
||||
createListCommentsProbe?: CreateListCommentsProbeFn;
|
||||
// Optional (#447): a deterministic hash of the tool-specs registry content,
|
||||
// generated into build/ by the package's build. Absent on a pre-#447 build (or
|
||||
// the mocked loader in unit tests) — the stale-check below is a NO-OP when it
|
||||
@@ -284,6 +309,7 @@ export async function loadDocmostMcp(): Promise<{
|
||||
DocmostClient: DocmostClientCtor;
|
||||
sharedToolSpecs: Record<string, SharedToolSpec>;
|
||||
createCommentSignalTracker?: CommentSignalTrackerFactory;
|
||||
createListCommentsProbe?: CreateListCommentsProbeFn;
|
||||
searchShapes: SearchShapesFn;
|
||||
getGuideSection: GetGuideSectionFn;
|
||||
}> {
|
||||
@@ -331,6 +357,9 @@ export async function loadDocmostMcp(): Promise<{
|
||||
// Optional: forwarded when present so the in-app layer can build the passive
|
||||
// comment signal (#417); undefined on a stale build => signal disabled.
|
||||
createCommentSignalTracker: mod.createCommentSignalTracker,
|
||||
// Optional (#494): the shared count-source probe factory; undefined on a
|
||||
// stale build => the in-app layer falls back to no signal.
|
||||
createListCommentsProbe: mod.createListCommentsProbe,
|
||||
// Pure no-network draw.io helpers (#424); not client methods.
|
||||
searchShapes: mod.searchShapes,
|
||||
getGuideSection: mod.getGuideSection,
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { ApiKeyController } from './api-key.controller';
|
||||
import {
|
||||
WorkspaceCaslAction,
|
||||
WorkspaceCaslSubject,
|
||||
} from '../casl/interfaces/workspace-ability.type';
|
||||
|
||||
/**
|
||||
* Authorization contract for the /api-keys management surface.
|
||||
*
|
||||
* - A token cannot manage tokens (an api_key PRINCIPAL is 403 on every method)
|
||||
* — GitHub-PAT semantics closing post-revocation laundering.
|
||||
* - admin (CASL Manage on API) sees/revokes all workspace keys; a member only
|
||||
* their own.
|
||||
* - kill-switch OFF -> the surface 404s (looks like the feature does not exist).
|
||||
*/
|
||||
|
||||
function makeController(over: any = {}) {
|
||||
const apiKeyService = {
|
||||
create: jest.fn().mockResolvedValue({
|
||||
token: 'tok',
|
||||
key: { id: 'k1', name: 'n', expiresAt: null, createdAt: new Date() },
|
||||
}),
|
||||
revoke: jest.fn().mockResolvedValue(undefined),
|
||||
...(over.apiKeyService ?? {}),
|
||||
};
|
||||
const apiKeyRepo = {
|
||||
findAllInWorkspace: jest.fn().mockResolvedValue([]),
|
||||
findByCreator: jest.fn().mockResolvedValue([]),
|
||||
findById: jest.fn(),
|
||||
...(over.apiKeyRepo ?? {}),
|
||||
};
|
||||
const workspaceAbility = {
|
||||
createForUser: jest.fn().mockReturnValue({
|
||||
can: (_a: any, _s: any) => over.canManage ?? false,
|
||||
}),
|
||||
...(over.workspaceAbility ?? {}),
|
||||
};
|
||||
const environmentService = {
|
||||
isApiKeysEnabled: jest.fn().mockReturnValue(over.enabled ?? true),
|
||||
...(over.environmentService ?? {}),
|
||||
};
|
||||
const auditService = { log: jest.fn() };
|
||||
const controller = new ApiKeyController(
|
||||
apiKeyService as any,
|
||||
apiKeyRepo as any,
|
||||
workspaceAbility as any,
|
||||
environmentService as any,
|
||||
auditService as any,
|
||||
);
|
||||
return {
|
||||
controller,
|
||||
apiKeyService,
|
||||
apiKeyRepo,
|
||||
workspaceAbility,
|
||||
environmentService,
|
||||
auditService,
|
||||
};
|
||||
}
|
||||
|
||||
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
|
||||
const workspace = { id: 'ws-1' } as any;
|
||||
const reqAccess = () => ({ raw: {}, ip: '1.2.3.4', socket: {} }) as any;
|
||||
const reqApiKey = () =>
|
||||
({ raw: { authType: 'api_key', apiKeyId: 'k-self' }, ip: '1.2.3.4', socket: {} }) as any;
|
||||
|
||||
describe('ApiKeyController — a token cannot manage tokens', () => {
|
||||
it('403 on create for an api_key principal', async () => {
|
||||
const { controller, apiKeyService } = makeController();
|
||||
await expect(
|
||||
controller.create({ name: 'x' } as any, user, reqApiKey()),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(apiKeyService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('403 on list for an api_key principal', async () => {
|
||||
const { controller } = makeController();
|
||||
await expect(
|
||||
controller.list(user, workspace, reqApiKey()),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('403 on revoke for an api_key principal', async () => {
|
||||
const { controller } = makeController();
|
||||
await expect(
|
||||
controller.revoke({ id: 'k1' } as any, user, workspace, reqApiKey()),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApiKeyController — kill-switch OFF → 404', () => {
|
||||
it('create/list/revoke all 404 when disabled', async () => {
|
||||
const { controller } = makeController({ enabled: false });
|
||||
await expect(
|
||||
controller.create({ name: 'x' } as any, user, reqAccess()),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(
|
||||
controller.list(user, workspace, reqAccess()),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(
|
||||
controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess()),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApiKeyController — list scoping', () => {
|
||||
it('admin (Manage on API) lists ALL workspace keys', async () => {
|
||||
const { controller, apiKeyRepo } = makeController({ canManage: true });
|
||||
await controller.list(user, workspace, reqAccess());
|
||||
expect(apiKeyRepo.findAllInWorkspace).toHaveBeenCalledWith('ws-1');
|
||||
expect(apiKeyRepo.findByCreator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('member lists ONLY their own keys', async () => {
|
||||
const { controller, apiKeyRepo } = makeController({ canManage: false });
|
||||
await controller.list(user, workspace, reqAccess());
|
||||
expect(apiKeyRepo.findByCreator).toHaveBeenCalledWith('u-1', 'ws-1');
|
||||
expect(apiKeyRepo.findAllInWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApiKeyController — revoke scoping', () => {
|
||||
it("member cannot revoke another user's key (403)", async () => {
|
||||
const { controller, apiKeyRepo, apiKeyService } = makeController({
|
||||
canManage: false,
|
||||
});
|
||||
apiKeyRepo.findById.mockResolvedValue({
|
||||
id: 'k1',
|
||||
creatorId: 'someone-else',
|
||||
workspaceId: 'ws-1',
|
||||
});
|
||||
await expect(
|
||||
controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess()),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(apiKeyService.revoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('member CAN revoke their own key', async () => {
|
||||
const { controller, apiKeyRepo, apiKeyService } = makeController({
|
||||
canManage: false,
|
||||
});
|
||||
apiKeyRepo.findById.mockResolvedValue({
|
||||
id: 'k1',
|
||||
creatorId: 'u-1',
|
||||
workspaceId: 'ws-1',
|
||||
});
|
||||
await controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess());
|
||||
expect(apiKeyService.revoke).toHaveBeenCalledWith('k1', 'ws-1');
|
||||
});
|
||||
|
||||
it("admin CAN revoke another user's key", async () => {
|
||||
const { controller, apiKeyRepo, apiKeyService } = makeController({
|
||||
canManage: true,
|
||||
});
|
||||
apiKeyRepo.findById.mockResolvedValue({
|
||||
id: 'k1',
|
||||
creatorId: 'someone-else',
|
||||
workspaceId: 'ws-1',
|
||||
});
|
||||
await controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess());
|
||||
expect(apiKeyService.revoke).toHaveBeenCalledWith('k1', 'ws-1');
|
||||
});
|
||||
|
||||
it('404 when the key does not exist', async () => {
|
||||
const { controller, apiKeyRepo } = makeController({ canManage: true });
|
||||
apiKeyRepo.findById.mockResolvedValue(undefined);
|
||||
await expect(
|
||||
controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess()),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApiKeyController — create emits audit + returns token once', () => {
|
||||
it('logs API_KEY_CREATED and returns the token + computed expiry', async () => {
|
||||
const { controller, auditService } = makeController();
|
||||
const res = await controller.create(
|
||||
{ name: 'ci' } as any,
|
||||
user,
|
||||
reqAccess(),
|
||||
);
|
||||
expect(res.token).toBe('tok');
|
||||
expect(res.apiKey).toMatchObject({ id: 'k1', name: 'n' });
|
||||
expect(auditService.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ event: 'api_key.created' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Sanity: the CASL subject used is the workspace API subject.
|
||||
it('uses WorkspaceCaslSubject.API with the Manage action', () => {
|
||||
expect(WorkspaceCaslSubject.API).toBe('api_key');
|
||||
expect(WorkspaceCaslAction.Manage).toBeDefined();
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
import { ApiKeyRepo } from '@docmost/db/repos/api-key/api-key.repo';
|
||||
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
||||
import { RevokeApiKeyDto } from './dto/revoke-api-key.dto';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
|
||||
import {
|
||||
WorkspaceCaslAction,
|
||||
WorkspaceCaslSubject,
|
||||
} from '../casl/interfaces/workspace-ability.type';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../integrations/audit/audit.service';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard';
|
||||
import { AUTH_THROTTLER } from '../../integrations/throttle/throttler-names';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('api-keys')
|
||||
export class ApiKeyController {
|
||||
private readonly logger = new Logger(ApiKeyController.name);
|
||||
|
||||
constructor(
|
||||
private readonly apiKeyService: ApiKeyService,
|
||||
private readonly apiKeyRepo: ApiKeyRepo,
|
||||
private readonly workspaceAbility: WorkspaceAbilityFactory,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
// Kill-switch OFF: the issuance surface disappears entirely (404), not a 403 —
|
||||
// it must look like the feature does not exist.
|
||||
private assertEnabled(): void {
|
||||
if (!this.environmentService.isApiKeysEnabled()) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
// A token cannot manage tokens (GitHub-PAT semantics): an api-key principal is
|
||||
// refused on the management surface, so a leaked key cannot mint a replacement
|
||||
// or revoke the keys that would lock it out (closes post-revocation laundering).
|
||||
private rejectApiKeyPrincipal(req: FastifyRequest): void {
|
||||
if ((req.raw as { authType?: string }).authType === 'api_key') {
|
||||
throw new ForbiddenException('API keys cannot manage API keys');
|
||||
}
|
||||
}
|
||||
|
||||
private clientIp(req: FastifyRequest): string {
|
||||
return req.ip ?? req.socket?.remoteAddress ?? 'unknown';
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(JwtAuthGuard, UserThrottlerGuard)
|
||||
@Throttle({ [AUTH_THROTTLER]: { limit: 10, ttl: 60_000 } })
|
||||
@Post('create')
|
||||
async create(
|
||||
@Body() dto: CreateApiKeyDto,
|
||||
@AuthUser() user: User,
|
||||
@Req() req: FastifyRequest,
|
||||
) {
|
||||
this.assertEnabled();
|
||||
this.rejectApiKeyPrincipal(req);
|
||||
|
||||
// undefined -> service default (1 year); null -> unlimited; string -> Date.
|
||||
const expiresAt =
|
||||
dto.expiresAt === undefined
|
||||
? undefined
|
||||
: dto.expiresAt === null
|
||||
? null
|
||||
: new Date(dto.expiresAt);
|
||||
|
||||
const { token, key } = await this.apiKeyService.create(
|
||||
user,
|
||||
dto.name,
|
||||
expiresAt,
|
||||
);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.API_KEY_CREATED,
|
||||
resourceType: AuditResource.API_KEY,
|
||||
resourceId: key.id,
|
||||
});
|
||||
// The durable trail lives in container logs (AUDIT_SERVICE is a Noop in this
|
||||
// build). No token material — the JWT is only ever returned in the response.
|
||||
this.logger.log(
|
||||
`API key created: id=${key.id} name=${JSON.stringify(
|
||||
key.name,
|
||||
)} actor=${user.id} expiresAt=${
|
||||
key.expiresAt ? new Date(key.expiresAt).toISOString() : 'never'
|
||||
} ip=${this.clientIp(req)}`,
|
||||
);
|
||||
|
||||
// Return the token ONCE (never retrievable again) and the computed expiry so
|
||||
// the caller/UI can surface "expires <date>" (the year-default time-bomb
|
||||
// early-warning).
|
||||
return {
|
||||
token,
|
||||
apiKey: {
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
expiresAt: key.expiresAt,
|
||||
createdAt: key.createdAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('list')
|
||||
async list(
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Req() req: FastifyRequest,
|
||||
) {
|
||||
this.assertEnabled();
|
||||
this.rejectApiKeyPrincipal(req);
|
||||
|
||||
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||
// Admin (CASL Manage on API): every key in the workspace, attributed to its
|
||||
// creator (closes "a leaked key of a departed employee is invisible"). A
|
||||
// member sees only their own.
|
||||
const keys = ability.can(
|
||||
WorkspaceCaslAction.Manage,
|
||||
WorkspaceCaslSubject.API,
|
||||
)
|
||||
? await this.apiKeyRepo.findAllInWorkspace(workspace.id)
|
||||
: await this.apiKeyRepo.findByCreator(user.id, workspace.id);
|
||||
|
||||
return keys.map((k) => ({
|
||||
id: k.id,
|
||||
name: k.name,
|
||||
expiresAt: k.expiresAt,
|
||||
lastUsedAt: k.lastUsedAt,
|
||||
createdAt: k.createdAt,
|
||||
creator: k.creator,
|
||||
}));
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('revoke')
|
||||
async revoke(
|
||||
@Body() dto: RevokeApiKeyDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Req() req: FastifyRequest,
|
||||
) {
|
||||
this.assertEnabled();
|
||||
this.rejectApiKeyPrincipal(req);
|
||||
|
||||
const key = await this.apiKeyRepo.findById(dto.id, workspace.id);
|
||||
// Uniform 404 for a missing/already-revoked key regardless of who asks — no
|
||||
// existence oracle for keys the caller may not own.
|
||||
if (!key) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||
const canManageAll = ability.can(
|
||||
WorkspaceCaslAction.Manage,
|
||||
WorkspaceCaslSubject.API,
|
||||
);
|
||||
// Admin may revoke any key in the workspace; a member only their own.
|
||||
if (!canManageAll && key.creatorId !== user.id) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await this.apiKeyService.revoke(key.id, workspace.id);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.API_KEY_DELETED,
|
||||
resourceType: AuditResource.API_KEY,
|
||||
resourceId: key.id,
|
||||
});
|
||||
this.logger.log(
|
||||
`API key revoked: id=${key.id} name=${JSON.stringify(
|
||||
key.name,
|
||||
)} actor=${user.id} ip=${this.clientIp(req)}`,
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
import { ApiKeyController } from './api-key.controller';
|
||||
import { TokenModule } from '../auth/token.module';
|
||||
|
||||
// Core (non-EE) API-key feature: issuance REST endpoints + the shared validator
|
||||
// consumed by jwt.strategy (REST) and McpService (the /mcp Bearer router).
|
||||
// DatabaseModule (global) provides ApiKeyRepo/UserRepo/WorkspaceRepo; CaslModule
|
||||
// (global) provides WorkspaceAbilityFactory; TokenModule provides TokenService
|
||||
// (the no-exp api-key signer). ApiKeyService is exported so AuthModule (for
|
||||
// jwt.strategy) and McpModule (for the /mcp router) can inject it directly,
|
||||
// replacing the absent EE `ee/api-key` dynamic require.
|
||||
@Module({
|
||||
imports: [TokenModule],
|
||||
controllers: [ApiKeyController],
|
||||
providers: [ApiKeyService],
|
||||
exports: [ApiKeyService],
|
||||
})
|
||||
export class ApiKeyModule {}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
import { JwtType } from '../auth/dto/jwt-payload';
|
||||
|
||||
/**
|
||||
* Security contract for ApiKeyService.validate — the single validator shared by
|
||||
* jwt.strategy (REST) and the /mcp Bearer router.
|
||||
*
|
||||
* Invariants under test:
|
||||
* - Anti-enumeration: every DEFINITE deny (missing/revoked/expired row, disabled
|
||||
* user, workspace mismatch, kill-switch off) throws the SAME bare
|
||||
* UnauthorizedException — an agent cannot distinguish expired from revoked.
|
||||
* - deny-on-decision / 5xx-on-infra: an UNEXPECTED (infra) error PROPAGATES as
|
||||
* itself (→ 5xx), never masked as a 401.
|
||||
* - Expiry is read from the ROW, never a JWT exp claim.
|
||||
* - No validate cache (each call re-reads the row → immediate revocation).
|
||||
*/
|
||||
|
||||
const APP_SECRET = 'secret';
|
||||
|
||||
function makeDeps(over: Partial<Record<string, any>> = {}) {
|
||||
const apiKeyRepo = {
|
||||
findById: jest.fn(),
|
||||
insert: jest.fn(),
|
||||
softDelete: jest.fn(),
|
||||
touchLastUsed: jest.fn().mockResolvedValue(undefined),
|
||||
...(over.apiKeyRepo ?? {}),
|
||||
};
|
||||
const userRepo = {
|
||||
findById: jest.fn(),
|
||||
...(over.userRepo ?? {}),
|
||||
};
|
||||
const workspaceRepo = {
|
||||
findById: jest.fn().mockResolvedValue({ id: 'ws-1' }),
|
||||
...(over.workspaceRepo ?? {}),
|
||||
};
|
||||
const tokenService = {
|
||||
generateApiToken: jest.fn().mockResolvedValue('minted.jwt.token'),
|
||||
...(over.tokenService ?? {}),
|
||||
};
|
||||
const environmentService = {
|
||||
isApiKeysEnabled: jest.fn().mockReturnValue(true),
|
||||
getApiKeysEnabledRaw: jest.fn().mockReturnValue(undefined),
|
||||
getAppSecret: jest.fn().mockReturnValue(APP_SECRET),
|
||||
...(over.environmentService ?? {}),
|
||||
};
|
||||
const service = new (ApiKeyService as unknown as new (...a: any[]) => ApiKeyService)(
|
||||
apiKeyRepo,
|
||||
userRepo,
|
||||
workspaceRepo,
|
||||
tokenService,
|
||||
environmentService,
|
||||
);
|
||||
return { service, apiKeyRepo, userRepo, workspaceRepo, tokenService, environmentService };
|
||||
}
|
||||
|
||||
const payload = (over: Record<string, any> = {}) => ({
|
||||
sub: 'u-1',
|
||||
workspaceId: 'ws-1',
|
||||
apiKeyId: 'key-1',
|
||||
type: JwtType.API_KEY,
|
||||
...over,
|
||||
});
|
||||
|
||||
const activeRow = (over: Record<string, any> = {}) => ({
|
||||
id: 'key-1',
|
||||
name: 'k',
|
||||
creatorId: 'u-1',
|
||||
workspaceId: 'ws-1',
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
deletedAt: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
const activeUser = (over: Record<string, any> = {}) => ({
|
||||
id: 'u-1',
|
||||
workspaceId: 'ws-1',
|
||||
deactivatedAt: null,
|
||||
deletedAt: null,
|
||||
isAgent: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('ApiKeyService.validate', () => {
|
||||
it('returns { user, workspace } for a valid active key', async () => {
|
||||
const { service, apiKeyRepo, userRepo } = makeDeps();
|
||||
apiKeyRepo.findById.mockResolvedValue(activeRow());
|
||||
userRepo.findById.mockResolvedValue(activeUser());
|
||||
|
||||
const res = await service.validate(payload() as any);
|
||||
|
||||
expect(res.user).toMatchObject({ id: 'u-1' });
|
||||
expect(res.workspace).toMatchObject({ id: 'ws-1' });
|
||||
// Loads isAgent so downstream provenance does not silently degrade.
|
||||
expect(userRepo.findById).toHaveBeenCalledWith('u-1', 'ws-1', {
|
||||
includeIsAgent: true,
|
||||
});
|
||||
});
|
||||
|
||||
// --- Anti-enumeration: every deny is the SAME bare 401 -------------------
|
||||
const denyCases: Array<[string, (d: ReturnType<typeof makeDeps>) => void]> = [
|
||||
[
|
||||
'missing/orphaned row',
|
||||
(d) => d.apiKeyRepo.findById.mockResolvedValue(undefined),
|
||||
],
|
||||
[
|
||||
'revoked (soft-deleted → findById returns undefined)',
|
||||
(d) => d.apiKeyRepo.findById.mockResolvedValue(undefined),
|
||||
],
|
||||
[
|
||||
'expired (expires_at in the past)',
|
||||
(d) => {
|
||||
d.apiKeyRepo.findById.mockResolvedValue(
|
||||
activeRow({ expiresAt: new Date(Date.now() - 1000) }),
|
||||
);
|
||||
d.userRepo.findById.mockResolvedValue(activeUser());
|
||||
},
|
||||
],
|
||||
[
|
||||
'disabled user',
|
||||
(d) => {
|
||||
d.apiKeyRepo.findById.mockResolvedValue(activeRow());
|
||||
d.userRepo.findById.mockResolvedValue(
|
||||
activeUser({ deactivatedAt: new Date() }),
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'user not found',
|
||||
(d) => {
|
||||
d.apiKeyRepo.findById.mockResolvedValue(activeRow());
|
||||
d.userRepo.findById.mockResolvedValue(undefined);
|
||||
},
|
||||
],
|
||||
[
|
||||
'creator/sub mismatch',
|
||||
(d) => {
|
||||
d.apiKeyRepo.findById.mockResolvedValue(activeRow({ creatorId: 'other' }));
|
||||
d.userRepo.findById.mockResolvedValue(activeUser());
|
||||
},
|
||||
],
|
||||
[
|
||||
'workspace row missing',
|
||||
(d) => {
|
||||
d.apiKeyRepo.findById.mockResolvedValue(activeRow());
|
||||
d.userRepo.findById.mockResolvedValue(activeUser());
|
||||
d.workspaceRepo.findById.mockResolvedValue(undefined);
|
||||
},
|
||||
],
|
||||
[
|
||||
'kill-switch OFF',
|
||||
(d) => d.environmentService.isApiKeysEnabled.mockReturnValue(false),
|
||||
],
|
||||
[
|
||||
'malformed payload (missing apiKeyId)',
|
||||
() => undefined,
|
||||
],
|
||||
];
|
||||
|
||||
it.each(denyCases)(
|
||||
'throws a bare UnauthorizedException with NO message for: %s',
|
||||
async (label, arrange) => {
|
||||
const deps = makeDeps();
|
||||
arrange(deps);
|
||||
const p =
|
||||
label === 'malformed payload (missing apiKeyId)'
|
||||
? (payload({ apiKeyId: undefined }) as any)
|
||||
: (payload() as any);
|
||||
|
||||
const err = await deps.service.validate(p).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(UnauthorizedException);
|
||||
// Anti-enumeration: identical, class-less message for every failure mode.
|
||||
expect(err.message).toBe('Unauthorized');
|
||||
},
|
||||
);
|
||||
|
||||
it('kill-switch OFF denies BEFORE any DB read', async () => {
|
||||
const { service, apiKeyRepo, environmentService } = makeDeps();
|
||||
environmentService.isApiKeysEnabled.mockReturnValue(false);
|
||||
|
||||
await expect(service.validate(payload() as any)).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(apiKeyRepo.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- Infra error propagates (5xx), NOT masked as 401 ---------------------
|
||||
it('PROPAGATES an infra (DB) error instead of masking it as 401', async () => {
|
||||
const { service, apiKeyRepo } = makeDeps();
|
||||
const boom = new Error('connection terminated');
|
||||
apiKeyRepo.findById.mockRejectedValue(boom);
|
||||
|
||||
await expect(service.validate(payload() as any)).rejects.toBe(boom);
|
||||
});
|
||||
|
||||
// --- No cache: revocation is immediate on the next call ------------------
|
||||
it('re-reads the row on every call (no validate cache → immediate revocation)', async () => {
|
||||
const { service, apiKeyRepo, userRepo } = makeDeps();
|
||||
apiKeyRepo.findById.mockResolvedValue(activeRow());
|
||||
userRepo.findById.mockResolvedValue(activeUser());
|
||||
await service.validate(payload() as any);
|
||||
|
||||
// Now the key is revoked (row invisible) — the very next call denies.
|
||||
apiKeyRepo.findById.mockResolvedValue(undefined);
|
||||
await expect(service.validate(payload() as any)).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(apiKeyRepo.findById).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// --- last_used_at is throttled + fire-and-forget -------------------------
|
||||
it('touches last_used_at when stale and skips when fresh', async () => {
|
||||
const { service, apiKeyRepo, userRepo } = makeDeps();
|
||||
userRepo.findById.mockResolvedValue(activeUser());
|
||||
|
||||
// Stale (never used) -> touch.
|
||||
apiKeyRepo.findById.mockResolvedValue(activeRow({ lastUsedAt: null }));
|
||||
await service.validate(payload() as any);
|
||||
expect(apiKeyRepo.touchLastUsed).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Fresh (used 1 minute ago) -> skip.
|
||||
apiKeyRepo.touchLastUsed.mockClear();
|
||||
apiKeyRepo.findById.mockResolvedValue(
|
||||
activeRow({ lastUsedAt: new Date(Date.now() - 60_000) }),
|
||||
);
|
||||
await service.validate(payload() as any);
|
||||
expect(apiKeyRepo.touchLastUsed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a failing last_used_at touch never fails the request', async () => {
|
||||
const { service, apiKeyRepo, userRepo } = makeDeps();
|
||||
apiKeyRepo.findById.mockResolvedValue(activeRow({ lastUsedAt: null }));
|
||||
userRepo.findById.mockResolvedValue(activeUser());
|
||||
apiKeyRepo.touchLastUsed.mockRejectedValue(new Error('write failed'));
|
||||
|
||||
await expect(service.validate(payload() as any)).resolves.toMatchObject({
|
||||
user: { id: 'u-1' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApiKeyService.create (mint-then-insert, no exp)', () => {
|
||||
it('mints the JWT BEFORE inserting the row and returns the token once', async () => {
|
||||
const { service, apiKeyRepo, tokenService } = makeDeps();
|
||||
const order: string[] = [];
|
||||
tokenService.generateApiToken.mockImplementation(async () => {
|
||||
order.push('mint');
|
||||
return 'tok';
|
||||
});
|
||||
apiKeyRepo.insert.mockImplementation(async (row: any) => {
|
||||
order.push('insert');
|
||||
return { ...row, createdAt: new Date() };
|
||||
});
|
||||
|
||||
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
|
||||
const res = await service.create(user, 'my key');
|
||||
|
||||
expect(order).toEqual(['mint', 'insert']);
|
||||
expect(res.token).toBe('tok');
|
||||
// The id is generated before minting and reused for the row.
|
||||
const mintArg = tokenService.generateApiToken.mock.calls[0][0];
|
||||
const insertArg = apiKeyRepo.insert.mock.calls[0][0];
|
||||
expect(mintArg.apiKeyId).toBe(insertArg.id);
|
||||
});
|
||||
|
||||
it('applies the 1-year default when expiresAt is undefined', async () => {
|
||||
const { service, apiKeyRepo } = makeDeps();
|
||||
apiKeyRepo.insert.mockImplementation(async (row: any) => row);
|
||||
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
|
||||
|
||||
await service.create(user, 'k');
|
||||
|
||||
const insertArg = apiKeyRepo.insert.mock.calls[0][0];
|
||||
const days =
|
||||
(insertArg.expiresAt.getTime() - Date.now()) / (24 * 60 * 60 * 1000);
|
||||
expect(days).toBeGreaterThan(364);
|
||||
expect(days).toBeLessThan(367);
|
||||
});
|
||||
|
||||
it('honors an explicit null (unlimited)', async () => {
|
||||
const { service, apiKeyRepo } = makeDeps();
|
||||
apiKeyRepo.insert.mockImplementation(async (row: any) => row);
|
||||
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
|
||||
|
||||
await service.create(user, 'k', null);
|
||||
|
||||
expect(apiKeyRepo.insert.mock.calls[0][0].expiresAt).toBeNull();
|
||||
});
|
||||
|
||||
it('does NOT insert a row when minting fails (mint-then-insert → inert)', async () => {
|
||||
const { service, apiKeyRepo, tokenService } = makeDeps();
|
||||
tokenService.generateApiToken.mockRejectedValue(new Error('mint failed'));
|
||||
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
|
||||
|
||||
await expect(service.create(user, 'k')).rejects.toThrow('mint failed');
|
||||
expect(apiKeyRepo.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { v7 as uuid7 } from 'uuid';
|
||||
import { ApiKeyRepo } from '@docmost/db/repos/api-key/api-key.repo';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { TokenService } from '../auth/services/token.service';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { JwtApiKeyPayload } from '../auth/dto/jwt-payload';
|
||||
import { ApiKey, User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { isUserDisabled } from '../../common/helpers';
|
||||
|
||||
// Default lifetime for a new key when the caller does not specify one: 1 year.
|
||||
// The owner runs a homelab where agents live for years; forcing rotation is
|
||||
// operational pain, so an explicit `null` (unlimited) is also allowed.
|
||||
const DEFAULT_LIFETIME_MS = 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// last_used_at is a best-effort forensics stamp, not an access record; we only
|
||||
// refresh it when it is older than this to avoid a write on every request. The
|
||||
// 1h resolution is a deliberate constant (forensics granularity, not accounting).
|
||||
const LAST_USED_THROTTLE_MS = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Core API-key lifecycle service. Owns minting (create), the single validator
|
||||
* shared by BOTH the REST jwt.strategy path and the /mcp Bearer path (validate),
|
||||
* and revocation (revoke). The `api_keys` ROW is the sole source of truth for a
|
||||
* key's lifetime and revocation — never the JWT, which carries no `exp` claim.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ApiKeyService implements OnModuleInit {
|
||||
private readonly logger = new Logger(ApiKeyService.name);
|
||||
|
||||
constructor(
|
||||
private readonly apiKeyRepo: ApiKeyRepo,
|
||||
private readonly userRepo: UserRepo,
|
||||
private readonly workspaceRepo: WorkspaceRepo,
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
// Boot log so the kill-switch state after each deploy is verifiable in logs.
|
||||
const enabled = this.environmentService.isApiKeysEnabled();
|
||||
const raw = this.environmentService.getApiKeysEnabledRaw();
|
||||
this.logger.log(
|
||||
`API keys: ${enabled ? 'ENABLED' : 'DISABLED'} (API_KEYS_ENABLED=${
|
||||
raw ?? 'unset'
|
||||
})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a new key for `user`. mint-then-insert ordering (R1):
|
||||
* 1. generate the id first — it must be in the JWT payload before the row.
|
||||
* 2. mint the JWT (no `exp` claim). A mint failure aborts before any row is
|
||||
* written (inert), so a half-created key cannot exist.
|
||||
* 3. insert the row last. A lost response leaves an orphaned row that is
|
||||
* visible in `list` and self-heals (the user revokes it).
|
||||
* The token is returned ONCE and never stored — the JWT is self-contained, so
|
||||
* no token material lives in the table.
|
||||
*
|
||||
* `expiresAt`: `undefined` -> default 1 year; `null` -> unlimited (explicit);
|
||||
* a Date -> that instant (a past date is rejected at the DTO layer).
|
||||
*/
|
||||
async create(
|
||||
user: User,
|
||||
name: string,
|
||||
expiresAt?: Date | null,
|
||||
): Promise<{ token: string; key: ApiKey }> {
|
||||
const resolvedExpiresAt =
|
||||
expiresAt === undefined
|
||||
? new Date(Date.now() + DEFAULT_LIFETIME_MS)
|
||||
: expiresAt;
|
||||
|
||||
const apiKeyId = uuid7();
|
||||
|
||||
const token = await this.tokenService.generateApiToken({
|
||||
apiKeyId,
|
||||
user,
|
||||
workspaceId: user.workspaceId,
|
||||
});
|
||||
|
||||
const key = await this.apiKeyRepo.insert({
|
||||
id: apiKeyId,
|
||||
name,
|
||||
creatorId: user.id,
|
||||
workspaceId: user.workspaceId,
|
||||
expiresAt: resolvedExpiresAt,
|
||||
});
|
||||
|
||||
return { token, key };
|
||||
}
|
||||
|
||||
/**
|
||||
* The single validator for an api-key principal, shared by jwt.strategy and the
|
||||
* /mcp Bearer router. Returns `{ user, workspace }` (the same shape the access
|
||||
* path returns) so the AuthUser/AuthWorkspace decorators and MCP identity work
|
||||
* unchanged.
|
||||
*
|
||||
* Failure semantics (R4, anti-enumeration): a DEFINITE negative fact — feature
|
||||
* disabled, missing/revoked/expired row, workspace mismatch, disabled user —
|
||||
* throws a bare `UnauthorizedException` (a single generic 401 for every case;
|
||||
* an agent cannot distinguish expired from revoked, and its reaction is
|
||||
* identical). An UNEXPECTED (infra) error is NOT caught here: it propagates so
|
||||
* the surface returns 5xx, never a masked 401 (deny-on-decision / 5xx-on-infra).
|
||||
* There is NO validate cache: it is 2–3 PK lookups (~1ms), two orders of
|
||||
* magnitude cheaper than the bcrypt it replaces; a cache would only add a
|
||||
* Date-serialization trap and a revocation lag. Revocation is immediate.
|
||||
*/
|
||||
async validate(
|
||||
payload: JwtApiKeyPayload,
|
||||
): Promise<{ user: User; workspace: Workspace }> {
|
||||
// Kill-switch OFF: deny unconditionally (same generic 401). Note the
|
||||
// endpoints additionally 404 at the controller; here we deny the token.
|
||||
if (!this.environmentService.isApiKeysEnabled()) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
if (!payload?.apiKeyId || !payload?.sub || !payload?.workspaceId) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const row = await this.apiKeyRepo.findById(
|
||||
payload.apiKeyId,
|
||||
payload.workspaceId,
|
||||
);
|
||||
// Absent row = revoked (soft-deleted, invisible to findById), orphaned
|
||||
// (creator/workspace cascade-deleted), or never existed. All terminal deny.
|
||||
if (!row) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
// Expiry is read from the ROW, never an `exp` JWT claim.
|
||||
if (row.expiresAt && row.expiresAt.getTime() <= Date.now()) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const user = await this.userRepo.findById(
|
||||
payload.sub,
|
||||
payload.workspaceId,
|
||||
{ includeIsAgent: true },
|
||||
);
|
||||
if (!user || isUserDisabled(user)) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
// The key acts only as its creator (defence in depth against a token whose
|
||||
// signed `sub` ever drifted from the row's owner).
|
||||
if (row.creatorId !== user.id) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const workspace = await this.workspaceRepo.findById(payload.workspaceId);
|
||||
if (!workspace) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
// Best-effort, throttled, fire-and-forget forensics stamp AFTER all checks.
|
||||
this.touchLastUsed(row);
|
||||
|
||||
return { user, workspace };
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (soft-delete) a key. Authorization/ownership is decided by the caller
|
||||
* (the controller, via CASL); this only performs the terminal write. Idempotent:
|
||||
* a second revoke is a no-op (the row is already invisible).
|
||||
*/
|
||||
async revoke(id: string, workspaceId: string): Promise<void> {
|
||||
await this.apiKeyRepo.softDelete(id, workspaceId);
|
||||
}
|
||||
|
||||
// Throttled best-effort last_used_at bump: skip if it was touched within the
|
||||
// window; otherwise fire-and-forget so a stamp write never fails or slows the
|
||||
// request (mirrors SessionActivityService.trackActivity).
|
||||
private touchLastUsed(row: ApiKey): void {
|
||||
const last = row.lastUsedAt ? new Date(row.lastUsedAt).getTime() : 0;
|
||||
if (Date.now() - last < LAST_USED_THROTTLE_MS) return;
|
||||
void this.apiKeyRepo.touchLastUsed(row.id).catch((err) => {
|
||||
this.logger.warn(
|
||||
`Failed to update api_key last_used_at for ${row.id}: ${
|
||||
(err as Error)?.message ?? err
|
||||
}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
IsDateString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
registerDecorator,
|
||||
ValidationOptions,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* `expiresAt` must be strictly in the future. Skips validation for `null`
|
||||
* (explicit "unlimited") and `undefined` (server applies the 1-year default),
|
||||
* so only an actually-supplied date is range-checked. Rejecting a PAST date at
|
||||
* the DTO layer means a caller cannot mint an already-dead key.
|
||||
*/
|
||||
function IsFutureDateString(options?: ValidationOptions) {
|
||||
return function (object: object, propertyName: string) {
|
||||
registerDecorator({
|
||||
name: 'isFutureDateString',
|
||||
target: object.constructor,
|
||||
propertyName,
|
||||
options: {
|
||||
message: 'expiresAt must be a date in the future',
|
||||
...options,
|
||||
},
|
||||
validator: {
|
||||
validate(value: unknown) {
|
||||
if (value === null || value === undefined) return true;
|
||||
if (typeof value !== 'string') return false;
|
||||
const t = Date.parse(value);
|
||||
return !Number.isNaN(t) && t > Date.now();
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export class CreateApiKeyDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
name: string;
|
||||
|
||||
// undefined -> default 1 year (applied server-side); null -> unlimited
|
||||
// (explicit); an ISO date string -> that instant, which must be in the future.
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
@IsFutureDateString()
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class RevokeApiKeyDto {
|
||||
@IsUUID()
|
||||
id: string;
|
||||
}
|
||||
@@ -20,3 +20,71 @@ describe('AuthController', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// The collab-token handler is the ARM-SEAM of the #501 anti-laundering defense:
|
||||
// it derives the api-key origin args ({ apiKeyId }) from the SIGNED-derived
|
||||
// `req.raw` fields (stamped by jwt.strategy) and threads them into the collab
|
||||
// token mint, forcing principal='api_key' so a later key revoke rejects NEW
|
||||
// collab connections. A regression here (reading `body`, dropping `apiKeyId`,
|
||||
// or inverting the ternary) would silently mint api-key requests as
|
||||
// principal='session' and reopen the laundering hole with a green suite. These
|
||||
// tests pin the EXACT args the controller passes to authService.getCollabToken.
|
||||
describe('AuthController.collabToken arms the api-key origin (#501)', () => {
|
||||
let controller: AuthController;
|
||||
let getCollabToken: jest.Mock;
|
||||
|
||||
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
|
||||
const workspace = { id: 'ws-1' } as any;
|
||||
|
||||
beforeEach(() => {
|
||||
getCollabToken = jest.fn().mockResolvedValue({ token: 'ct' });
|
||||
controller = new AuthController(
|
||||
{ getCollabToken } as any, // authService
|
||||
{} as any, // sessionService
|
||||
{} as any, // environmentService
|
||||
{} as any, // moduleRef
|
||||
{} as any, // auditService
|
||||
);
|
||||
});
|
||||
|
||||
it('threads { apiKeyId } when req.raw marks an api_key principal (ARMED)', async () => {
|
||||
const req = { raw: { authType: 'api_key', apiKeyId: 'k-1' } } as any;
|
||||
|
||||
await controller.collabToken(user, workspace, req);
|
||||
|
||||
expect(getCollabToken).toHaveBeenCalledTimes(1);
|
||||
expect(getCollabToken).toHaveBeenCalledWith(user, 'ws-1', {
|
||||
apiKeyId: 'k-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes undefined for a normal session request (NOT armed)', async () => {
|
||||
const req = { raw: {} } as any;
|
||||
|
||||
await controller.collabToken(user, workspace, req);
|
||||
|
||||
expect(getCollabToken).toHaveBeenCalledTimes(1);
|
||||
expect(getCollabToken).toHaveBeenCalledWith(user, 'ws-1', undefined);
|
||||
});
|
||||
|
||||
it('does NOT arm when api_key authType lacks an apiKeyId', async () => {
|
||||
const req = { raw: { authType: 'api_key' } } as any;
|
||||
|
||||
await controller.collabToken(user, workspace, req);
|
||||
|
||||
expect(getCollabToken).toHaveBeenCalledWith(user, 'ws-1', undefined);
|
||||
});
|
||||
|
||||
it('reads the SIGNED req.raw, never a spoofable client body', async () => {
|
||||
// A client-supplied body claiming api_key must be ignored: only the
|
||||
// jwt.strategy-stamped req.raw can arm the api-key origin.
|
||||
const req = {
|
||||
raw: {},
|
||||
body: { authType: 'api_key', apiKeyId: 'attacker' },
|
||||
} as any;
|
||||
|
||||
await controller.collabToken(user, workspace, req);
|
||||
|
||||
expect(getCollabToken).toHaveBeenCalledWith(user, 'ws-1', undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -207,8 +207,20 @@ export class AuthController {
|
||||
async collabToken(
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Req() req: FastifyRequest,
|
||||
) {
|
||||
return this.authService.getCollabToken(user, workspace.id);
|
||||
// Thread the api-key origin (#501): when the requester authenticated with an
|
||||
// api key (jwt.strategy stamped req.raw.authType/apiKeyId), the minted collab
|
||||
// token carries principal='api_key' + apiKeyId so a later revoke of the key
|
||||
// rejects NEW collab connections. A normal session request mints a
|
||||
// principal='session' token. Reading the SIGNED-derived req.raw fields (never
|
||||
// a client body) keeps it unspoofable.
|
||||
const raw = req.raw as { authType?: string; apiKeyId?: string };
|
||||
const apiKey =
|
||||
raw.authType === 'api_key' && raw.apiKeyId
|
||||
? { apiKeyId: raw.apiKeyId }
|
||||
: undefined;
|
||||
return this.authService.getCollabToken(user, workspace.id, apiKey);
|
||||
}
|
||||
|
||||
@SkipThrottle({ [AUTH_THROTTLER]: true })
|
||||
|
||||
@@ -5,9 +5,13 @@ import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { WorkspaceModule } from '../workspace/workspace.module';
|
||||
import { SignupService } from './services/signup.service';
|
||||
import { TokenModule } from './token.module';
|
||||
import { ApiKeyModule } from '../api-key/api-key.module';
|
||||
|
||||
@Module({
|
||||
imports: [TokenModule, WorkspaceModule],
|
||||
// ApiKeyModule supplies ApiKeyService, injected into JwtStrategy so an
|
||||
// api_key Bearer/cookie token is validated directly (replacing the absent EE
|
||||
// `ee/api-key` dynamic require).
|
||||
imports: [TokenModule, WorkspaceModule, ApiKeyModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, SignupService, JwtStrategy],
|
||||
exports: [SignupService, AuthService],
|
||||
|
||||
@@ -33,6 +33,17 @@ export type JwtPayload = {
|
||||
aiChatId?: string | null;
|
||||
};
|
||||
|
||||
// The AUTH-PRINCIPAL kind behind a collab token — distinct from `actor`
|
||||
// (provenance). Stamped into EVERY newly-minted collab token (#501): 'session'
|
||||
// for a normal user/session (incl. the internal AI agent, which is session-
|
||||
// backed), 'api_key' when the token was minted by an api-key principal (an
|
||||
// external MCP agent). The discriminator keys on the token's ORIGIN, so the
|
||||
// collab seam can re-check a revoked api key on connect and reject a claimless
|
||||
// token after the rollout grace window. NOT keyed on `actor:'agent'` — the
|
||||
// internal agent is 'agent' but session-backed, so it must stay on the no-check
|
||||
// path.
|
||||
export type CollabPrincipal = 'session' | 'api_key';
|
||||
|
||||
export type JwtCollabPayload = {
|
||||
sub: string;
|
||||
workspaceId: string;
|
||||
@@ -44,6 +55,13 @@ export type JwtCollabPayload = {
|
||||
// Nullable: an external MCP agent has no internal ai_chats row, so it carries
|
||||
// an 'agent' actor with a null aiChatId.
|
||||
aiChatId?: string | null;
|
||||
// Auth-principal discriminator (#501). Present on every post-rollout token;
|
||||
// its absence on a still-valid token past the grace window is treated as an
|
||||
// error, not trust (fail-closed).
|
||||
principal?: CollabPrincipal;
|
||||
// Only when principal === 'api_key': the minting key's id, so the collab seam
|
||||
// can row-check (and reject) a revoked key on connect.
|
||||
apiKeyId?: string;
|
||||
};
|
||||
|
||||
export type JwtExchangePayload = {
|
||||
|
||||
@@ -375,10 +375,20 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
async getCollabToken(user: User, workspaceId: string) {
|
||||
async getCollabToken(
|
||||
user: User,
|
||||
workspaceId: string,
|
||||
// Origin of the request minting this collab token (#501). When the caller is
|
||||
// an api-key principal, its apiKeyId is threaded into the token so the collab
|
||||
// seam can re-check the key on connect (closing api-key -> long-lived-collab
|
||||
// laundering). Absent for a normal session/human request.
|
||||
apiKey?: { apiKeyId: string },
|
||||
) {
|
||||
const token = await this.tokenService.generateCollabToken(
|
||||
user,
|
||||
workspaceId,
|
||||
undefined,
|
||||
apiKey,
|
||||
);
|
||||
return { token };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { TokenService } from './token.service';
|
||||
import { JwtType } from '../dto/jwt-payload';
|
||||
|
||||
@@ -213,4 +214,175 @@ describe('TokenService.generateCollabToken', () => {
|
||||
aiChatId: 'chat-456',
|
||||
});
|
||||
});
|
||||
|
||||
// #501 fail-closed discriminator: EVERY collab token carries a principal.
|
||||
it("defaults principal to 'session' with NO apiKeyId (normal/internal-agent path)", async () => {
|
||||
const { service, jwtService } = makeTokenService();
|
||||
await service.generateCollabToken(makeUser() as never, 'ws-1');
|
||||
const [payload] = jwtService.sign.mock.calls[0];
|
||||
expect(payload.principal).toBe('session');
|
||||
expect(payload).not.toHaveProperty('apiKeyId');
|
||||
});
|
||||
|
||||
it("the internal agent (provenance, NO apiKey) still gets principal='session'", async () => {
|
||||
const { service, jwtService } = makeTokenService();
|
||||
await service.generateCollabToken(
|
||||
makeUser() as never,
|
||||
'ws-1',
|
||||
{ actor: 'agent', aiChatId: 'chat-1' },
|
||||
);
|
||||
const [payload] = jwtService.sign.mock.calls[0];
|
||||
// Keyed on api-key ORIGIN, not actor: an is_agent session token is 'session'.
|
||||
expect(payload.principal).toBe('session');
|
||||
expect(payload).not.toHaveProperty('apiKeyId');
|
||||
});
|
||||
|
||||
it("stamps principal='api_key' + apiKeyId when minted by an api-key principal", async () => {
|
||||
const { service, jwtService } = makeTokenService();
|
||||
await service.generateCollabToken(
|
||||
makeUser() as never,
|
||||
'ws-1',
|
||||
undefined,
|
||||
{ apiKeyId: 'key-9' },
|
||||
);
|
||||
const [payload] = jwtService.sign.mock.calls[0];
|
||||
expect(payload.principal).toBe('api_key');
|
||||
expect(payload.apiKeyId).toBe('key-9');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* API-key token minting MUST carry NO `exp` claim — the ONLY source of truth for
|
||||
* a key's lifetime/revocation is its `api_keys` row, checked on every request.
|
||||
*
|
||||
* This is a LIVE-bug regression: the shared JwtService is registered with a
|
||||
* global `signOptions.expiresIn` (default '90d') that merges into every sign(),
|
||||
* so an api-key minted through it silently gets exp=now+90d and an "unlimited"
|
||||
* key dies in 90 days. TokenService.generateApiToken mints through a dedicated
|
||||
* no-expiry signer instead. These tests construct the REAL signer (a real secret
|
||||
* via the stubbed EnvironmentService) and decode the produced JWT to assert the
|
||||
* observable property: no `exp`.
|
||||
*/
|
||||
describe('TokenService.generateApiToken (no exp claim ever)', () => {
|
||||
const APP_SECRET_LOCAL = 'apikey-secret';
|
||||
|
||||
function makeRealSignerService() {
|
||||
// Give the SHARED jwtService a global expiresIn so a regression (minting
|
||||
// through it) would show up as an exp claim — the exact live bug.
|
||||
const { JwtService } = require('@nestjs/jwt');
|
||||
const sharedJwt = new JwtService({
|
||||
secret: APP_SECRET_LOCAL,
|
||||
signOptions: { expiresIn: '90d', issuer: 'Docmost' },
|
||||
});
|
||||
const environmentService = {
|
||||
getAppSecret: () => APP_SECRET_LOCAL,
|
||||
};
|
||||
const service = new (TokenService as unknown as new (
|
||||
...args: unknown[]
|
||||
) => TokenService)(sharedJwt, environmentService);
|
||||
return { service };
|
||||
}
|
||||
|
||||
const user = makeUser({ id: 'svc-1', workspaceId: 'ws-1' });
|
||||
|
||||
it('mints an api-key JWT with NO exp claim and issuer Docmost', async () => {
|
||||
const { service } = makeRealSignerService();
|
||||
|
||||
const token = await service.generateApiToken({
|
||||
apiKeyId: 'key-1',
|
||||
user: user as never,
|
||||
workspaceId: 'ws-1',
|
||||
});
|
||||
|
||||
const decoded = jwt.decode(token) as Record<string, unknown>;
|
||||
// The observable security property: no expiry lives in the JWT.
|
||||
expect(decoded.exp).toBeUndefined();
|
||||
expect(decoded).toMatchObject({
|
||||
sub: 'svc-1',
|
||||
apiKeyId: 'key-1',
|
||||
workspaceId: 'ws-1',
|
||||
type: JwtType.API_KEY,
|
||||
iss: 'Docmost',
|
||||
});
|
||||
});
|
||||
|
||||
it('demonstrates the live bug it guards: the SHARED signer WOULD add exp', () => {
|
||||
const { JwtService } = require('@nestjs/jwt');
|
||||
const sharedJwt = new JwtService({
|
||||
secret: APP_SECRET_LOCAL,
|
||||
signOptions: { expiresIn: '90d', issuer: 'Docmost' },
|
||||
});
|
||||
// Even with empty per-call options the global expiresIn merges in.
|
||||
const leaky = sharedJwt.sign({ sub: 'x', type: JwtType.API_KEY }, {});
|
||||
expect((jwt.decode(leaky) as Record<string, unknown>).exp).toBeDefined();
|
||||
});
|
||||
|
||||
it('refuses to mint for a disabled user', async () => {
|
||||
const { service } = makeRealSignerService();
|
||||
await expect(
|
||||
service.generateApiToken({
|
||||
apiKeyId: 'key-1',
|
||||
user: makeUser({ deactivatedAt: new Date() }) as never,
|
||||
workspaceId: 'ws-1',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* verifyJwtOneOf is the type-routing primitive: verify the signature once and
|
||||
* assert the token type is on an explicit allowlist. It must NOT degrade into a
|
||||
* "return whatever type" helper — a token whose type is off the allowlist is
|
||||
* rejected with the same generic error as a single-type mismatch.
|
||||
*/
|
||||
describe('TokenService.verifyJwtOneOf (allowlist type-routing)', () => {
|
||||
it('returns the payload when the type is on the allowlist', async () => {
|
||||
const verifyAsync = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ type: JwtType.API_KEY, sub: 'u-1' });
|
||||
const { service } = makeTokenService({ verifyAsync });
|
||||
|
||||
const payload = await service.verifyJwtOneOf(token123(), [
|
||||
JwtType.ACCESS,
|
||||
JwtType.API_KEY,
|
||||
]);
|
||||
|
||||
expect(payload).toMatchObject({ type: JwtType.API_KEY, sub: 'u-1' });
|
||||
expect(verifyAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('accepts the OTHER allowed type too', async () => {
|
||||
const verifyAsync = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ type: JwtType.ACCESS, sub: 'u-1' });
|
||||
const { service } = makeTokenService({ verifyAsync });
|
||||
|
||||
await expect(
|
||||
service.verifyJwtOneOf(token123(), [JwtType.ACCESS, JwtType.API_KEY]),
|
||||
).resolves.toMatchObject({ type: JwtType.ACCESS });
|
||||
});
|
||||
|
||||
it('rejects a token whose type is OFF the allowlist (confused-deputy guard)', async () => {
|
||||
const verifyAsync = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ type: JwtType.COLLAB, sub: 'u-1' });
|
||||
const { service } = makeTokenService({ verifyAsync });
|
||||
|
||||
await expect(
|
||||
service.verifyJwtOneOf(token123(), [JwtType.ACCESS, JwtType.API_KEY]),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('verifies the signature exactly ONCE', async () => {
|
||||
const verifyAsync = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ type: JwtType.ACCESS });
|
||||
const { service } = makeTokenService({ verifyAsync });
|
||||
await service.verifyJwtOneOf(token123(), [JwtType.ACCESS]);
|
||||
expect(verifyAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
function token123(): string {
|
||||
return 'a.b.c';
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import type { StringValue } from 'ms';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
import {
|
||||
JwtApiKeyPayload,
|
||||
@@ -62,6 +61,12 @@ export class TokenService {
|
||||
// token carries no actor/aiChatId and is treated as 'user' downstream.
|
||||
// aiChatId is nullable for an external agent with no internal ai_chats row.
|
||||
provenance?: { actor: 'agent'; aiChatId: string | null },
|
||||
// Optional api-key origin (#501). When the collab token is minted by an
|
||||
// api-key principal (an external MCP agent), the caller passes the key id so
|
||||
// the token carries principal='api_key' + apiKeyId and the collab seam can
|
||||
// re-check the key on connect. Absent -> principal='session' (a normal
|
||||
// user/session, including the internal session-backed AI agent).
|
||||
apiKey?: { apiKeyId: string },
|
||||
): Promise<string> {
|
||||
if (isUserDisabled(user)) {
|
||||
throw new ForbiddenException();
|
||||
@@ -71,6 +76,10 @@ export class TokenService {
|
||||
sub: user.id,
|
||||
workspaceId,
|
||||
type: JwtType.COLLAB,
|
||||
// Fail-closed discriminator on EVERY minted token: 'api_key' when minted by
|
||||
// an api-key principal, else 'session'.
|
||||
principal: apiKey ? 'api_key' : 'session',
|
||||
...(apiKey ? { apiKeyId: apiKey.apiKeyId } : {}),
|
||||
...(provenance
|
||||
? { actor: provenance.actor, aiChatId: provenance.aiChatId }
|
||||
: {}),
|
||||
@@ -123,9 +132,8 @@ export class TokenService {
|
||||
apiKeyId: string;
|
||||
user: User;
|
||||
workspaceId: string;
|
||||
expiresIn?: StringValue | number;
|
||||
}): Promise<string> {
|
||||
const { apiKeyId, user, workspaceId, expiresIn } = opts;
|
||||
const { apiKeyId, user, workspaceId } = opts;
|
||||
if (isUserDisabled(user)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
@@ -137,7 +145,32 @@ export class TokenService {
|
||||
type: JwtType.API_KEY,
|
||||
};
|
||||
|
||||
return this.jwtService.sign(payload, expiresIn ? { expiresIn } : {});
|
||||
// API-key tokens carry NO `exp` claim EVER — the ONLY source of truth for a
|
||||
// key's lifetime and revocation is its `api_keys` row (checked on every
|
||||
// request), not the JWT. This CANNOT use `this.jwtService`: TokenModule
|
||||
// registers it with a global `signOptions.expiresIn` (JWT_TOKEN_EXPIRES_IN,
|
||||
// default '90d'), which merges into EVERY sign() call — even `sign(payload,
|
||||
// {})` — and `{ expiresIn: undefined }` THROWS rather than stripping it
|
||||
// (verified empirically). So an "unlimited" key minted through the shared
|
||||
// signer would silently get exp=now+90d and die in 90 days regardless of its
|
||||
// row. We mint through a dedicated no-expiry signer, re-stamping only
|
||||
// `issuer: 'Docmost'` for claim parity with the shared signer.
|
||||
return this.apiKeyJwtService().sign(payload);
|
||||
}
|
||||
|
||||
// Lazily-built JWT signer for API-key tokens: same APP_SECRET, same 'Docmost'
|
||||
// issuer, but WITHOUT the global `expiresIn` — so minted API-key tokens have no
|
||||
// `exp` claim. Built once and cached. Verification still goes through the
|
||||
// shared verifier (same secret); `verifyAsync` does not require an `exp`.
|
||||
private _apiKeyJwtService?: JwtService;
|
||||
private apiKeyJwtService(): JwtService {
|
||||
if (!this._apiKeyJwtService) {
|
||||
this._apiKeyJwtService = new JwtService({
|
||||
secret: this.environmentService.getAppSecret(),
|
||||
signOptions: { issuer: 'Docmost' },
|
||||
});
|
||||
}
|
||||
return this._apiKeyJwtService;
|
||||
}
|
||||
|
||||
async generatePdfRenderToken(
|
||||
@@ -177,4 +210,31 @@ export class TokenService {
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a token's signature ONCE and assert its `type` is one of `allowed`.
|
||||
*
|
||||
* This is the type-routing primitive for surfaces that legitimately accept
|
||||
* more than one token type on the same Bearer slot (the /mcp Bearer path
|
||||
* accepts both an ACCESS and an API_KEY token). It is deliberately NOT a
|
||||
* "verify-and-return-whatever-type" helper — that would be a reusable
|
||||
* confused-deputy footgun (any caller could then feed an attachment/collab
|
||||
* token where an access token is expected). An explicit allowlist preserves
|
||||
* the type-pinning property of `verifyJwt`: a token whose `type` is not in the
|
||||
* allowlist is rejected with the SAME generic error as a type mismatch, and
|
||||
* the signature is verified exactly once (no double-verify).
|
||||
*/
|
||||
async verifyJwtOneOf(token: string, allowed: JwtType[]) {
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: this.environmentService.getAppSecret(),
|
||||
});
|
||||
|
||||
if (!allowed.includes(payload.type)) {
|
||||
throw new UnauthorizedException(
|
||||
'Invalid JWT token. Token type does not match.',
|
||||
);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ describe('JwtStrategy — provenance derivation', () => {
|
||||
const userSessionRepo: any = { findActiveById: jest.fn() };
|
||||
const sessionActivityService: any = { trackActivity: jest.fn() };
|
||||
const environmentService: any = { getAppSecret: () => 'test-secret' };
|
||||
const moduleRef: any = {};
|
||||
// ACCESS-path tests never touch the api-key seam; a bare stub suffices.
|
||||
const apiKeyService: any = { validate: jest.fn() };
|
||||
|
||||
const strategy = new JwtStrategy(
|
||||
userRepo,
|
||||
@@ -30,7 +31,7 @@ describe('JwtStrategy — provenance derivation', () => {
|
||||
userSessionRepo,
|
||||
sessionActivityService,
|
||||
environmentService,
|
||||
moduleRef,
|
||||
apiKeyService,
|
||||
);
|
||||
return { strategy, userRepo };
|
||||
}
|
||||
@@ -122,25 +123,29 @@ describe('JwtStrategy — provenance derivation', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Provenance derivation on the API-KEY path (jwt.strategy.validateApiKey, #486).
|
||||
* Provenance derivation on the API-KEY path (jwt.strategy.validateApiKey, #486
|
||||
* + #501).
|
||||
*
|
||||
* The access-token path stamped provenance; the API-key path returned early
|
||||
* WITHOUT it, so an is_agent API key's REST writes recorded no 'agent' marker.
|
||||
* The API-key payload carries no signed claim, so provenance is resolved from the
|
||||
* SERVER-SIDE user returned by ApiKeyService.validateApiKey: isAgent -> 'agent',
|
||||
* SERVER-SIDE user returned by ApiKeyService.validate: isAgent -> 'agent',
|
||||
* otherwise 'user'; aiChatId is always null (an API key has no ai_chats row).
|
||||
*
|
||||
* The enterprise ApiKeyService is not bundled in the OSS build, so the strategy
|
||||
* loads it through an overridable `resolveApiKeyService` seam that we stub here.
|
||||
* #501 wires the CORE ApiKeyService (the EE `ee/api-key` module is absent in the
|
||||
* fork) directly into the strategy — no dynamic require. The strategy also stamps
|
||||
* `req.raw.authType='api_key'` + `req.raw.apiKeyId` for the "a token cannot manage
|
||||
* tokens" guard on the /api-keys surface.
|
||||
*/
|
||||
describe('JwtStrategy — API-key provenance derivation (#486)', () => {
|
||||
function makeApiKeyStrategy(validateApiKeyImpl: (p: any) => Promise<any>) {
|
||||
describe('JwtStrategy — API-key provenance derivation (#486/#501)', () => {
|
||||
function makeApiKeyStrategy(validateImpl: (p: any) => Promise<any>) {
|
||||
const userRepo: any = { findById: jest.fn() };
|
||||
const workspaceRepo: any = { findById: jest.fn() };
|
||||
const userSessionRepo: any = { findActiveById: jest.fn() };
|
||||
const sessionActivityService: any = { trackActivity: jest.fn() };
|
||||
const environmentService: any = { getAppSecret: () => 'test-secret' };
|
||||
const moduleRef: any = {};
|
||||
const validate = jest.fn(validateImpl);
|
||||
const apiKeyService: any = { validate };
|
||||
|
||||
const strategy = new JwtStrategy(
|
||||
userRepo,
|
||||
@@ -148,14 +153,9 @@ describe('JwtStrategy — API-key provenance derivation (#486)', () => {
|
||||
userSessionRepo,
|
||||
sessionActivityService,
|
||||
environmentService,
|
||||
moduleRef,
|
||||
apiKeyService,
|
||||
);
|
||||
// Stub the EE ApiKeyService seam (the real module is not in the OSS build).
|
||||
const validateApiKey = jest.fn(validateApiKeyImpl);
|
||||
jest
|
||||
.spyOn(strategy as any, 'resolveApiKeyService')
|
||||
.mockReturnValue({ validateApiKey });
|
||||
return { strategy, validateApiKey };
|
||||
return { strategy, validate };
|
||||
}
|
||||
|
||||
const makeReq = () => ({ raw: {} as Record<string, any> });
|
||||
@@ -166,22 +166,23 @@ describe('JwtStrategy — API-key provenance derivation (#486)', () => {
|
||||
type: JwtType.API_KEY,
|
||||
});
|
||||
|
||||
it("stamps actor='agent' for an is_agent API key (from the validated user)", async () => {
|
||||
it("stamps actor='agent' + authType/apiKeyId for an is_agent API key", async () => {
|
||||
const validated = {
|
||||
user: { id: 'svc-1', isAgent: true },
|
||||
workspace: { id: 'ws-1' },
|
||||
};
|
||||
const { strategy, validateApiKey } = makeApiKeyStrategy(
|
||||
async () => validated,
|
||||
);
|
||||
const { strategy, validate } = makeApiKeyStrategy(async () => validated);
|
||||
const req = makeReq();
|
||||
|
||||
const result = await strategy.validate(req, apiKeyPayload() as any);
|
||||
|
||||
expect(validateApiKey).toHaveBeenCalledTimes(1);
|
||||
expect(validate).toHaveBeenCalledTimes(1);
|
||||
expect(req.raw.actor).toBe('agent');
|
||||
// API keys carry no internal ai_chats row -> null.
|
||||
expect(req.raw.aiChatId).toBeNull();
|
||||
// Principal-kind markers for the management-surface guard.
|
||||
expect(req.raw.authType).toBe('api_key');
|
||||
expect(req.raw.apiKeyId).toBe('key-1');
|
||||
// The validated auth object is returned unchanged (req.user shape preserved).
|
||||
expect(result).toBe(validated);
|
||||
});
|
||||
@@ -197,25 +198,19 @@ describe('JwtStrategy — API-key provenance derivation (#486)', () => {
|
||||
|
||||
expect(req.raw.actor).toBe('user');
|
||||
expect(req.raw.aiChatId).toBeNull();
|
||||
expect(req.raw.authType).toBe('api_key');
|
||||
});
|
||||
|
||||
it('throws Unauthorized (and stamps nothing) when the EE module is missing', async () => {
|
||||
const userRepo: any = { findById: jest.fn() };
|
||||
const strategy = new JwtStrategy(
|
||||
userRepo,
|
||||
{ findById: jest.fn() } as any,
|
||||
{ findActiveById: jest.fn() } as any,
|
||||
{ trackActivity: jest.fn() } as any,
|
||||
{ getAppSecret: () => 'test-secret' } as any,
|
||||
{} as any,
|
||||
);
|
||||
// EE not bundled: the seam returns null.
|
||||
jest.spyOn(strategy as any, 'resolveApiKeyService').mockReturnValue(null);
|
||||
it('propagates a validate() rejection and stamps nothing', async () => {
|
||||
const { strategy } = makeApiKeyStrategy(async () => {
|
||||
throw new UnauthorizedException();
|
||||
});
|
||||
const req = makeReq();
|
||||
|
||||
await expect(
|
||||
strategy.validate(req, apiKeyPayload() as any),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
expect(req.raw.actor).toBeUndefined();
|
||||
expect(req.raw.authType).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy } from 'passport-jwt';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
@@ -9,20 +9,18 @@ import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
|
||||
import { SessionActivityService } from '../../session/session-activity.service';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { extractBearerTokenFromHeader, isUserDisabled } from '../../../common/helpers';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { resolveProvenance } from '../../../common/decorators/auth-provenance.decorator';
|
||||
import { ApiKeyService } from '../../api-key/api-key.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
private logger = new Logger('JwtStrategy');
|
||||
|
||||
constructor(
|
||||
private userRepo: UserRepo,
|
||||
private workspaceRepo: WorkspaceRepo,
|
||||
private userSessionRepo: UserSessionRepo,
|
||||
private sessionActivityService: SessionActivityService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private moduleRef: ModuleRef,
|
||||
private readonly apiKeyService: ApiKeyService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: (req: FastifyRequest) => {
|
||||
@@ -102,12 +100,17 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
}
|
||||
|
||||
private async validateApiKey(req: any, payload: JwtApiKeyPayload) {
|
||||
const apiKeyService = this.resolveApiKeyService();
|
||||
if (!apiKeyService) {
|
||||
throw new UnauthorizedException('Enterprise API Key module missing');
|
||||
}
|
||||
// The fork ships the core `ApiKeyService` (the EE `ee/api-key` module is
|
||||
// absent). `validate` throws a bare UnauthorizedException on any definite
|
||||
// deny (missing/revoked/expired row, disabled user, kill-switch off) and
|
||||
// propagates infra errors (→ 5xx) rather than masking them as a 401.
|
||||
const result = await this.apiKeyService.validate(payload);
|
||||
|
||||
const result = await apiKeyService.validateApiKey(payload);
|
||||
// Stamp the principal kind + key id so the /api-keys management surface can
|
||||
// enforce "a token cannot manage tokens". Done in this branch because it
|
||||
// returns before the shared ACCESS-path stamping below.
|
||||
req.raw.authType = 'api_key';
|
||||
req.raw.apiKeyId = payload.apiKeyId;
|
||||
|
||||
// Stamp the agent-edit provenance for the API-KEY path too (#486). Unlike the
|
||||
// access-token path above, it CANNOT be resolved before this point: the
|
||||
@@ -119,32 +122,10 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
// SERVER-SIDE user (never a client field), so an 'agent' badge is unspoofable
|
||||
// — mirroring the access-token path. Passing `null` for the claim means the
|
||||
// actor is decided solely by user.isAgent.
|
||||
const provenance = resolveProvenance((result as any)?.user, null);
|
||||
const provenance = resolveProvenance(result.user, null);
|
||||
req.raw.actor = provenance.actor;
|
||||
req.raw.aiChatId = provenance.aiChatId;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the enterprise ApiKeyService, or `null` when the EE module is not
|
||||
* bundled in this build (community build). Extracted as an overridable seam so
|
||||
* the API-key provenance stamping can be unit-tested without the EE package
|
||||
* present (docmost is OSS + a separate EE bundle; `require` of the EE path
|
||||
* throws here). Any load/resolve error is treated as "module missing".
|
||||
*/
|
||||
protected resolveApiKeyService(): {
|
||||
validateApiKey: (payload: JwtApiKeyPayload) => Promise<unknown>;
|
||||
} | null {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const ApiKeyModule = require('./../../../ee/api-key/api-key.service');
|
||||
return this.moduleRef.get(ApiKeyModule.ApiKeyService, { strict: false });
|
||||
} catch (err) {
|
||||
this.logger.debug(
|
||||
'API Key module requested but enterprise module not bundled in this build',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { UserModule } from './user/user.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { ApiKeyModule } from './api-key/api-key.module';
|
||||
import { WorkspaceModule } from './workspace/workspace.module';
|
||||
import { PageModule } from './page/page.module';
|
||||
import { AttachmentModule } from './attachment/attachment.module';
|
||||
@@ -29,6 +30,7 @@ import { ClsMiddleware } from 'nestjs-cls';
|
||||
imports: [
|
||||
UserModule,
|
||||
AuthModule,
|
||||
ApiKeyModule,
|
||||
WorkspaceModule,
|
||||
PageModule,
|
||||
AttachmentModule,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user