Compare commits
48 Commits
c1c87c21c3
...
feat/201-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53cbec9354 | ||
|
|
7d64b11045 | ||
|
|
983f2fa654 | ||
|
|
eb5b696431 | ||
|
|
fad1aa0501 | ||
|
|
8bb4224a20 | ||
| 13589b3973 | |||
|
|
69fcccd6e8 | ||
|
|
0db48f1706 | ||
|
|
2e72a24d13 | ||
|
|
aad0a37cfd | ||
|
|
50d3e7b476 | ||
|
|
bd62d906bb | ||
|
|
e4b46ddbfc | ||
|
|
deeec50b5f | ||
|
|
7eefdad512 | ||
|
|
a7f8ee04b3 | ||
|
|
378d8b676b | ||
| 580f7bd5bb | |||
|
|
b538c729c3 | ||
|
|
0643cd1d82 | ||
| e3b23e0d26 | |||
|
|
b392219659 | ||
|
|
ba5cd02439 | ||
|
|
1043fe3b51 | ||
|
|
df50f23d58 | ||
|
|
eb5c8e6611 | ||
|
|
d32ad73158 | ||
|
|
acf2241e23 | ||
|
|
cb61274187 | ||
|
|
fdeede003b | ||
|
|
1d610b3a62 | ||
|
|
6bb9dfdc86 | ||
|
|
770ba70541 | ||
|
|
3d47c306fa | ||
|
|
c919d4f636 | ||
|
|
c4807022f2 | ||
|
|
00ca4ff3d6 | ||
|
|
ef7d04d1e7 | ||
|
|
5b59a70e3f | ||
|
|
eafd15f0ef | ||
|
|
fbdb8aa16c | ||
|
|
9b61024b95 | ||
|
|
63c26042ba | ||
|
|
2644fe6a83 | ||
|
|
993f884e64 | ||
|
|
2f058a6e40 | ||
|
|
99d0cb8773 |
46
.env.example
46
.env.example
@@ -187,43 +187,11 @@ MCP_DOCMOST_PASSWORD=
|
||||
# Per-request output-token ceiling for the anonymous assistant (default: 512).
|
||||
# Worst-case output per accepted call = agent steps (5) × this value.
|
||||
# SHARE_AI_MAX_OUTPUT_TOKENS=512
|
||||
|
||||
# --- GIT-SYNC (native two-way Docmost <-> git Markdown sync) ---
|
||||
# Master switch. Off by default. When 'true', GIT_SYNC_SERVICE_USER_ID below is
|
||||
# REQUIRED (the service account that git-originated create/move/rename/delete are
|
||||
# attributed to) — the server refuses to boot with sync enabled and no user id.
|
||||
# GIT_SYNC_ENABLED=false
|
||||
#
|
||||
# Serve the per-space vaults over smart-HTTP (the /git host). Defaults to
|
||||
# GIT_SYNC_ENABLED when unset.
|
||||
# GIT_SYNC_HTTP_ENABLED=false
|
||||
#
|
||||
# REQUIRED when GIT_SYNC_ENABLED=true: id of the user that git-originated page
|
||||
# operations (create / move / rename / delete) are attributed to.
|
||||
# GIT_SYNC_SERVICE_USER_ID=
|
||||
#
|
||||
# Where the per-space working vaults live (non-bare repos; the engine needs a
|
||||
# working tree).
|
||||
# Defaults to "<DATA_DIR or ./data>/git-sync".
|
||||
# GIT_SYNC_DATA_DIR=
|
||||
#
|
||||
# Optional remote URL template to mirror each space's vault to (e.g. a git host).
|
||||
# Leave unset to keep vaults local-only.
|
||||
# GIT_SYNC_REMOTE_TEMPLATE=
|
||||
#
|
||||
# Path to the SSH private key used when pushing to GIT_SYNC_REMOTE_TEMPLATE.
|
||||
# GIT_SYNC_SSH_KEY_PATH=
|
||||
#
|
||||
# Poll-safety interval in ms — the cadence of the background reconcile cycle
|
||||
# (default: 15000).
|
||||
# GIT_SYNC_POLL_INTERVAL_MS=15000
|
||||
#
|
||||
# Debounce window in ms for collapsing bursts of page edits into one sync cycle
|
||||
# (default: 2000).
|
||||
# GIT_SYNC_DEBOUNCE_MS=2000
|
||||
#
|
||||
# Watchdog timeout in ms for the spawned `git http-backend` process serving a
|
||||
# git smart-HTTP push (default: 120000). A stalled/hung receive-pack is killed
|
||||
# after this deadline so it cannot hold the per-space lock forever.
|
||||
# GIT_SYNC_BACKEND_TIMEOUT_MS=120000
|
||||
#
|
||||
# Second cost backstop: a cluster-wide per-workspace rolling-DAY token budget
|
||||
# (input re-sent per step + output, summed across every accepted turn). The
|
||||
# hourly request cap above bounds how MANY calls run, not how expensive each is,
|
||||
# so this caps the owner's actual provider bill directly. Like the request cap it
|
||||
# FAILS CLOSED if Redis is unavailable (default: 1,000,000 tokens per workspace
|
||||
# per rolling day).
|
||||
# SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY=1000000
|
||||
|
||||
157
.github/workflows/develop.yml
vendored
157
.github/workflows/develop.yml
vendored
@@ -56,3 +56,160 @@ jobs:
|
||||
tags: ${{ env.IMAGE }}:develop
|
||||
cache-from: type=gha,scope=develop-amd64
|
||||
cache-to: type=gha,scope=develop-amd64,mode=max,ignore-error=true
|
||||
|
||||
# e2e jobs run on every develop push but DO NOT gate the build/publish above:
|
||||
# `build` stays `needs: test` only, so the :develop image still ships even if
|
||||
# e2e fails. A failing e2e job turns the run red and triggers GitHub's email
|
||||
# to the pusher — that red run + email is the intended notification, not a
|
||||
# deploy block.
|
||||
e2e-server:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DATABASE_URL: postgresql://docmost:docmost@localhost:5432/docmost
|
||||
REDIS_URL: redis://localhost:6379
|
||||
APP_SECRET: ci-e2e-secret-change-me-min-32-characters
|
||||
APP_URL: http://localhost:3000
|
||||
services:
|
||||
postgres:
|
||||
image: 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:
|
||||
image: 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
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build editor-ext
|
||||
run: pnpm --filter @docmost/editor-ext build
|
||||
|
||||
- name: Run migrations
|
||||
run: pnpm --filter ./apps/server migration:latest
|
||||
|
||||
- name: Run server e2e
|
||||
run: pnpm --filter ./apps/server test:e2e
|
||||
|
||||
# Same rationale as e2e-server: this job is intentionally NOT in
|
||||
# `build.needs`. Deploy of the :develop image must not be blocked by e2e;
|
||||
# a red run plus GitHub's email to the pusher is the notification mechanism.
|
||||
e2e-mcp:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DATABASE_URL: postgresql://docmost:docmost@localhost:5432/docmost
|
||||
REDIS_URL: redis://localhost:6379
|
||||
APP_SECRET: ci-e2e-secret-change-me-min-32-characters
|
||||
APP_URL: http://localhost:3000
|
||||
NODE_ENV: production
|
||||
services:
|
||||
postgres:
|
||||
image: 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:
|
||||
image: 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
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build editor-ext
|
||||
run: pnpm --filter @docmost/editor-ext build
|
||||
|
||||
- name: Build server
|
||||
run: pnpm server:build
|
||||
|
||||
- name: Build mcp
|
||||
run: pnpm --filter @docmost/mcp build
|
||||
|
||||
- name: Run migrations
|
||||
run: pnpm --filter ./apps/server migration:latest
|
||||
|
||||
- name: Start server (prod)
|
||||
# Capture stdout/stderr so a start-up crash (bind error, stack trace,
|
||||
# migration mismatch) is diagnosable; without this the only signal is
|
||||
# the generic health-loop timeout below, ~120s later.
|
||||
run: pnpm --filter ./apps/server start:prod > /tmp/server.log 2>&1 &
|
||||
|
||||
- name: Wait for server health
|
||||
run: |
|
||||
for i in $(seq 1 60); do
|
||||
if curl -fsS http://localhost:3000/api/health > /dev/null; then
|
||||
echo "Server is healthy"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "Server did not become healthy in time"
|
||||
exit 1
|
||||
|
||||
- name: Dump server log on failure
|
||||
if: failure()
|
||||
run: cat /tmp/server.log || true
|
||||
|
||||
- name: Seed admin
|
||||
run: |
|
||||
curl -fsS -X POST http://localhost:3000/api/auth/setup \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"E2E","email":"e2e@example.com","password":"E2ePassword123","workspaceName":"E2E"}'
|
||||
|
||||
- name: Run mcp e2e
|
||||
env:
|
||||
DOCMOST_API_URL: http://localhost:3000/api
|
||||
DOCMOST_EMAIL: e2e@example.com
|
||||
DOCMOST_PASSWORD: E2ePassword123
|
||||
run: pnpm --filter @docmost/mcp test:e2e
|
||||
|
||||
7
.github/workflows/test.yml
vendored
7
.github/workflows/test.yml
vendored
@@ -68,13 +68,6 @@ jobs:
|
||||
- name: Build editor-ext
|
||||
run: pnpm --filter @docmost/editor-ext build
|
||||
|
||||
# git-sync and mcp are no longer committed in built form (build/ is
|
||||
# gitignored), so CI must compile them: the server resolves both via their
|
||||
# built build/index.js. The server pretest also builds them, but building
|
||||
# here keeps it explicit and independent of pnpm lifecycle ordering.
|
||||
- name: Build git-sync and mcp
|
||||
run: pnpm --filter @docmost/git-sync build && pnpm --filter @docmost/mcp build
|
||||
|
||||
- name: Run unit tests
|
||||
run: pnpm -r test
|
||||
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -5,12 +5,6 @@ data
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
# workspace package node_modules (pnpm symlinks — never commit; they bake
|
||||
# machine-local store paths) and the git-sync compiled output (built in CI/Docker
|
||||
# via `pnpm build`, never committed, so src/ and prod can never silently diverge).
|
||||
packages/*/node_modules/
|
||||
packages/git-sync/build/
|
||||
packages/mcp/build/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
@@ -48,6 +42,7 @@ lerna-debug.log*
|
||||
.nx/installation
|
||||
.nx/cache
|
||||
.claude/worktrees/
|
||||
.claude/tmp/
|
||||
|
||||
# TypeScript incremental build artifacts
|
||||
*.tsbuildinfo
|
||||
|
||||
58
AGENTS.md
58
AGENTS.md
@@ -182,7 +182,7 @@ tea issues create --repo vvzvlad/gitmost --labels feature \
|
||||
|
||||
## Monorepo layout
|
||||
|
||||
pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Five workspace packages:
|
||||
pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
|
||||
|
||||
| Path | Name | Stack | Role |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -190,7 +190,6 @@ pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Five workspace packages:
|
||||
| `apps/client` | `client` | React 18 + Vite + Mantine 8 + TanStack Query + Jotai | SPA frontend |
|
||||
| `packages/editor-ext` | `@docmost/editor-ext` | Tiptap/ProseMirror | Shared Tiptap node/mark extensions, imported by both the client and the server |
|
||||
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Does **not** import `editor-ext` — it keeps its own vendored mirror of the schema in `packages/mcp/src/lib/` |
|
||||
| `packages/git-sync` | `@docmost/git-sync` | Tiptap/ProseMirror, Yjs, git | Pure ProseMirror↔Markdown converter plus the two-way Docmost↔git Markdown sync engine. Bundled into the server (loaded over the ESM bridge), built in CI and the Dockerfile. Does **not** import `editor-ext` — it keeps its own vendored mirror of the document schema (kept in sync with `editor-ext`). |
|
||||
|
||||
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
||||
|
||||
@@ -264,7 +263,7 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
|
||||
### Client structure
|
||||
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
||||
- **TanStack Query** for server state (one `queries/` file per feature), **Jotai** atoms for local/shared UI state, **Mantine 8** + CSS modules (`*.module.css`) + `postcss-preset-mantine` for UI.
|
||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, import/export) — editor schema changes often need to be made in `editor-ext`, not just the client. Note neither `packages/mcp` nor `packages/git-sync` depends on `editor-ext`; each carries its own mirrored copy of the schema. There are now **three** independent copies (`editor-ext` is canonical, plus `packages/mcp` and `packages/git-sync`), so keep all three in sync manually when the document schema changes.
|
||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, import/export) — editor schema changes often need to be made in `editor-ext`, not just the client. Note `packages/mcp` does *not* depend on `editor-ext`; it carries its own mirrored copy of the schema, so keep the two in sync manually when the document schema changes.
|
||||
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
||||
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
||||
|
||||
@@ -284,37 +283,46 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
||||
|
||||
### Cutting a release
|
||||
|
||||
The git tag is the source of truth for the displayed version (UI reads `git describe --tags`); the `package.json` bump is metadata only. Steps:
|
||||
The git tag is the source of truth for the displayed version (the client UI reads `git describe --tags` via `vite.config.ts`); the `package.json` bump is metadata that backs the server `/version` endpoint (`version.service.ts`).
|
||||
|
||||
1. Make sure `main` is clean and pushed (`git status`, `git push`).
|
||||
**Golden rule — tag on `develop` first, merge to `main` afterwards.** Cut the version-bump commit on `develop`, put the tag on *that* commit, and push it. Merge `develop` into `main` later (it does not block the tag or the release). Because the tag is in `develop`'s ancestry from the moment it is created, `git describe` on `develop` — and the `ghcr.io/vvzvlad/gitmost:develop` image — reports the new version immediately, with **no back-merge dance**. Do **not** tag `main`'s merge commit; that is the mistake described in the pitfall below (we hit it twice).
|
||||
|
||||
Steps:
|
||||
|
||||
1. Make sure `develop` is up to date, clean, and pushed to **both** remotes (`git status`; `git push gitea develop && git push github develop`).
|
||||
2. Pick `vX.Y.Z` (SemVer): **minor** bump for a batch of features, **patch** for fixes only. Review what landed with `git log <last-tag>..HEAD --no-merges`.
|
||||
3. Bump `"version"` to `X.Y.Z` in the **root** `package.json`, `apps/client/package.json`, and `apps/server/package.json` (keep all three in sync). Leave `packages/mcp` alone — it is versioned independently. Commit with the bare version as the subject, e.g. `0.91.0` (matches past bump commits).
|
||||
4. Update `CHANGELOG.md` (Keep a Changelog format): add a `## [X.Y.Z] - YYYY-MM-DD` section summarising `git log vPREV..HEAD --no-merges` grouped by type (Breaking / Added / Changed / Fixed / Removed), and add the `compare/vPREV...vX.Y.Z` link at the bottom. Fold the bump + changelog into the release commit.
|
||||
5. Tag the release commit with a **lightweight** tag (existing release tags are lightweight): `git tag vX.Y.Z`.
|
||||
6. Push commit and tag: `git push origin main && git push origin vX.Y.Z`. Pushing the `v*` tag triggers `release.yml` (multi-arch GHCR images + a draft GitHub Release).
|
||||
7. **Back-merge the release into `develop`** so develop builds report the new version: `git checkout develop && git merge --no-ff main && git push origin develop` (push to Gitea as well if that is the canonical remote).
|
||||
3. Bump `"version"` to `X.Y.Z` in the **root** `package.json`, `apps/client/package.json`, and `apps/server/package.json` (keep all three in sync). Leave `packages/mcp` alone — it is versioned independently. Commit **on `develop`** with the bare version as the subject, e.g. `0.94.1` (matches past bump commits).
|
||||
4. For a real release (skip for a bare hotfix tag), update `CHANGELOG.md` (Keep a Changelog format): add a `## [X.Y.Z] - YYYY-MM-DD` section summarising `git log vPREV..HEAD --no-merges` grouped by type (Breaking / Added / Changed / Fixed / Removed), and the `compare/vPREV...vX.Y.Z` link at the bottom. Fold it into the bump commit.
|
||||
5. Tag that develop commit with a **lightweight** tag (existing release tags are lightweight): `git tag vX.Y.Z`.
|
||||
6. Push the branch **and** the tag to **both** writable remotes — `git push <branch>` does **not** push tags, and tags are per-remote:
|
||||
```bash
|
||||
git push gitea develop && git push gitea vX.Y.Z
|
||||
git push github develop && git push github vX.Y.Z
|
||||
```
|
||||
Pushing the `v*` tag to `github` triggers `release.yml` (multi-arch GHCR images + a draft GitHub Release). The tag *must* exist on `github`, because the `:develop` and release images are built there by GitHub Actions and `git describe` on the runner only sees the tags present on `github` (not your local clone or `gitea`).
|
||||
7. Merge `develop` into `main` when ready (commonly later — this does not gate the release):
|
||||
```bash
|
||||
git checkout main
|
||||
git merge --ff-only develop # or a merge commit if fast-forward is not possible
|
||||
git push gitea main && git push github main
|
||||
```
|
||||
The tag is already reachable from `main` (it lives in the `develop` history that `main` now contains), so `main` reports `vX.Y.Z` too — no extra tagging needed.
|
||||
|
||||
#### Why develop keeps showing the *previous* version (and why step 7 matters)
|
||||
#### Pitfall: tagging `main` instead of `develop` (the mistake to avoid)
|
||||
|
||||
The UI version is `git describe --tags --always` (see `vite.config.ts`), which walks **backwards from the current commit** and picks the **nearest tag reachable in that commit's ancestry**, then appends `-<commits-since-tag>-g<short-hash>`.
|
||||
`git describe --tags --always` (see `vite.config.ts`) walks **backwards from the current commit** and picks the **nearest tag reachable in that commit's ancestry**, then appends `-<commits-since-tag>-g<short-hash>`.
|
||||
|
||||
The release tag (`vX.Y.Z`) is created on **`main`'s release merge commit**, and that commit is **not** in `develop`'s history. So until the release is back-merged, `git describe` on `develop` cannot see the new tag and falls back to the *previous* reachable tag. Result: every develop build — and the `ghcr.io/vvzvlad/gitmost:develop` image — keeps reporting e.g. `v0.91.0-NNN-g<hash>` even though `main` is already tagged `v0.93.0`. This is the classic git-flow pitfall: the version on `develop` does **not** advance just because a release was tagged on `main`.
|
||||
The wrong flow we fell into twice: merge `develop` into `main` *first*, then tag `main`'s **release merge commit**. That merge commit is **not** in `develop`'s history, so `git describe` on `develop` cannot see the new tag and falls back to the *previous* reachable one. Result: every develop build — and the `ghcr.io/vvzvlad/gitmost:develop` image — keeps reporting e.g. `v0.93.0-NNN-g<hash>` even though a release was "cut". Tagging on `develop` (the golden rule above) avoids this entirely: the tag is in `develop`'s ancestry from the start, and `main` still gets it once `develop` is merged in.
|
||||
|
||||
Back-merging `main → develop` (step 7) pulls the tagged release commit into `develop`'s ancestry, after which develop builds correctly show `vX.Y.Z-NNN-g<hash>`. If `develop` already drifted (release tagged but never back-merged), just run step 7 now — no new tag is needed.
|
||||
Second gotcha — the tag must exist on the remote CI builds from. `git describe` names a tag **ref**, not just a commit. The `:develop` and release images are built by GitHub Actions (`develop.yml` / `release.yml`, `actions/checkout` with `fetch-depth: 0`), so the version they print depends on which tags exist **on the `github` remote** — not on your local clone or on `gitea`. `git push <branch>` does **not** push tags; push them explicitly to **each** remote (`gitea` and `github`). A tag that only lives on `gitea` is invisible to the GitHub build.
|
||||
|
||||
##### The tag must also exist on the remote that CI builds from (multi-remote gotcha)
|
||||
If you already tagged `main` (or `develop` still shows the old version), recover without re-tagging:
|
||||
|
||||
`git describe` names a tag **ref**, not just a commit — so the back-merge is *necessary but not sufficient*. The develop image is built by GitHub Actions (`develop.yml`, `actions/checkout` with `fetch-depth: 0`, then `git describe --tags --always`), so the version it prints depends on which tags exist **on the `github` remote**, not on your local clone or on `gitea`.
|
||||
1. Make the tagged commit reachable from `develop` — either back-merge `main → develop` (`git checkout develop && git merge --no-ff main`), or confirm the tagged commit is already an ancestor of `develop`.
|
||||
2. Make sure the tag exists on `github`: compare `git ls-remote --tags github` with `gitea`, and push the missing one (`git push github vX.Y.Z` / `git push gitea vX.Y.Z`). Pushing a `v*` tag to `github` also fires `release.yml` — expected, just be aware.
|
||||
3. Re-run the develop build (`gh workflow run Develop`, or push any commit to `develop`) so `git describe` re-resolves with the tag now in scope.
|
||||
|
||||
This repo has two writable remotes — `gitea` (canonical, where commits land) and `github` (where the `:develop` and release images are built) — plus `upstream` (docmost, never push). **`git push <branch>` does NOT push tags**; tags must be pushed explicitly and *to each remote separately*. A release tag that only lives on `gitea` is invisible to the GitHub Actions build: even with the tagged commit fully in `develop`'s history (step 7 done), `git describe` on the GitHub runner falls back to the previous tag it *does* have, so the develop image keeps showing e.g. `v0.91.0-NNN` while `git describe` locally already says `v0.93.0-NN`.
|
||||
|
||||
Fix / checklist when develop still shows the old version after a back-merge:
|
||||
|
||||
1. Confirm the tag is missing on github: `git ls-remote --tags github` (compare with `gitea`).
|
||||
2. Push it there: `git push github vX.Y.Z` (and `git push gitea vX.Y.Z` if it is missing on gitea too). Note: pushing a `v*` tag to `github` also triggers `release.yml` (multi-arch GHCR images + draft Release) — expected, but be aware.
|
||||
3. Re-run the develop build (`gh workflow run Develop`, or push any commit to `develop`) so `git describe` re-resolves with the tag now present.
|
||||
|
||||
(The `git push origin ...` in steps 6–7 above is shorthand — there is no `origin` remote here; substitute `gitea` **and** `github` as appropriate, and always push release tags to both.)
|
||||
(There is no `origin` remote here — push to `gitea` **and** `github` explicitly, and always push release tags to both.)
|
||||
|
||||
## Planning docs
|
||||
|
||||
|
||||
68
CHANGELOG.md
68
CHANGELOG.md
@@ -10,8 +10,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.94.0] - 2026-06-26
|
||||
|
||||
This release makes AI chat durable and fast: assistant turns are persisted to
|
||||
the database step by step and exported server-side, the desktop app no longer
|
||||
freezes at 100% CPU on long agent runs, and MCP writes are badged with
|
||||
unspoofable AI attribution. It also reworks footnotes (Pandoc-style reuse and
|
||||
per-reference back-links), hardens page moves and duplication against cycles
|
||||
and lost edits, and caps the anonymous public-share assistant with a
|
||||
per-workspace rolling-day token budget.
|
||||
|
||||
### Added
|
||||
|
||||
- **Custom pretty-links for shared pages (`/l/:alias`).** A page editor can give
|
||||
any publicly shared page a short, memorable, workspace-scoped vanity address
|
||||
backed by a new `share_aliases` table. Hitting `/l/<alias>` issues a `302`
|
||||
(never `301`, since the target is retargetable) to the canonical
|
||||
`/share/<key>/p/<slug>` page; an unknown, dangling, or no-longer-readable alias
|
||||
serves the plain SPA index so that the existence of a name never leaks. An
|
||||
alias can be moved to another page (with a confirm-reassign guard) and the
|
||||
foreign key is `ON DELETE SET NULL`, so deleting the target leaves a dangling
|
||||
alias any workspace member can reclaim. (#205)
|
||||
|
||||
- **Temporary notes — auto-move to Trash after a workspace lifetime.** A note can
|
||||
be marked temporary so it auto-moves to Trash once a configurable workspace
|
||||
lifetime elapses (default `DEFAULT_TEMPORARY_NOTE_HOURS` = 24h) unless made
|
||||
permanent first. The deadline is frozen at creation time, so later changes to
|
||||
the workspace setting never reschedule existing notes; an hourly background
|
||||
sweep trashes notes past their deadline (children ride along). An open
|
||||
temporary note shows a banner with a "Make permanent" rescue action; restoring
|
||||
a note from Trash disarms the timer so it is not immediately re-trashed.
|
||||
Operators configure the lifetime per workspace. (#201)
|
||||
|
||||
- **Persistent AI-chat history as the source of truth + server-side export.**
|
||||
An assistant turn is now persisted to the database step by step: the row is
|
||||
inserted upfront as `streaming` and updated as each agent step finishes, then
|
||||
@@ -78,6 +108,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- **AI chat: the desktop app no longer freezes at 100% CPU on long agent runs.**
|
||||
`useChat` re-rendered on every streamed token and `MessageItem`/`ReasoningBlock`
|
||||
re-parsed the whole transcript markdown (marked + DOMPurify) on every delta, so
|
||||
per-turn work grew quadratically and saturated the main thread. The stream is now
|
||||
throttled (`experimental_throttle`) to ~20 Hz and each finalized message row /
|
||||
markdown part / reasoning block is memoized, so a long turn no longer re-parses
|
||||
already-finished content. (#182)
|
||||
- **Editor: caret/selection landed on the wrong line when clicking inside code
|
||||
blocks and footnotes.** The affected NodeViews rendered their non-editable
|
||||
chrome (language menu, footnotes heading, footnote number marker) before the
|
||||
@@ -92,6 +129,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
no longer froze on the previous step's authoritative usage; the current step's
|
||||
estimate is combined per-component with `max`, so the count rises smoothly and
|
||||
never jumps backwards. (#163)
|
||||
- **AI chat: "New chat" during a streaming first turn now resets the whole
|
||||
chat, not just the role badge.** Starting a new chat mid-stream cleared the
|
||||
header but left the in-flight turn's messages behind, so the fresh chat opened
|
||||
pre-populated with the previous conversation; it now fully resets. (#161)
|
||||
- **AI chat: a dropped tool argument now yields an actionable error.** When the
|
||||
model omitted a required parameter (typically `pageId`) in a parallel/batch
|
||||
tool call, the assistant forwarded zod's raw "expected string, received
|
||||
undefined" text; tool inputs now return a message naming each missing/invalid
|
||||
parameter (the JSON Schema contract is unchanged and nothing is backfilled).
|
||||
(#190)
|
||||
- **Page move: cycle checks are now atomic and depth-bounded.** Moving a page
|
||||
under one of its own descendants is rejected in the same transaction as the
|
||||
update (closing a TOCTOU window where two concurrent A→B / B→A moves could
|
||||
form a cycle), and the recursive tree-traversal CTEs carry a cycle/depth guard
|
||||
so a pre-existing cycle can no longer spin a query. (#207)
|
||||
- **Page/editor robustness batch.** Duplicating a page now copies shared
|
||||
attachments for every referencing page (not just the first); colliding block
|
||||
ids are de-duplicated on import/normalize so MCP addressed edits can't hit the
|
||||
wrong node; transient collab store failures are retried so autosave edits
|
||||
aren't lost; and an out-of-order tree move no longer drops the moved subtree.
|
||||
(#206)
|
||||
|
||||
### Security
|
||||
|
||||
- **Public share AI: per-workspace rolling-day token budget.** The anonymous
|
||||
share assistant now caps a workspace's actual token spend (input + output,
|
||||
summed across every accepted turn) over a trailing day, on top of the hourly
|
||||
request cap — so a caller who evades the per-IP throttle still cannot run up
|
||||
the owner's provider bill without bound. Cluster-wide via Redis and FAILS
|
||||
CLOSED if Redis is down; default 1,000,000 tokens/day, overridable via
|
||||
`SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY`. (#159)
|
||||
|
||||
## [0.93.0] - 2026-06-21
|
||||
|
||||
|
||||
@@ -17,9 +17,8 @@ RUN pnpm build
|
||||
|
||||
FROM base AS installer
|
||||
|
||||
# git: required by the git-sync VaultGit (shells out to git)
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl bash git \
|
||||
&& apt-get install -y --no-install-recommends curl bash \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
@@ -34,11 +33,6 @@ COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist
|
||||
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
|
||||
COPY --from=builder /app/packages/mcp/build /app/packages/mcp/build
|
||||
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
|
||||
# git-sync: the server requires @docmost/git-sync at runtime; without these the
|
||||
# image starts and crashes on `require('@docmost/git-sync')`. Built fresh by the
|
||||
# builder's `pnpm build` (nx builds the package's tsc `build` target).
|
||||
COPY --from=builder /app/packages/git-sync/build /app/packages/git-sync/build
|
||||
COPY --from=builder /app/packages/git-sync/package.json /app/packages/git-sync/package.json
|
||||
|
||||
# Copy root package files
|
||||
COPY --from=builder /app/package.json /app/package.json
|
||||
|
||||
@@ -114,7 +114,7 @@ community feature, with no enterprise license. Open it from the page header; the
|
||||
- 🔭 **Viewer comments** — let read-only viewers leave comments.
|
||||
- 🔭 **Password-protected pages** — protect individual pages / shares with a password.
|
||||
- 🔭 **Windows / Linux app** — native desktop app for Windows and Linux.
|
||||
- 🔭 **Mobile app** — mobile apps (iOS first, Android to follow), reusing the existing responsive web UI and editor via a Capacitor wrapper, with offline planned for later. See [docs/mobile-app-plan.md](docs/mobile-app-plan.md).
|
||||
- 🔭 **Mobile app** — mobile apps (iOS first, Android to follow), reusing the existing responsive web UI and editor via a Capacitor wrapper, with offline planned for later. See [issue #195](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/issues/195).
|
||||
- 🔭 **Offline mode** — offline sync & PWA support.
|
||||
- 🔭 **Editor & UX improvements** — blocks inside tables (lists, to-do items), column layout, additional heading levels, highlight blocks, custom emoji in callouts, floating images, anchor links for page mentions, toggles (shared-page width, aside/sidebar, spellcheck, ligatures), sanitized space-tree export, and mentions in breadcrumbs.
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ real-time-коллаборации Docmost, поэтому запись нико
|
||||
- 🔭 **Комментарии зрителей** — возможность комментировать для пользователей с доступом только на чтение.
|
||||
- 🔭 **Защищённые паролем страницы** — защита отдельных страниц / шар паролем.
|
||||
- 🔭 **Приложение для Windows / Linux** — нативное десктоп-приложение для Windows и Linux.
|
||||
- 🔭 **Мобильное приложение** — мобильные приложения (iOS обязательно, Android как пойдёт) на базе существующей адаптивной веб-версии и редактора через обёртку Capacitor; оффлайн запланирован на будущее. См. [docs/mobile-app-plan.md](docs/mobile-app-plan.md).
|
||||
- 🔭 **Мобильное приложение** — мобильные приложения (iOS обязательно, Android как пойдёт) на базе существующей адаптивной веб-версии и редактора через обёртку Capacitor; оффлайн запланирован на будущее. См. [issue #195](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/issues/195).
|
||||
- 🔭 **Офлайн-режим** — офлайн-синхронизация и поддержка PWA.
|
||||
- 🔭 **Улучшения редактора и UX** — блоки внутри таблиц (списки, чек-листы), колоночная вёрстка, дополнительные уровни заголовков, highlight-блоки, кастомные эмодзи в callout-ах, плавающие изображения, anchor-ссылки на упоминания страниц, тоглы (ширина шары, aside/сайдбар, spellcheck, лигатуры), санитизация экспорта дерева спейса и mentions в хлебных крошках.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.93.0",
|
||||
"version": "0.94.1",
|
||||
"scripts": {
|
||||
"dev": "node scripts/copy-vad-assets.mjs && vite",
|
||||
"build": "node scripts/copy-vad-assets.mjs && tsc && vite build",
|
||||
|
||||
@@ -598,6 +598,17 @@
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.",
|
||||
"Restore '{{title}}' and its sub-pages?": "Restore '{{title}}' and its sub-pages?",
|
||||
"Move to trash": "Move to trash",
|
||||
"Make temporary": "Make temporary",
|
||||
"Make permanent": "Make permanent",
|
||||
"New temporary note": "New temporary note",
|
||||
"Temporary note": "Temporary note",
|
||||
"Temporary notes": "Temporary notes",
|
||||
"Temporary note — moves to trash unless made permanent": "Temporary note — moves to trash unless made permanent",
|
||||
"Note will move to trash unless made permanent": "Note will move to trash unless made permanent",
|
||||
"Note is now permanent": "Note is now permanent",
|
||||
"Temporary note lifetime (hours)": "Temporary note lifetime (hours)",
|
||||
"A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.": "A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.",
|
||||
"This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.": "This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.",
|
||||
"Move this page to trash?": "Move this page to trash?",
|
||||
"Restore page": "Restore page",
|
||||
"Permanently delete": "Permanently delete",
|
||||
@@ -715,6 +726,8 @@
|
||||
"Test": "Test",
|
||||
"Available tools": "Available tools",
|
||||
"No tools available": "No tools available",
|
||||
"Failed": "Failed",
|
||||
"OK · {{n}}": "OK · {{n}}",
|
||||
"Created successfully": "Created successfully",
|
||||
"Deleted successfully": "Deleted successfully",
|
||||
"Clear": "Clear",
|
||||
@@ -1167,8 +1180,9 @@
|
||||
"Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.": "Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.",
|
||||
"Built-in assistant persona": "Built-in assistant persona",
|
||||
"Minimize": "Minimize",
|
||||
"Current context size": "Current context size",
|
||||
"Tokens generated this turn": "Tokens generated this turn",
|
||||
"Context size / model limit": "Context size / model limit",
|
||||
"Context window (tokens)": "Context window (tokens)",
|
||||
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Shown as used / total in the chat header. Leave empty to hide the limit.",
|
||||
"AI agent": "AI agent",
|
||||
"Take a look at the current document": "Take a look at the current document",
|
||||
"AI agent is typing…": "AI agent is typing…",
|
||||
@@ -1204,8 +1218,6 @@
|
||||
"Ran tool {{name}}": "Ran tool {{name}}",
|
||||
"AI-agent": "AI-agent",
|
||||
"Edited by AI agent on behalf of {{name}}": "Edited by AI agent on behalf of {{name}}",
|
||||
"Git sync": "Git sync",
|
||||
"Synced from Git on behalf of {{name}}": "Synced from Git on behalf of {{name}}",
|
||||
"Endpoints": "Endpoints",
|
||||
"where we fetch models": "where we fetch models",
|
||||
"All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.": "All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.",
|
||||
@@ -1230,8 +1242,6 @@
|
||||
"MCP server": "MCP server",
|
||||
"expose the workspace": "expose the workspace",
|
||||
"Enable MCP server": "Enable MCP server",
|
||||
"Enable Git sync": "Enable Git sync",
|
||||
"Sync this space's pages to a Git repository.": "Sync this space's pages to a Git repository.",
|
||||
"Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.": "Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.",
|
||||
"Resolves to {{url}}": "Resolves to {{url}}",
|
||||
"Model": "Model",
|
||||
@@ -1319,5 +1329,15 @@
|
||||
"Protocol": "Protocol",
|
||||
"How chat requests are sent and how reasoning is surfaced": "How chat requests are sent and how reasoning is surfaced",
|
||||
"OpenAI-compatible (surfaces reasoning)": "OpenAI-compatible (surfaces reasoning)",
|
||||
"OpenAI (official)": "OpenAI (official)"
|
||||
"OpenAI (official)": "OpenAI (official)",
|
||||
"Custom address": "Custom address",
|
||||
"A short, memorable link you can point at any shared page.": "A short, memorable link you can point at any shared page.",
|
||||
"Use 2-60 lowercase letters, digits and hyphens": "Use 2-60 lowercase letters, digits and hyphens",
|
||||
"This address is already in use": "This address is already in use",
|
||||
"Move custom address?": "Move custom address?",
|
||||
"Move here": "Move here",
|
||||
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?",
|
||||
"The address \"{{alias}}\" is already in use. Move it to this page?": "The address \"{{alias}}\" is already in use. Move it to this page?",
|
||||
"Failed to set custom address": "Failed to set custom address",
|
||||
"Failed to remove custom address": "Failed to remove custom address"
|
||||
}
|
||||
|
||||
@@ -607,6 +607,17 @@
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Вы уверены, что хотите окончательно удалить '{{title}}'? Это действие невозможно отменить.",
|
||||
"Restore '{{title}}' and its sub-pages?": "Восстановить '{{title}}' и её подстраницы?",
|
||||
"Move to trash": "Переместить в корзину",
|
||||
"Make temporary": "Сделать временной",
|
||||
"Make permanent": "Сделать постоянной",
|
||||
"New temporary note": "Новая временная заметка",
|
||||
"Temporary note": "Временная заметка",
|
||||
"Temporary notes": "Временные заметки",
|
||||
"Temporary note — moves to trash unless made permanent": "Временная заметка — уедет в корзину, если не сделать постоянной",
|
||||
"Note will move to trash unless made permanent": "Заметка уедет в корзину, если не сделать её постоянной",
|
||||
"Note is now permanent": "Заметка теперь постоянная",
|
||||
"Temporary note lifetime (hours)": "Время жизни временной заметки (часы)",
|
||||
"A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.": "Временная заметка автоматически уезжает в корзину через указанное число часов, если не сделать её постоянной. Дедлайн фиксируется при создании заметки.",
|
||||
"This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.": "Эта временная заметка уедет в корзину {{time}} (вместе с подстраницами), если не сделать её постоянной.",
|
||||
"Move this page to trash?": "Переместить эту страницу в корзину?",
|
||||
"Restore page": "Восстановить страницу",
|
||||
"Permanently delete": "Удалить навсегда",
|
||||
@@ -704,13 +715,19 @@
|
||||
"Ask the AI agent…": "Спросите AI-агента…",
|
||||
"Copy chat": "Копировать чат",
|
||||
"Created successfully": "Успешно создано",
|
||||
"Current context size": "Текущий размер контекста",
|
||||
"Tokens generated this turn": "Токенов сгенерировано за ход",
|
||||
"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": "Успешно удалено",
|
||||
"Edited by AI agent on behalf of {{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": "Отправить",
|
||||
@@ -1169,5 +1186,15 @@
|
||||
"Protocol": "Протокол",
|
||||
"How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning",
|
||||
"OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)",
|
||||
"OpenAI (official)": "OpenAI (официальный)"
|
||||
"OpenAI (official)": "OpenAI (официальный)",
|
||||
"Custom address": "Пользовательский адрес",
|
||||
"A short, memorable link you can point at any shared page.": "Короткая запоминающаяся ссылка, которую можно направить на любую опубликованную страницу.",
|
||||
"Use 2-60 lowercase letters, digits and hyphens": "Используйте 2–60 строчных букв, цифр и дефисов",
|
||||
"This address is already in use": "Этот адрес уже занят",
|
||||
"Move custom address?": "Переместить пользовательский адрес?",
|
||||
"Move here": "Переместить сюда",
|
||||
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "Адрес «{{alias}}» сейчас указывает на «{{title}}». Переместить его на эту страницу?",
|
||||
"The address \"{{alias}}\" is already in use. Move it to this page?": "Адрес «{{alias}}» уже используется. Переместить его на эту страницу?",
|
||||
"Failed to set custom address": "Не удалось задать пользовательский адрес",
|
||||
"Failed to remove custom address": "Не удалось удалить пользовательский адрес"
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Badge, Tooltip } from "@mantine/core";
|
||||
import { IconGitMerge } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface GitSyncBadgeProps {
|
||||
authorName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge marking a version produced by git-sync (provenance §8.1). The history
|
||||
* version is created on the PUSH path — when an incoming git body is written back
|
||||
* into the Docmost doc — not by the pull itself. Like {@link AiAgentBadge} it is
|
||||
* ADDITIVE — shown next to the human author, never replacing them — but a git-sync
|
||||
* edit is NOT an agent edit and has no chat to deep-link into, so it is a small,
|
||||
* neutral, non-clickable label.
|
||||
*/
|
||||
export function GitSyncBadge({ authorName }: GitSyncBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const tooltip = t("Synced from Git on behalf of {{name}}", {
|
||||
name: authorName ?? "",
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip label={tooltip} withArrow>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="gray"
|
||||
radius="sm"
|
||||
leftSection={<IconGitMerge size={12} stroke={2} />}
|
||||
>
|
||||
{t("Git sync")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
shouldCollapseOnOutsidePointer,
|
||||
isHeaderClick,
|
||||
} from "@/features/ai-chat/utils/collapse-helpers.ts";
|
||||
import { selectContextBadge } from "@/features/ai-chat/utils/context-badge.ts";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import classes from "@/features/ai-chat/components/ai-chat-window.module.css";
|
||||
@@ -161,12 +162,6 @@ export default function AiChatWindow() {
|
||||
const { data: messageRows, isLoading: messagesLoading } =
|
||||
useAiChatMessagesQuery(activeChatId ?? undefined);
|
||||
|
||||
// Live turn-token total (reasoning + output) for the in-flight turn, pushed up
|
||||
// (THROTTLED to ~8 Hz inside ChatThread) so the header badge ticks mid-stream.
|
||||
// `null` means no turn is in flight -> the badge falls back to the persisted
|
||||
// context size below.
|
||||
const [liveTurnTokens, setLiveTurnTokens] = useState<number | null>(null);
|
||||
|
||||
// The page the user is currently viewing. AiChatWindow lives in a pathless
|
||||
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
||||
// pathname against the authenticated page route instead so "the current page"
|
||||
@@ -193,6 +188,7 @@ export default function AiChatWindow() {
|
||||
const {
|
||||
threadKey,
|
||||
waitingForHistory,
|
||||
startFreshThread,
|
||||
onTurnFinished,
|
||||
onServerChatId,
|
||||
cancelPendingAdoption,
|
||||
@@ -215,12 +211,25 @@ export default function AiChatWindow() {
|
||||
// just-failed chat after they chose a fresh one.
|
||||
const startNewChat = useCallback((): void => {
|
||||
cancelPendingAdoption();
|
||||
// Force a fresh, empty thread UNCONDITIONALLY (#161). Pressing "New chat"
|
||||
// while a brand-new chat's first turn is still streaming leaves activeChatId
|
||||
// null (the real id is adopted only at turn end), so setActiveChatId(null)
|
||||
// alone is a no-op and the reconciler never remounts — the chat/stream/history
|
||||
// would persist and only the role badge would drop. This always remounts the
|
||||
// thread into a clean new chat.
|
||||
startFreshThread();
|
||||
setActiveChatId(null);
|
||||
setHistoryOpen(false);
|
||||
setDraft("");
|
||||
// Default the picker back to "Universal assistant" for the fresh chat.
|
||||
setSelectedRoleId(null);
|
||||
}, [cancelPendingAdoption, setActiveChatId, setDraft, setSelectedRoleId]);
|
||||
}, [
|
||||
cancelPendingAdoption,
|
||||
startFreshThread,
|
||||
setActiveChatId,
|
||||
setDraft,
|
||||
setSelectedRoleId,
|
||||
]);
|
||||
|
||||
const selectChat = useCallback(
|
||||
(chatId: string): void => {
|
||||
@@ -287,24 +296,19 @@ export default function AiChatWindow() {
|
||||
// shipped; older rows fall back to that turn's `usage` total. NOTE: reflects
|
||||
// PERSISTED rows (updates on chat open/switch); it does not tick live
|
||||
// mid-stream — acceptable for v1.
|
||||
const contextTokens = useMemo(() => {
|
||||
if (!activeChatId || !messageRows) return 0;
|
||||
for (let i = messageRows.length - 1; i >= 0; i--) {
|
||||
const meta = messageRows[i].metadata;
|
||||
if (!meta) continue;
|
||||
if (typeof meta.contextTokens === "number" && meta.contextTokens > 0) {
|
||||
return meta.contextTokens;
|
||||
}
|
||||
const usage = meta.usage;
|
||||
if (usage) {
|
||||
const fallback =
|
||||
usage.totalTokens ??
|
||||
(usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
|
||||
if (fallback > 0) return fallback;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}, [activeChatId, messageRows]);
|
||||
//
|
||||
// The denominator `maxContextTokens` (the model's configured max window) is
|
||||
// derived in the SAME backward scan: it is stamped alongside `contextTokens`
|
||||
// on a completed turn, but the numerator and denominator are taken from the
|
||||
// most recent row carrying EACH value independently — they may land on
|
||||
// different rows (e.g. a fresh error row can carry contextTokens but not
|
||||
// maxContextTokens), so we keep scanning for whichever is still unset. 0 when
|
||||
// no row has it (older rows, or no admin-configured limit) — the badge then
|
||||
// shows just the current size with no denominator.
|
||||
const { contextTokens, maxContextTokens } = useMemo(
|
||||
() => selectContextBadge(activeChatId ? messageRows : undefined),
|
||||
[activeChatId, messageRows],
|
||||
);
|
||||
|
||||
// On (re)open, settle the geometry before paint (useLayoutEffect → no
|
||||
// first-frame jump): compute an initial top-right placement the first time,
|
||||
@@ -495,20 +499,17 @@ export default function AiChatWindow() {
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
|
||||
{/* While a turn streams, show the LIVE turn-token count (ticks ~8 Hz);
|
||||
once it finishes, fall back to the persisted context size. Require
|
||||
> 0 so the very first emit (an empty tail message, count 0) does not
|
||||
flash a "0" badge before any token streams in (#151 review). */}
|
||||
{liveTurnTokens !== null && liveTurnTokens > 0 ? (
|
||||
<Tooltip label={t("Tokens generated this turn")} withArrow>
|
||||
<span className={classes.badge}>
|
||||
{formatTokens(liveTurnTokens)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : contextTokens > 0 ? (
|
||||
<Tooltip label={t("Current context size")} withArrow>
|
||||
{/* Always show the persisted "current / max" context. The denominator
|
||||
(the admin-configured model limit) is appended only when known;
|
||||
not clamped when current > max (shown as-is, e.g. "210k / 200k").
|
||||
Hidden entirely until a turn has recorded a context figure. */}
|
||||
{contextTokens > 0 ? (
|
||||
<Tooltip label={t("Context size / model limit")} withArrow>
|
||||
<span className={classes.badge}>
|
||||
{formatTokens(contextTokens)}
|
||||
{maxContextTokens > 0
|
||||
? ` / ${formatTokens(maxContextTokens)}`
|
||||
: ""}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
@@ -622,6 +623,7 @@ export default function AiChatWindow() {
|
||||
) : (
|
||||
<ChatThread
|
||||
key={threadKey}
|
||||
threadKey={threadKey}
|
||||
chatId={activeChatId}
|
||||
initialRows={activeChatId ? messageRows : []}
|
||||
openPage={openPage}
|
||||
@@ -634,7 +636,6 @@ export default function AiChatWindow() {
|
||||
assistantName={currentRole?.name}
|
||||
onTurnFinished={onTurnFinished}
|
||||
onServerChatId={onServerChatId}
|
||||
onLiveTurnTokens={setLiveTurnTokens}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
} from "@/features/ai-chat/utils/role-launch.ts";
|
||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
|
||||
import { liveTurnTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||
import {
|
||||
dequeue,
|
||||
enqueueMessage,
|
||||
@@ -29,6 +28,14 @@ import {
|
||||
} from "@/features/ai-chat/utils/queue-helpers.ts";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
// Throttle how often the streamed `messages` state triggers a re-render. Without
|
||||
// it, useChat updates state on EVERY token, so the whole transcript's markdown
|
||||
// (marked + DOMPurify) is re-parsed per token — on a long agent run that grows
|
||||
// into a quadratic CPU storm that pins the main thread and freezes the UI.
|
||||
// ~50ms (20 Hz) keeps streaming visually smooth while decoupling re-render cost
|
||||
// from the token rate.
|
||||
const STREAM_THROTTLE_MS = 50;
|
||||
|
||||
/** The page the user is currently viewing, sent as chat context. */
|
||||
export interface OpenPageContext {
|
||||
id: string;
|
||||
@@ -38,6 +45,11 @@ export interface OpenPageContext {
|
||||
interface ChatThreadProps {
|
||||
/** The open chat id, or null for a brand-new (not-yet-created) chat. */
|
||||
chatId: string | null;
|
||||
/** This thread's mount key (the same value the parent uses as React `key`).
|
||||
* Forwarded to onTurnFinished so the session can tell a turn finishing on the
|
||||
* CURRENT thread from one ABANDONED by New chat mid-stream — whose onFinish/
|
||||
* onError still fire after unmount and must not adopt the abandoned chat (#161). */
|
||||
threadKey?: string;
|
||||
/** Persisted rows to seed initial messages (existing chats only). */
|
||||
initialRows?: IAiChatMessageRow[];
|
||||
/** The page currently open in the workspace, or null on a non-page route.
|
||||
@@ -59,20 +71,16 @@ interface ChatThreadProps {
|
||||
/** Called when a turn finishes; the parent refreshes the chat list and, for a
|
||||
* new chat, adopts the freshly created chat id. `serverChatId` is the
|
||||
* authoritative id the server streamed on the assistant message metadata, or
|
||||
* undefined on a failed turn — see adopt-chat-id.ts for the full #137 design. */
|
||||
onTurnFinished: (serverChatId?: string) => void;
|
||||
* undefined on a failed turn — see adopt-chat-id.ts for the full #137 design.
|
||||
* `finishingThreadKey` (this thread's mount key) lets the session ignore a turn
|
||||
* finishing on a thread already abandoned by New chat mid-stream (#161). */
|
||||
onTurnFinished: (serverChatId?: string, finishingThreadKey?: string) => void;
|
||||
/** Called EARLY (at the stream's `start` chunk) with the authoritative server
|
||||
* chat id streamed on the assistant message metadata, so a brand-new chat
|
||||
* adopts its real id WHILE the first turn is still streaming (#174 — makes the
|
||||
* Copy/export button available mid-stream). Distinct from onTurnFinished,
|
||||
* which fires only at the terminal outcome. */
|
||||
onServerChatId?: (serverChatId?: string) => void;
|
||||
/** Reports the live turn-token total (reasoning + output) for the in-flight
|
||||
* turn so the parent can show a header badge that ticks mid-stream. THROTTLED
|
||||
* here (~8 Hz) so the parent re-renders a handful of times a second, not on
|
||||
* every streamed delta. Called with `null` when no turn is in flight (the
|
||||
* parent then reverts the badge to the persisted context size). */
|
||||
onLiveTurnTokens?: (tokens: number | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,6 +117,7 @@ function rowToUiMessage(row: IAiChatMessageRow): UIMessage {
|
||||
*/
|
||||
export default function ChatThread({
|
||||
chatId,
|
||||
threadKey,
|
||||
initialRows,
|
||||
openPage,
|
||||
roleId,
|
||||
@@ -117,7 +126,6 @@ export default function ChatThread({
|
||||
assistantName,
|
||||
onTurnFinished,
|
||||
onServerChatId,
|
||||
onLiveTurnTokens,
|
||||
}: ChatThreadProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -246,6 +254,8 @@ export default function ChatThread({
|
||||
id: chatStoreId,
|
||||
messages: initialMessages,
|
||||
transport,
|
||||
// See STREAM_THROTTLE_MS — bounds re-render/markdown-reparse frequency.
|
||||
experimental_throttle: STREAM_THROTTLE_MS,
|
||||
// `onFinish` (ai@6 useChat) fires from a `finally` on EVERY terminal outcome
|
||||
// — success, user Stop/abort (`isAbort`), network drop (`isDisconnect`), and
|
||||
// stream error (`isError`). Keep calling `onTurnFinished()` on all of them
|
||||
@@ -257,8 +267,10 @@ export default function ChatThread({
|
||||
onFinish: ({ message, isAbort, isDisconnect, isError }) => {
|
||||
// Forward the authoritative server chatId (streamed on the assistant
|
||||
// message metadata) so the parent adopts the REAL created chat id for a new
|
||||
// chat — see adopt-chat-id.ts for the full #137 design.
|
||||
onTurnFinished(extractServerChatId(message));
|
||||
// chat — see adopt-chat-id.ts for the full #137 design. `threadKey` lets the
|
||||
// session ignore this finish if it belongs to a thread abandoned by New chat
|
||||
// mid-stream (#161).
|
||||
onTurnFinished(extractServerChatId(message), threadKey);
|
||||
// Show a neutral "stopped" marker for an aborted turn; the red error banner
|
||||
// (via `error`) already covers isError, and a clean finish clears any marker.
|
||||
if (isError) setStopNotice(null);
|
||||
@@ -279,7 +291,7 @@ export default function ChatThread({
|
||||
// Surface the raw failure in the browser console (devtools) for debugging;
|
||||
// the UI separately shows a friendly classified banner (see errorView).
|
||||
console.error("AI chat stream error:", streamError);
|
||||
onTurnFinished();
|
||||
onTurnFinished(undefined, threadKey);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -328,53 +340,6 @@ export default function ChatThread({
|
||||
// the SAME on-screen banner text can be mirrored into the export (issue #160).
|
||||
const errorView = error ? describeChatError(error.message ?? "", t) : null;
|
||||
|
||||
// Report the live turn-token total to the parent header badge, THROTTLED to
|
||||
// ~8 Hz so the parent re-renders a few times a second instead of on every
|
||||
// streamed delta. The tail assistant message's reasoning+output (estimate while
|
||||
// streaming, authoritative once a step reports usage) is the live figure. When
|
||||
// the turn ends we emit a final exact value, then `null` so the parent reverts
|
||||
// the badge to the persisted context size.
|
||||
const lastEmitRef = useRef(0);
|
||||
const emitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!onLiveTurnTokens) return;
|
||||
if (!isStreaming) {
|
||||
// Turn ended (or never started): clear any pending throttle and revert.
|
||||
if (emitTimerRef.current) {
|
||||
clearTimeout(emitTimerRef.current);
|
||||
emitTimerRef.current = null;
|
||||
}
|
||||
lastEmitRef.current = 0;
|
||||
onLiveTurnTokens(null);
|
||||
return;
|
||||
}
|
||||
const tail = messages[messages.length - 1];
|
||||
const live = tail?.role === "assistant" ? liveTurnTokens(tail) : null;
|
||||
const total = live ? live.reasoning + live.output : 0;
|
||||
const now = Date.now();
|
||||
const MIN_INTERVAL = 120; // ms (~8 Hz)
|
||||
const elapsed = now - lastEmitRef.current;
|
||||
if (elapsed >= MIN_INTERVAL) {
|
||||
lastEmitRef.current = now;
|
||||
onLiveTurnTokens(total);
|
||||
} else if (!emitTimerRef.current) {
|
||||
// Schedule a trailing emit so the FINAL value of a burst is not dropped.
|
||||
emitTimerRef.current = setTimeout(() => {
|
||||
emitTimerRef.current = null;
|
||||
lastEmitRef.current = Date.now();
|
||||
onLiveTurnTokens(total);
|
||||
}, MIN_INTERVAL - elapsed);
|
||||
}
|
||||
}, [messages, isStreaming, onLiveTurnTokens]);
|
||||
|
||||
// Clear any pending throttle timer on unmount (chat switch via `key`) so a
|
||||
// trailing emit can't fire into a torn-down thread's parent.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (emitTimerRef.current) clearTimeout(emitTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// A role was picked with autoStart=false: the role is bound but NOTHING was
|
||||
// sent, so chatId stays null and the empty state would keep showing the cards.
|
||||
// This flag hides the cards and reveals the composer (with the role indicated)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
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 stub in
|
||||
// reasoning-block.test.tsx.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
// Spy on `renderChatMarkdown` so we can count parse calls per text. We keep every
|
||||
// OTHER named export of markdown.ts intact via `importActual`, and override only
|
||||
// `renderChatMarkdown` with a `vi.fn()` that returns simple HTML so the component
|
||||
// still renders. This is the seam that proves the MarkdownPart memo works: a
|
||||
// finalized text part must NOT be re-parsed on a later streamed delta.
|
||||
// `vi.hoisted` so the spy exists when the hoisted `vi.mock` factory runs.
|
||||
const { renderChatMarkdownSpy } = vi.hoisted(() => ({
|
||||
renderChatMarkdownSpy: vi.fn((text: string) => `<p>${text}</p>`),
|
||||
}));
|
||||
vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/features/ai-chat/utils/markdown.ts")
|
||||
>("@/features/ai-chat/utils/markdown.ts");
|
||||
return { ...actual, renderChatMarkdown: renderChatMarkdownSpy };
|
||||
});
|
||||
|
||||
import MessageItem from "./message-item";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
||||
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
||||
|
||||
const renderRow = (message: UIMessage) =>
|
||||
render(
|
||||
<MantineProvider>
|
||||
<MessageItem message={message} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
/** Count how many spy calls parsed exactly `text` (filtering by the first arg). */
|
||||
const callsFor = (text: string) =>
|
||||
renderChatMarkdownSpy.mock.calls.filter((c) => c[0] === text).length;
|
||||
|
||||
describe("MessageItem markdown memoization", () => {
|
||||
it("does not re-parse finalized text parts when only a tail part grows", () => {
|
||||
renderChatMarkdownSpy.mockClear();
|
||||
|
||||
// Two finalized text parts.
|
||||
const first = msg([
|
||||
{ type: "text", text: "alpha" },
|
||||
{ type: "text", text: "beta" },
|
||||
]);
|
||||
const { rerender } = renderRow(first);
|
||||
|
||||
// Both finalized parts parsed exactly once on the initial render.
|
||||
expect(callsFor("alpha")).toBe(1);
|
||||
expect(callsFor("beta")).toBe(1);
|
||||
|
||||
// A streamed delta: a NEW message object where only a third tail part grows;
|
||||
// the first two parts' text is byte-identical.
|
||||
const next = msg([
|
||||
{ type: "text", text: "alpha" },
|
||||
{ type: "text", text: "beta" },
|
||||
{ type: "text", text: "gamm" },
|
||||
]);
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<MessageItem message={next} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
// The finalized parts hit the MarkdownPart memo: still parsed at most once
|
||||
// each across BOTH renders (the resilient invariant). The only new parse is
|
||||
// for the changed/added tail part.
|
||||
expect(callsFor("alpha")).toBe(1);
|
||||
expect(callsFor("beta")).toBe(1);
|
||||
expect(callsFor("gamm")).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
// Stub react-i18next: importing the component module pulls in `useTranslation`,
|
||||
// and we only exercise the pure `arePropsEqual` comparator (no rendering), so a
|
||||
// minimal `t` that echoes the key is enough. Mirrors the stub in
|
||||
// reasoning-block.test.tsx.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import { arePropsEqual } from "./message-item";
|
||||
|
||||
/**
|
||||
* Tests for `arePropsEqual`, the `React.memo` comparator for MessageItem. It must
|
||||
* return false on any visible prop/content change (so the row re-renders) and
|
||||
* true when nothing visible changed (so a finalized row is skipped). A FIXED
|
||||
* message id is used so a content-identical clone yields an equal signature.
|
||||
*/
|
||||
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
||||
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
||||
|
||||
const props = (
|
||||
message: UIMessage,
|
||||
over: Record<string, unknown> = {},
|
||||
) => ({
|
||||
message,
|
||||
showCitations: true,
|
||||
neutralizeInternalLinks: false,
|
||||
assistantName: "AI",
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("arePropsEqual", () => {
|
||||
it("returns false when showCitations differs", () => {
|
||||
const m = msg([{ type: "text", text: "answer" }]);
|
||||
expect(
|
||||
arePropsEqual(props(m), props(m, { showCitations: false })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when neutralizeInternalLinks differs", () => {
|
||||
const m = msg([{ type: "text", text: "answer" }]);
|
||||
expect(
|
||||
arePropsEqual(props(m), props(m, { neutralizeInternalLinks: true })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when assistantName differs", () => {
|
||||
const m = msg([{ type: "text", text: "answer" }]);
|
||||
expect(
|
||||
arePropsEqual(props(m), props(m, { assistantName: "Other" })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true on the identity fast path (same message object, equal props)", () => {
|
||||
const m = msg([{ type: "text", text: "answer" }]);
|
||||
expect(arePropsEqual(props(m), props(m))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for the same content in a different message object", () => {
|
||||
const a = msg([{ type: "text", text: "answer" }]);
|
||||
const b = msg([{ type: "text", text: "answer" }]);
|
||||
expect(a).not.toBe(b);
|
||||
expect(arePropsEqual(props(a), props(b))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when content changed in a different message object", () => {
|
||||
const a = msg([{ type: "text", text: "answer" }]);
|
||||
const b = msg([{ type: "text", text: "answer grown" }]);
|
||||
expect(arePropsEqual(props(a), props(b))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from "react";
|
||||
import { Box, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
@@ -10,6 +11,7 @@ import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/mess
|
||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||
import { resolveAssistantName } from "@/features/ai-chat/utils/assistant-name.ts";
|
||||
import { reasoningTokensForPart } from "@/features/ai-chat/utils/reasoning-tokens.ts";
|
||||
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
@@ -34,6 +36,39 @@ interface MessageItemProps {
|
||||
assistantName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
const MarkdownPart = memo(function MarkdownPart({
|
||||
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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Fallback when markdown could not render synchronously: raw text.
|
||||
return (
|
||||
<Text className={classes.markdown} style={{ whiteSpace: "pre-wrap" }}>
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Render a single UIMessage by iterating its `parts`:
|
||||
* - `text` parts -> sanitized markdown.
|
||||
@@ -41,12 +76,13 @@ interface MessageItemProps {
|
||||
* Other part kinds (reasoning, sources, files, step-start) are ignored for v1.
|
||||
* User messages render their text as a right-aligned plain bubble.
|
||||
*
|
||||
* This component is intentionally NOT memoized: `useChat` replaces the streaming
|
||||
* assistant message with a freshly cloned object on every streamed delta, so the
|
||||
* `message` prop identity (and its `parts`) changes each tick. Re-rendering the
|
||||
* text parts on each delta is what makes the answer stream in progressively.
|
||||
* This component is memoized (see `arePropsEqual` at the bottom) on a cheap
|
||||
* per-message content signature: the streaming TAIL message's signature changes
|
||||
* on each delta so it still re-renders and streams in, while finalized rows are
|
||||
* skipped. Each text part's markdown is itself memoized via `MarkdownPart`, so a
|
||||
* long turn no longer re-parses the whole transcript on every token.
|
||||
*/
|
||||
export default function MessageItem({
|
||||
function MessageItem({
|
||||
message,
|
||||
showCitations = true,
|
||||
neutralizeInternalLinks = false,
|
||||
@@ -109,24 +145,12 @@ export default function MessageItem({
|
||||
// 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;
|
||||
const html = renderChatMarkdown(part.text, {
|
||||
neutralizeInternalLinks,
|
||||
});
|
||||
if (html) {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={classes.markdown}
|
||||
// Sanitized by renderChatMarkdown (DOMPurify) before insertion.
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Fallback when markdown could not render synchronously: raw text.
|
||||
return (
|
||||
<Text key={index} className={classes.markdown} style={{ whiteSpace: "pre-wrap" }}>
|
||||
{part.text}
|
||||
</Text>
|
||||
<MarkdownPart
|
||||
key={index}
|
||||
text={part.text}
|
||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -177,3 +201,26 @@ export default function MessageItem({
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skip re-rendering a message whose visible content is unchanged. The streaming
|
||||
* TAIL message gets a fresh object whose signature changes each delta, so it
|
||||
* still re-renders and streams in; every FINALIZED message is skipped, turning a
|
||||
* per-token whole-transcript re-render into a tail-only one. */
|
||||
export function arePropsEqual(
|
||||
prev: MessageItemProps,
|
||||
next: MessageItemProps,
|
||||
): boolean {
|
||||
if (
|
||||
prev.showCitations !== next.showCitations ||
|
||||
prev.neutralizeInternalLinks !== next.neutralizeInternalLinks ||
|
||||
prev.assistantName !== next.assistantName
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Fast path: identical message object (finalized rows keep their identity
|
||||
// across deltas) — skip without building signatures.
|
||||
if (prev.message === next.message) return true;
|
||||
return messageSignature(prev.message) === messageSignature(next.message);
|
||||
}
|
||||
|
||||
export default memo(MessageItem, arePropsEqual);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import { Box, Collapse, Group, Text, UnstyledButton } from "@mantine/core";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -27,19 +27,23 @@ interface ReasoningBlockProps {
|
||||
* Providers that don't stream reasoning TEXT still render this block from the
|
||||
* authoritative count alone (header only, empty body) so the cost is visible.
|
||||
*/
|
||||
export default function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
|
||||
function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Authoritative count wins; otherwise estimate live from the streamed text.
|
||||
const count = tokens && tokens > 0 ? tokens : estimateTokens(text);
|
||||
const trimmed = text.trim();
|
||||
// Collapse the blank-line gaps the model emits between every list item /
|
||||
// paragraph so the reasoning renders compactly (tight lists, joined
|
||||
// paragraphs) — see collapseBlankLines. ONLY here, not in the normal answer.
|
||||
const html = trimmed
|
||||
? renderChatMarkdown(collapseBlankLines(trimmed), {})
|
||||
: "";
|
||||
// Memoize the markdown render so toggling `open` (or a parent re-render caused
|
||||
// by an unrelated streamed delta) does not re-parse the reasoning text; it
|
||||
// recomputes only when the reasoning text itself changes (while it streams in).
|
||||
// collapseBlankLines collapses the blank-line gaps the model emits between every
|
||||
// list item / paragraph so the reasoning renders compactly (tight lists, joined
|
||||
// paragraphs) — ONLY here, not in the normal answer.
|
||||
const html = useMemo(
|
||||
() => (trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : ""),
|
||||
[trimmed],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box className={classes.reasoningBlock} mb={6}>
|
||||
@@ -87,3 +91,8 @@ export default function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Memoized: re-renders only when `text`/`tokens` change (primitive props, default
|
||||
// shallow compare), so a parent re-render during streaming of OTHER content does
|
||||
// not re-run the markdown parse for an already-finalized reasoning block.
|
||||
export default memo(ReasoningBlock);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useChatSession } from "./use-chat-session";
|
||||
import type { UseChatSessionOptions } from "./use-chat-session";
|
||||
|
||||
@@ -227,6 +227,50 @@ describe("useChatSession", () => {
|
||||
expect(result.current.threadKey).toBe("C");
|
||||
});
|
||||
|
||||
it("#161: New chat during a streaming first turn forces a fresh thread (remount), not just a no-op", () => {
|
||||
// Brand-new chat whose first turn is still streaming: the id is adopted only
|
||||
// at turn end, so activeChatId AND thread.chatId are both null. Pressing "New
|
||||
// chat" must still remount to a clean thread even though the atom is unchanged
|
||||
// — the render-phase reconciler (null === null) would otherwise do nothing,
|
||||
// leaving the old chat/stream/history in place (the bug: only the role badge
|
||||
// dropped).
|
||||
const { result } = setup({ activeChatId: null, chats: { items: [] } });
|
||||
const keyBefore = result.current.threadKey;
|
||||
act(() => result.current.startFreshThread());
|
||||
expect(result.current.threadKey).not.toBe(keyBefore);
|
||||
});
|
||||
|
||||
it("#161: an abandoned thread's late onTurnFinished does NOT adopt its chat (thread-aware guard)", () => {
|
||||
// New chat mid-stream remounts to a fresh thread, but @ai-sdk/react does not
|
||||
// abort the abandoned stream on unmount: its onFinish still fires later with
|
||||
// the real server id, tagged with the OLD (abandoned) mount key. That must not
|
||||
// adopt — it would yank the user back into the chat they just left.
|
||||
const { result, setActiveChatId, onInvalidateChatList } = setup({
|
||||
activeChatId: null,
|
||||
chats: { items: [] },
|
||||
});
|
||||
const abandonedKey = result.current.threadKey;
|
||||
act(() => result.current.startFreshThread());
|
||||
expect(result.current.threadKey).not.toBe(abandonedKey);
|
||||
// The abandoned turn finishes in the background, streaming its real id "A".
|
||||
result.current.onTurnFinished("A", abandonedKey);
|
||||
expect(setActiveChatId).not.toHaveBeenCalledWith("A");
|
||||
// It still refreshes the chat list so the left-behind chat shows in history.
|
||||
expect(onInvalidateChatList).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("#161: a turn finishing on the CURRENT thread still adopts (guard is key-scoped, not blanket)", () => {
|
||||
// The happy path must keep working: onTurnFinished tagged with the mounted
|
||||
// thread's own key adopts in place as before.
|
||||
const { result, setActiveChatId } = setup({
|
||||
activeChatId: null,
|
||||
chats: { items: [] },
|
||||
});
|
||||
const currentKey = result.current.threadKey;
|
||||
result.current.onTurnFinished("A", currentKey);
|
||||
expect(setActiveChatId).toHaveBeenCalledWith("A");
|
||||
});
|
||||
|
||||
it("waitingForHistory gates the loader only while opening an unloaded existing chat", () => {
|
||||
// Open an existing chat whose history is still loading => loader on.
|
||||
const { result, rerender } = setup({
|
||||
|
||||
@@ -31,9 +31,19 @@ export interface UseChatSessionResult {
|
||||
threadKey: string;
|
||||
/** Show the history loader instead of the live thread. */
|
||||
waitingForHistory: boolean;
|
||||
/** Force a brand-new, empty thread (new mount key, no chat id) UNCONDITIONALLY,
|
||||
* even when `activeChatId` is unchanged. The window calls this from
|
||||
* startNewChat so "New chat" pressed WHILE a brand-new chat's first turn is
|
||||
* still streaming (activeChatId still null, nothing to diverge) actually
|
||||
* resets the chat instead of only dropping the role badge (#161). */
|
||||
startFreshThread: () => void;
|
||||
/** Call when a turn finishes; `serverChatId` is the authoritative streamed id
|
||||
* (undefined on a failed turn). Handles new-chat id adoption + invalidations. */
|
||||
onTurnFinished: (serverChatId?: string) => void;
|
||||
* (undefined on a failed turn). `finishingThreadKey` is the mount key of the
|
||||
* thread that produced the turn (omit => "current thread", back-compatible):
|
||||
* a turn ABANDONED by New chat mid-stream still fires this after its thread
|
||||
* unmounted, so adoption is gated to the still-mounted thread (#161). Handles
|
||||
* new-chat id adoption + invalidations. */
|
||||
onTurnFinished: (serverChatId?: string, finishingThreadKey?: string) => void;
|
||||
/** Call EARLY (at the stream's `start` chunk) with the authoritative streamed
|
||||
* chat id so a brand-new chat adopts its real id WHILE its first turn is still
|
||||
* streaming — making `activeChatId`-gated affordances (e.g. the Copy/export
|
||||
@@ -98,6 +108,15 @@ export function useChatSession(
|
||||
: switchThread(activeChatId),
|
||||
);
|
||||
|
||||
// Live mirror of the mounted thread's mount key, read by onTurnFinished to tell
|
||||
// the CURRENT thread from one ABANDONED by New chat mid-stream. @ai-sdk/react
|
||||
// does not abort a stream on unmount and proxies callbacks through a ref, so an
|
||||
// abandoned turn's onFinish/onError still fires AFTER its ChatThread unmounted;
|
||||
// matching its key against this ref keeps that late finish from adopting the
|
||||
// abandoned chat and yanking the user out of the fresh chat they opened (#161).
|
||||
const threadKeyRef = useRef(thread.key);
|
||||
threadKeyRef.current = thread.key;
|
||||
|
||||
// Error-path fallback for new-chat id adoption. When a brand-new chat's first
|
||||
// turn errors BEFORE the server's `start` chunk, no authoritative chatId ever
|
||||
// reaches the client, so the primary metadata adoption cannot run. We then ARM
|
||||
@@ -115,7 +134,23 @@ export function useChatSession(
|
||||
// yet) we adopt the server's AUTHORITATIVE streamed id (never the newest in the
|
||||
// list, which races a second tab — #137; see adopt-chat-id.ts).
|
||||
const onTurnFinished = useCallback(
|
||||
(serverChatId?: string) => {
|
||||
(serverChatId?: string, finishingThreadKey?: string) => {
|
||||
// Thread-aware guard (#161). A turn ABANDONED by "New chat" mid-stream still
|
||||
// fires onFinish/onError after its ChatThread unmounted (@ai-sdk/react does
|
||||
// not abort on unmount and proxies callbacks through a ref). If that late
|
||||
// finish ran the adoption path it would set activeChatId to the abandoned
|
||||
// chat's real id and yank the user out of the fresh chat they just opened.
|
||||
// So adopt / arm the fallback ONLY for the still-mounted thread; an
|
||||
// abandoned one merely refreshes the chat list (so the left-behind chat
|
||||
// surfaces in history) and does nothing else. A missing key (undefined)
|
||||
// means "current thread" — keeps old call sites/tests working.
|
||||
if (
|
||||
finishingThreadKey !== undefined &&
|
||||
finishingThreadKey !== threadKeyRef.current
|
||||
) {
|
||||
onInvalidateChatList();
|
||||
return;
|
||||
}
|
||||
// Read the live id from the ref, not the closure: on a failed turn this can
|
||||
// run twice in one turn (onFinish + onError) before any re-render, and the
|
||||
// primary branch below updates the ref so the second call sees the adopted id.
|
||||
@@ -258,9 +293,28 @@ export function useChatSession(
|
||||
pendingNewChatRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Force a fresh, empty thread regardless of `activeChatId` (#161). The render-
|
||||
// phase reconciler only remounts when activeChatId diverges from thread.chatId,
|
||||
// so "New chat" pressed while a brand-new chat's first turn is still streaming
|
||||
// (activeChatId AND thread.chatId both null — the real id is adopted only at the
|
||||
// end of the turn) is a no-op for it and the abandoned thread/stream/history
|
||||
// would persist. Dispatching reconcile with a fresh key and chatId:null here
|
||||
// always produces a new mount key, so React remounts ChatThread (a clean useChat
|
||||
// store) and the post-dispatch state (activeChatId null === thread.chatId null)
|
||||
// keeps the reconciler from interfering. Also disarms any pending fallback.
|
||||
const startFreshThread = useCallback(() => {
|
||||
pendingNewChatRef.current = null;
|
||||
dispatch({
|
||||
type: "reconcile",
|
||||
chatId: null,
|
||||
newKey: `new-${generateId()}`,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
threadKey: thread.key,
|
||||
waitingForHistory,
|
||||
startFreshThread,
|
||||
onTurnFinished,
|
||||
onServerChatId,
|
||||
cancelPendingAdoption,
|
||||
|
||||
@@ -116,6 +116,9 @@ export interface IAiChatMessageRow {
|
||||
// turn. Distinct from `usage` (legacy cumulative totalUsage). Shown in the
|
||||
// floating window's header badge.
|
||||
contextTokens?: number;
|
||||
// The model's max context window (denominator for the header badge); set
|
||||
// alongside contextTokens on a completed turn; absent on older rows.
|
||||
maxContextTokens?: number;
|
||||
// Set on an assistant row whose turn ended in a provider/stream error; the
|
||||
// raw provider error text (e.g. "402: ...") for inline display in the thread.
|
||||
error?: string;
|
||||
|
||||
90
apps/client/src/features/ai-chat/utils/context-badge.test.ts
Normal file
90
apps/client/src/features/ai-chat/utils/context-badge.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import { selectContextBadge } from "@/features/ai-chat/utils/context-badge.ts";
|
||||
|
||||
/**
|
||||
* Pure-helper tests for the header context badge selection. Covers the two
|
||||
* non-obvious rules: numerator and denominator are each taken from the most
|
||||
* recent row carrying THAT value (they may live on different rows), and a fresh
|
||||
* row with a zero/absent value must NOT shadow an older positive one.
|
||||
*/
|
||||
const row = (metadata: IAiChatMessageRow["metadata"]): IAiChatMessageRow => ({
|
||||
id: Math.random().toString(),
|
||||
role: "assistant",
|
||||
content: null,
|
||||
metadata,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
describe("selectContextBadge", () => {
|
||||
it("returns zeros for empty / nullish input", () => {
|
||||
expect(selectContextBadge(undefined)).toEqual({
|
||||
contextTokens: 0,
|
||||
maxContextTokens: 0,
|
||||
});
|
||||
expect(selectContextBadge(null)).toEqual({
|
||||
contextTokens: 0,
|
||||
maxContextTokens: 0,
|
||||
});
|
||||
expect(selectContextBadge([])).toEqual({
|
||||
contextTokens: 0,
|
||||
maxContextTokens: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads both figures from the most recent row that carries them", () => {
|
||||
expect(
|
||||
selectContextBadge([
|
||||
row({ contextTokens: 100, maxContextTokens: 200000 }),
|
||||
row({ contextTokens: 1500, maxContextTokens: 200000 }),
|
||||
]),
|
||||
).toEqual({ contextTokens: 1500, maxContextTokens: 200000 });
|
||||
});
|
||||
|
||||
it("falls back to legacy usage total for older rows without contextTokens", () => {
|
||||
expect(
|
||||
selectContextBadge([
|
||||
row({ usage: { inputTokens: 30, outputTokens: 70 } }),
|
||||
]),
|
||||
).toEqual({ contextTokens: 100, maxContextTokens: 0 });
|
||||
|
||||
expect(
|
||||
selectContextBadge([row({ usage: { totalTokens: 250 } })]),
|
||||
).toEqual({ contextTokens: 250, maxContextTokens: 0 });
|
||||
});
|
||||
|
||||
it("takes numerator and denominator from different rows", () => {
|
||||
// Freshest row (an error turn) carries contextTokens but no max; the older
|
||||
// completed turn carries the max. Each is picked from its own latest row.
|
||||
expect(
|
||||
selectContextBadge([
|
||||
row({ contextTokens: 800, maxContextTokens: 200000 }),
|
||||
row({ contextTokens: 1200, error: "402: nope" }),
|
||||
]),
|
||||
).toEqual({ contextTokens: 1200, maxContextTokens: 200000 });
|
||||
});
|
||||
|
||||
it("does not let a fresh zero/absent max shadow an older positive max", () => {
|
||||
expect(
|
||||
selectContextBadge([
|
||||
row({ contextTokens: 100, maxContextTokens: 200000 }),
|
||||
row({ contextTokens: 1200, maxContextTokens: 0 }),
|
||||
]),
|
||||
).toEqual({ contextTokens: 1200, maxContextTokens: 200000 });
|
||||
});
|
||||
|
||||
it("skips rows with null metadata", () => {
|
||||
expect(
|
||||
selectContextBadge([
|
||||
row({ contextTokens: 500, maxContextTokens: 200000 }),
|
||||
row(null),
|
||||
]),
|
||||
).toEqual({ contextTokens: 500, maxContextTokens: 200000 });
|
||||
});
|
||||
|
||||
it("reports current > max as-is (no clamp)", () => {
|
||||
expect(
|
||||
selectContextBadge([row({ contextTokens: 250000, maxContextTokens: 200000 })]),
|
||||
).toEqual({ contextTokens: 250000, maxContextTokens: 200000 });
|
||||
});
|
||||
});
|
||||
49
apps/client/src/features/ai-chat/utils/context-badge.ts
Normal file
49
apps/client/src/features/ai-chat/utils/context-badge.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
/**
|
||||
* Derive the header context badge figures from the persisted message rows.
|
||||
*
|
||||
* - `contextTokens` (numerator): how much the conversation now occupies in the
|
||||
* model's context window. Read from the most recent row carrying a context
|
||||
* figure — `contextTokens` (final-step input+output) on rows recorded after
|
||||
* this shipped, else that turn's legacy `usage` total for older rows.
|
||||
* - `maxContextTokens` (denominator): the model's configured max window, stamped
|
||||
* alongside `contextTokens` on a completed turn.
|
||||
*
|
||||
* Each value is taken from the most recent row carrying THAT value
|
||||
* independently — they may land on different rows (e.g. a fresh error row can
|
||||
* carry `contextTokens` but not `maxContextTokens`), so the scan continues for
|
||||
* whichever is still unset. `0` means "no row has it" (older rows, or no
|
||||
* admin-configured limit); the badge then omits the value.
|
||||
*/
|
||||
export function selectContextBadge(
|
||||
messageRows: readonly IAiChatMessageRow[] | undefined | null,
|
||||
): { contextTokens: number; maxContextTokens: number } {
|
||||
let contextTokens = 0;
|
||||
let maxContextTokens = 0;
|
||||
if (!messageRows) return { contextTokens, maxContextTokens };
|
||||
for (let i = messageRows.length - 1; i >= 0; i--) {
|
||||
const meta = messageRows[i].metadata;
|
||||
if (!meta) continue;
|
||||
if (contextTokens === 0) {
|
||||
if (typeof meta.contextTokens === "number" && meta.contextTokens > 0) {
|
||||
contextTokens = meta.contextTokens;
|
||||
} else if (meta.usage) {
|
||||
const usage = meta.usage;
|
||||
const fallback =
|
||||
usage.totalTokens ??
|
||||
(usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
|
||||
if (fallback > 0) contextTokens = fallback;
|
||||
}
|
||||
}
|
||||
if (
|
||||
maxContextTokens === 0 &&
|
||||
typeof meta.maxContextTokens === "number" &&
|
||||
meta.maxContextTokens > 0
|
||||
) {
|
||||
maxContextTokens = meta.maxContextTokens;
|
||||
}
|
||||
if (contextTokens !== 0 && maxContextTokens !== 0) break;
|
||||
}
|
||||
return { contextTokens, maxContextTokens };
|
||||
}
|
||||
@@ -1,17 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import {
|
||||
estimateTokens,
|
||||
liveTurnTokens,
|
||||
} from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||
|
||||
const msg = (parts: unknown[], metadata?: unknown): UIMessage =>
|
||||
({
|
||||
id: Math.random().toString(),
|
||||
role: "assistant",
|
||||
parts,
|
||||
metadata,
|
||||
}) as UIMessage;
|
||||
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||
|
||||
describe("estimateTokens", () => {
|
||||
it("returns 0 for the empty string", () => {
|
||||
@@ -25,147 +13,3 @@ describe("estimateTokens", () => {
|
||||
expect(estimateTokens("12345678")).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("liveTurnTokens — estimate path", () => {
|
||||
it("is all zeros for an undefined message", () => {
|
||||
expect(liveTurnTokens(undefined)).toEqual({
|
||||
reasoning: 0,
|
||||
output: 0,
|
||||
authoritative: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("is all zeros for a parts-less message", () => {
|
||||
expect(liveTurnTokens({ id: "x", role: "assistant" } as UIMessage)).toEqual({
|
||||
reasoning: 0,
|
||||
output: 0,
|
||||
authoritative: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("estimates output from text parts", () => {
|
||||
// 8 chars -> 2 tokens.
|
||||
const r = liveTurnTokens(msg([{ type: "text", text: "12345678" }]));
|
||||
expect(r).toEqual({ reasoning: 0, output: 2, authoritative: false });
|
||||
});
|
||||
|
||||
it("estimates reasoning from reasoning parts (kept separate from output)", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([
|
||||
{ type: "reasoning", text: "12345678" },
|
||||
{ type: "text", text: "abcd" },
|
||||
]),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 2, output: 1, authoritative: false });
|
||||
});
|
||||
|
||||
it("accumulates across multiple text + reasoning parts (multi-step)", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([
|
||||
{ type: "reasoning", text: "abcd" }, // 1
|
||||
{ type: "text", text: "abcd" }, // 1
|
||||
{ type: "tool-getPage", state: "output-available" }, // ignored
|
||||
{ type: "reasoning", text: "abcd" }, // 1
|
||||
{ type: "text", text: "abcdefgh" }, // 2
|
||||
]),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 2, output: 3, authoritative: false });
|
||||
});
|
||||
|
||||
it("ignores non text/reasoning parts (tools, step-start)", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([
|
||||
{ type: "step-start" },
|
||||
{ type: "tool-getPage", state: "input-available" },
|
||||
]),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 0, output: 0, authoritative: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("liveTurnTokens — authoritative path", () => {
|
||||
it("returns authoritative usage verbatim, splitting reasoning out of output", () => {
|
||||
// outputTokens INCLUDES reasoning in the AI SDK shape -> answer = 100 - 30.
|
||||
const r = liveTurnTokens(
|
||||
msg([{ type: "text", text: "estimate would be tiny" }], {
|
||||
usage: { inputTokens: 500, outputTokens: 100, reasoningTokens: 30 },
|
||||
}),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 30, output: 70, authoritative: true });
|
||||
});
|
||||
|
||||
it("treats missing reasoningTokens as 0 and keeps full output", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([{ type: "text", text: "x" }], {
|
||||
usage: { inputTokens: 10, outputTokens: 42 },
|
||||
}),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 0, output: 42, authoritative: true });
|
||||
});
|
||||
|
||||
it("never returns a negative output when reasoning exceeds reported output", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([], { usage: { outputTokens: 10, reasoningTokens: 40 } }),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 40, output: 0, authoritative: true });
|
||||
});
|
||||
|
||||
it("falls back to the estimate when metadata has no usage object", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([{ type: "text", text: "abcd" }], { chatId: "c1" }),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 0, output: 1, authoritative: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("liveTurnTokens — combined authoritative + estimate (#163)", () => {
|
||||
it("ticks the in-flight step above the completed-steps authoritative base", () => {
|
||||
// The authoritative usage is the sum over COMPLETED steps (step 1). The
|
||||
// CURRENT step is streaming and its text is NOT in `usage` yet, but it IS in
|
||||
// the parts -> the running estimate must push the live figure above the base
|
||||
// so the badge keeps growing between step boundaries.
|
||||
const longText = "x".repeat(800); // 800 chars -> 200 est output tokens
|
||||
const r = liveTurnTokens(
|
||||
msg([{ type: "text", text: longText }], {
|
||||
usage: { inputTokens: 500, outputTokens: 40 }, // step-1 base: 40 output
|
||||
}),
|
||||
);
|
||||
// max(authOutput=40, estOutput=200) = 200 -> the counter ticks, not frozen.
|
||||
expect(r.output).toBe(200);
|
||||
expect(r.authoritative).toBe(true);
|
||||
});
|
||||
|
||||
it("ticks reasoning of the in-flight step above the authoritative reasoning base", () => {
|
||||
const longReasoning = "r".repeat(400); // 400 chars -> 100 est reasoning
|
||||
const r = liveTurnTokens(
|
||||
msg([{ type: "reasoning", text: longReasoning }], {
|
||||
usage: { inputTokens: 100, outputTokens: 20, reasoningTokens: 20 },
|
||||
}),
|
||||
);
|
||||
// reasoning: max(20, 100) = 100 ; output: max(max(0,20-20)=0, 0) = 0.
|
||||
expect(r.reasoning).toBe(100);
|
||||
expect(r.output).toBe(0);
|
||||
expect(r.authoritative).toBe(true);
|
||||
});
|
||||
|
||||
it("snaps to the authoritative figure once it exceeds the rough estimate", () => {
|
||||
// Short on-screen text (estimate tiny) but a large authoritative output:
|
||||
// the exact figure wins at the boundary (the counter never under-reports).
|
||||
const r = liveTurnTokens(
|
||||
msg([{ type: "text", text: "abcd" }], {
|
||||
usage: { inputTokens: 10, outputTokens: 5000 },
|
||||
}),
|
||||
);
|
||||
expect(r.output).toBe(5000);
|
||||
});
|
||||
|
||||
it("is monotonic: max never drops below the authoritative base when the estimate is smaller", () => {
|
||||
// Mirrors the legacy 'verbatim' tests: estimate < authoritative -> unchanged.
|
||||
const r = liveTurnTokens(
|
||||
msg([{ type: "text", text: "tiny" }], {
|
||||
usage: { inputTokens: 500, outputTokens: 100, reasoningTokens: 30 },
|
||||
}),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 30, output: 70, authoritative: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
/**
|
||||
* Live token counting for a streaming AI-chat turn — split into REASONING
|
||||
* (thinking) and OUTPUT (answer) tokens, mirroring how Claude Code shows
|
||||
* `Thinking… · 60 tokens` next to its thinking indicator.
|
||||
* Rough client-side token estimation for AI-chat UI affordances.
|
||||
*
|
||||
* No provider streams exact per-token usage mid-stream, so the live number is a
|
||||
* CLIENT ESTIMATE (chars/≈4 heuristic) that is reconciled to AUTHORITATIVE usage
|
||||
* once the server attaches it on a step/turn boundary (see the server's
|
||||
* `chatStreamMetadata` + the client's read of `message.metadata.usage`). When
|
||||
* authoritative usage is present we return it verbatim (the number "jumps to
|
||||
* exact"); otherwise we return the running estimate. 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).
|
||||
* 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").
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -24,90 +17,3 @@ export function estimateTokens(text: string): number {
|
||||
if (!text) return 0;
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
/** Authoritative per-step/turn usage the server attaches to message metadata. */
|
||||
export interface AuthoritativeUsage {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
}
|
||||
|
||||
/** Live token split for a turn's tail (streaming) assistant message. */
|
||||
export interface LiveTurnTokens {
|
||||
/** Thinking/reasoning tokens (estimate, or authoritative when available). */
|
||||
reasoning: number;
|
||||
/** Answer/output tokens (estimate, or authoritative when available). */
|
||||
output: number;
|
||||
/** True when the numbers come from authoritative server usage, not estimate. */
|
||||
authoritative: boolean;
|
||||
}
|
||||
|
||||
/** Read the authoritative usage off a UIMessage's metadata, if the server set it. */
|
||||
function metadataUsage(message: UIMessage): AuthoritativeUsage | undefined {
|
||||
const meta = message?.metadata as
|
||||
| { usage?: AuthoritativeUsage }
|
||||
| undefined;
|
||||
const usage = meta?.usage;
|
||||
if (!usage || typeof usage !== "object") return undefined;
|
||||
return usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token split for the given (streaming) assistant message.
|
||||
*
|
||||
* COMBINES the authoritative server usage with the running text estimate so the
|
||||
* counter ticks in real time AND lands exact. The server only attaches
|
||||
* `metadata.usage` at a step/turn boundary (`finish-step`/`finish`) and it is
|
||||
* CUMULATIVE over COMPLETED steps — it does NOT yet include the in-flight step.
|
||||
* So a multi-step turn that returned the authoritative figure verbatim would
|
||||
* FREEZE between boundaries and jump in steps (issue #163).
|
||||
*
|
||||
* Instead we always compute the running ESTIMATE (chars/≈4 over the message's
|
||||
* `reasoning`/`text` parts, which grows on every streamed delta) and take the
|
||||
* per-component MAX of the authoritative base and the estimate:
|
||||
* - between boundaries the estimate of the in-flight step ticks the number up;
|
||||
* - at a boundary the authoritative figure snaps it to exact;
|
||||
* - because the server's usage is cumulative and we only ever take the max, the
|
||||
* number is MONOTONIC — it never drops.
|
||||
*
|
||||
* Providers that don't stream reasoning text still surface a reasoning count once
|
||||
* the authoritative usage arrives (`max(reasoningTokens, 0)`); on the pure
|
||||
* estimate path (no usage yet) such a turn shows `reasoning: 0` until then.
|
||||
*/
|
||||
export function liveTurnTokens(message: UIMessage | undefined): LiveTurnTokens {
|
||||
if (!message) return { reasoning: 0, output: 0, authoritative: false };
|
||||
|
||||
// Running ESTIMATE over every reasoning/text part — grows on each delta. This
|
||||
// includes the IN-FLIGHT step, which the authoritative usage does not cover yet.
|
||||
let estReasoning = 0;
|
||||
let estOutput = 0;
|
||||
for (const part of message.parts ?? []) {
|
||||
if (part.type === "reasoning") {
|
||||
estReasoning += estimateTokens((part as { text?: string }).text ?? "");
|
||||
} else if (part.type === "text") {
|
||||
estOutput += estimateTokens((part as { text?: string }).text ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
const usage = metadataUsage(message);
|
||||
if (!usage) {
|
||||
// No authoritative usage streamed yet: the estimate IS the live figure.
|
||||
return { reasoning: estReasoning, output: estOutput, authoritative: false };
|
||||
}
|
||||
|
||||
// Authoritative sum over COMPLETED steps. `outputTokens` already INCLUDES
|
||||
// reasoning in the AI SDK usage shape, so subtract it out for the "answer"
|
||||
// figure (never go negative if a provider reports them inconsistently).
|
||||
const authReasoning = usage.reasoningTokens ?? 0;
|
||||
const authOutput = Math.max(0, (usage.outputTokens ?? 0) - authReasoning);
|
||||
|
||||
// Per-component max: the in-flight step's estimate ticks above the completed-
|
||||
// steps base between boundaries, and the authoritative figure wins once it
|
||||
// exceeds the (rough) estimate at the next boundary. Monotonic by construction.
|
||||
return {
|
||||
reasoning: Math.max(authReasoning, estReasoning),
|
||||
output: Math.max(authOutput, estOutput),
|
||||
authoritative: true,
|
||||
};
|
||||
}
|
||||
|
||||
241
apps/client/src/features/ai-chat/utils/message-signature.test.ts
Normal file
241
apps/client/src/features/ai-chat/utils/message-signature.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||
|
||||
/**
|
||||
* Pure-helper tests for `messageSignature`, the cheap per-message content
|
||||
* signature that drives MessageItem's memo (a streaming row's signature must
|
||||
* change on every delta so it re-renders, while a finalized row's stays stable
|
||||
* so it is skipped). Each test exercises ONE change signal and asserts it flips
|
||||
* the signature; a content-identical clone must keep an EQUAL signature.
|
||||
*
|
||||
* The signature embeds `message.id` and `message.role`, so the `msg` factory
|
||||
* uses a FIXED id/role here (not `Math.random()`): otherwise two messages with
|
||||
* identical content would get different signatures and the negative case would
|
||||
* be impossible to express.
|
||||
*/
|
||||
const msg = (
|
||||
parts: UIMessage["parts"],
|
||||
metadata?: unknown,
|
||||
): UIMessage =>
|
||||
({
|
||||
id: "m1",
|
||||
role: "assistant",
|
||||
parts,
|
||||
metadata,
|
||||
}) as UIMessage;
|
||||
|
||||
describe("messageSignature", () => {
|
||||
it("changes when a text part grows", () => {
|
||||
const before = msg([{ type: "text", text: "alpha" }]);
|
||||
const after = msg([{ type: "text", text: "alpha beta" }]);
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
|
||||
it("changes when a new part is appended", () => {
|
||||
const before = msg([{ type: "text", text: "alpha" }]);
|
||||
const after = msg([
|
||||
{ type: "text", text: "alpha" },
|
||||
{ type: "text", text: "beta" },
|
||||
]);
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
|
||||
it("changes when a part's state flips", () => {
|
||||
const before = msg([
|
||||
{ type: "tool-getPage", state: "input-streaming" } as never,
|
||||
]);
|
||||
const after = msg([
|
||||
{ type: "tool-getPage", state: "output-available" } as never,
|
||||
]);
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
|
||||
it("changes when a tool part gains an output", () => {
|
||||
const before = msg([
|
||||
{ type: "tool-getPage", state: "output-available" } as never,
|
||||
]);
|
||||
const after = msg([
|
||||
{
|
||||
type: "tool-getPage",
|
||||
state: "output-available",
|
||||
output: { ok: true },
|
||||
} as never,
|
||||
]);
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
|
||||
it("changes when a part gains an errorText", () => {
|
||||
const before = msg([
|
||||
{ type: "tool-getPage", state: "output-error" } as never,
|
||||
]);
|
||||
const after = msg([
|
||||
{
|
||||
type: "tool-getPage",
|
||||
state: "output-error",
|
||||
errorText: "boom",
|
||||
} as never,
|
||||
]);
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
|
||||
it("changes when usage.reasoningTokens arrives on finish-step (text/state already frozen)", () => {
|
||||
// The specifically-commented edge case: the authoritative turn total lands on
|
||||
// the final finish-step AFTER the reasoning text length and state are frozen.
|
||||
// Only the token count appears between these two snapshots, so the signature
|
||||
// MUST still flip — otherwise the "Thinking · N tokens" header would never
|
||||
// snap from the live estimate to the exact figure.
|
||||
const before = msg([
|
||||
{ type: "reasoning", text: "thinking", state: "done" } as never,
|
||||
]);
|
||||
const after = msg(
|
||||
[{ type: "reasoning", text: "thinking", state: "done" } as never],
|
||||
{ usage: { reasoningTokens: 42 } },
|
||||
);
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
|
||||
it("changes when metadata.error appears", () => {
|
||||
const before = msg([{ type: "text", text: "answer" }]);
|
||||
const after = msg([{ type: "text", text: "answer" }], { error: "boom" });
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
|
||||
it("changes when metadata.finishReason changes (e.g. to 'aborted')", () => {
|
||||
const before = msg([{ type: "text", text: "answer" }], {
|
||||
finishReason: "stop",
|
||||
});
|
||||
const after = msg([{ type: "text", text: "answer" }], {
|
||||
finishReason: "aborted",
|
||||
});
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
|
||||
it("is UNCHANGED for a content-identical clone (different object, same values)", () => {
|
||||
// A finalized row that is re-created as a fresh object (different parts array
|
||||
// by reference, same parts by value) must keep an EQUAL signature, so the
|
||||
// memo skips re-rendering it.
|
||||
const a = msg([
|
||||
{ type: "text", text: "alpha" },
|
||||
{ type: "tool-getPage", state: "output-available", output: { ok: true } } as never,
|
||||
]);
|
||||
const b = msg([
|
||||
{ type: "text", text: "alpha" },
|
||||
{ type: "tool-getPage", state: "output-available", output: { ok: true } } as never,
|
||||
]);
|
||||
expect(a).not.toBe(b);
|
||||
expect(messageSignature(a)).toBe(messageSignature(b));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-part-kind coupling guard for the load-bearing invariant documented at the
|
||||
* top of message-signature.ts: the signature MUST sample every VISIBLE field the
|
||||
* MessageItem render body draws, or the memo freezes a stale row. This is an
|
||||
* executable lock for the part kinds rendered TODAY — read alongside
|
||||
* `MessageItem` (message-item.tsx) and the `assistantMessageHasVisibleContent`
|
||||
* helper (message-content.ts), which "mirrors MessageItem's render decisions
|
||||
* EXACTLY". For each kind, mutating a field the render body DRAWS must flip the
|
||||
* signature. If a new visible field is rendered without being added here AND to
|
||||
* the signature, the corresponding assertion below should fail — that is the
|
||||
* guard. (This intentionally stops short of the render-descriptor refactor:
|
||||
* adding a part kind or a visible field still requires a human to extend both
|
||||
* the signature and this block.)
|
||||
*/
|
||||
describe("messageSignature ↔ render coupling (per visible part kind)", () => {
|
||||
describe("text part — render draws part.text (MarkdownPart text={part.text})", () => {
|
||||
it("flips when the visible text changes", () => {
|
||||
// Streaming is append-only, so the visible text only grows; the signature
|
||||
// samples its length, so the growth is the change signal.
|
||||
const before = msg([{ type: "text", text: "answer" }]);
|
||||
const after = msg([{ type: "text", text: "answer extended" }]);
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
});
|
||||
|
||||
describe("reasoning part — render draws text + tokens (ReasoningBlock)", () => {
|
||||
it("flips when the visible reasoning text changes", () => {
|
||||
const before = msg([
|
||||
{ type: "reasoning", text: "think", state: "streaming" } as never,
|
||||
]);
|
||||
const after = msg([
|
||||
{ type: "reasoning", text: "think harder", state: "streaming" } as never,
|
||||
]);
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
|
||||
it("flips when the visible token count (metadata.usage.reasoningTokens) lands", () => {
|
||||
// The header's "Thinking · N tokens" reads reasoningTokensForPart, fed by
|
||||
// metadata.usage.reasoningTokens — a VISIBLE field that arrives on the final
|
||||
// finish-step after text length and state are frozen.
|
||||
const before = msg([
|
||||
{ type: "reasoning", text: "think", state: "done" } as never,
|
||||
]);
|
||||
const after = msg(
|
||||
[{ type: "reasoning", text: "think", state: "done" } as never],
|
||||
{ usage: { reasoningTokens: 99 } },
|
||||
);
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool-* part — render draws state/errorText/citations (ToolCallCard)", () => {
|
||||
it("flips when the run state changes (running ↔ done icon + label)", () => {
|
||||
// toolRunState(part.state) selects the spinner/check/error icon.
|
||||
const before = msg([
|
||||
{ type: "tool-getPage", state: "input-available" } as never,
|
||||
]);
|
||||
const after = msg([
|
||||
{ type: "tool-getPage", state: "output-available" } as never,
|
||||
]);
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
|
||||
it("flips when output arrives (drives the rendered citation links)", () => {
|
||||
// toolCitations reads part.output to render the "/p/{id}" anchors.
|
||||
const before = msg([
|
||||
{ type: "tool-getPage", state: "output-available" } as never,
|
||||
]);
|
||||
const after = msg([
|
||||
{
|
||||
type: "tool-getPage",
|
||||
state: "output-available",
|
||||
output: { id: "page-1", title: "Doc" },
|
||||
} as never,
|
||||
]);
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
|
||||
it("flips when errorText appears (the visible red error detail line)", () => {
|
||||
const before = msg([
|
||||
{ type: "tool-getPage", state: "output-error" } as never,
|
||||
]);
|
||||
const after = msg([
|
||||
{
|
||||
type: "tool-getPage",
|
||||
state: "output-error",
|
||||
errorText: "permission denied",
|
||||
} as never,
|
||||
]);
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
});
|
||||
|
||||
describe("metadata banners — render draws error / aborted notices", () => {
|
||||
it("flips when metadata.error appears (ChatErrorAlert banner)", () => {
|
||||
const before = msg([{ type: "text", text: "answer" }]);
|
||||
const after = msg([{ type: "text", text: "answer" }], { error: "boom" });
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
|
||||
it("flips when metadata.finishReason becomes 'aborted' (ChatStoppedNotice)", () => {
|
||||
const before = msg([{ type: "text", text: "answer" }], {
|
||||
finishReason: "stop",
|
||||
});
|
||||
const after = msg([{ type: "text", text: "answer" }], {
|
||||
finishReason: "aborted",
|
||||
});
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
});
|
||||
});
|
||||
44
apps/client/src/features/ai-chat/utils/message-signature.ts
Normal file
44
apps/client/src/features/ai-chat/utils/message-signature.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
/** Cheap content signature for one message: changes iff something VISIBLE in the
|
||||
* row changed. Streaming is APPEND-ONLY (text parts only grow, parts are only
|
||||
* appended, a tool/text part flips state once), so a per-part [type, text
|
||||
* length, state, error/output presence] tuple + the persisted metadata
|
||||
* (error/finishReason) is a sufficient change signal without comparing full
|
||||
* strings on every delta. WARNING — load-bearing for the MessageItem memo:
|
||||
* if a future part kind's VISIBLE content can change WITHOUT changing [type,
|
||||
* text length, state, error/output presence] (e.g. a tool that streams
|
||||
* `preliminary` output, or a client-side regenerate that edits a finalized
|
||||
* row in place), extend this signature or the memo will freeze a stale row. */
|
||||
export function messageSignature(message: UIMessage): string {
|
||||
const parts = message.parts
|
||||
.map((p) => {
|
||||
const any = p as {
|
||||
type: string;
|
||||
text?: string;
|
||||
state?: string;
|
||||
errorText?: string;
|
||||
output?: unknown;
|
||||
};
|
||||
return [
|
||||
any.type,
|
||||
any.text?.length ?? 0,
|
||||
any.state ?? "",
|
||||
any.errorText ? 1 : 0,
|
||||
any.output !== undefined ? 1 : 0,
|
||||
].join(":");
|
||||
})
|
||||
.join("|");
|
||||
const meta = message.metadata as
|
||||
| { error?: string; finishReason?: string; usage?: { reasoningTokens?: number } }
|
||||
| undefined;
|
||||
// `usage.reasoningTokens` is neither append-only nor part-bound: the authoritative
|
||||
// turn total arrives on the final `finish-step` AFTER the reasoning text length and
|
||||
// state are already frozen. Without it in the signature the row's signature would be
|
||||
// unchanged at that point and the re-render skipped, so the "Thinking · N tokens"
|
||||
// header (reasoningTokensForPart) would keep the live estimate instead of snapping
|
||||
// to the exact figure.
|
||||
return `${message.id}#${message.role}#${parts}#${meta?.error ?? ""}#${
|
||||
meta?.finishReason ?? ""
|
||||
}#${meta?.usage?.reasoningTokens ?? ""}`;
|
||||
}
|
||||
@@ -104,6 +104,19 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The inner editable paragraph inherits `.ProseMirror p { margin: 0.5em 0 }`,
|
||||
which pushes the first text line ~0.5em below the "N." marker (aligned to
|
||||
flex-start), making the number float above the text. Drop the outer margins
|
||||
so the marker and the first line share the same top edge — same approach
|
||||
used for callouts in core.css. */
|
||||
.definitionContent > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.definitionContent > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.backLink {
|
||||
flex: 0 0 auto;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -26,6 +26,7 @@ import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-t
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx";
|
||||
import { DeletedPageBanner } from "@/features/page/trash/components/deleted-page-banner.tsx";
|
||||
import { TemporaryNoteBanner } from "@/features/page/components/temporary-note-banner.tsx";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
currentPageEditModeAtom,
|
||||
@@ -37,6 +38,7 @@ const MemoizedTitleEditor = React.memo(TitleEditor);
|
||||
const MemoizedPageEditor = React.memo(PageEditor);
|
||||
const MemoizedFixedToolbar = React.memo(FixedToolbar);
|
||||
const MemoizedDeletedPageBanner = React.memo(DeletedPageBanner);
|
||||
const MemoizedTemporaryNoteBanner = React.memo(TemporaryNoteBanner);
|
||||
|
||||
type PageUser = {
|
||||
id: string;
|
||||
@@ -103,6 +105,7 @@ export function FullEditor({
|
||||
<MemoizedFixedToolbar />
|
||||
)}
|
||||
<MemoizedDeletedPageBanner slugId={slugId} />
|
||||
<MemoizedTemporaryNoteBanner slugId={slugId} />
|
||||
<MemoizedTitleEditor
|
||||
pageId={pageId}
|
||||
slugId={slugId}
|
||||
|
||||
@@ -10,9 +10,15 @@ ul[data-type="taskList"] {
|
||||
display: flex;
|
||||
|
||||
> label {
|
||||
padding-top: 0.2rem;
|
||||
/* Box exactly one text-line tall and center the checkbox in it, so the
|
||||
checkbox lines up with the first line of the item's text. This tracks
|
||||
the editor line-height (--mantine-line-height-xl) instead of a magic
|
||||
padding-top that drifts from the real line box. */
|
||||
flex: 0 0 auto;
|
||||
margin-right: 0.5rem;
|
||||
height: calc(var(--mantine-line-height-xl, 1.65) * 1em);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,40 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { toggleTemplate } from "@/features/page-embed/services/page-embed-api";
|
||||
import type { ToggleTemplateResponse } from "@/features/page-embed/types/page-embed.types";
|
||||
import {
|
||||
toggleTemplate,
|
||||
toggleTemporary,
|
||||
} from "@/features/page-embed/services/page-embed-api";
|
||||
import type {
|
||||
ToggleTemplateResponse,
|
||||
ToggleTemporaryResponse,
|
||||
} from "@/features/page-embed/types/page-embed.types";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
|
||||
/**
|
||||
* After toggling a note's temporary state, mirror the new deadline into the
|
||||
* shared page cache (keyed by both slugId and id) and refresh the sidebar so the
|
||||
* menu label, the in-page banner, and the tree icon all reflect the change.
|
||||
* Centralised here so the header menu and the banner can't drift apart on the
|
||||
* cache-key plumbing.
|
||||
*/
|
||||
export function syncTemporaryExpiresInCache(
|
||||
page: { id: string; slugId: string },
|
||||
temporaryExpiresAt: string | null,
|
||||
) {
|
||||
for (const key of [page.slugId, page.id]) {
|
||||
const cached = queryClient.getQueryData<any>(["pages", key]);
|
||||
if (cached) {
|
||||
queryClient.setQueryData(["pages", key], {
|
||||
...cached,
|
||||
temporaryExpiresAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
["sidebar-pages"].includes(item.queryKey[0] as string),
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleTemplateMutation() {
|
||||
return useMutation<
|
||||
@@ -18,3 +51,20 @@ export function useToggleTemplateMutation() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleTemporaryMutation() {
|
||||
return useMutation<
|
||||
ToggleTemporaryResponse,
|
||||
Error,
|
||||
{ pageId: string; temporary?: boolean }
|
||||
>({
|
||||
mutationFn: (data) => toggleTemporary(data),
|
||||
onError: (err: any) => {
|
||||
notifications.show({
|
||||
message:
|
||||
err?.response?.data?.message || "Failed to update temporary note",
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import api from "@/lib/api-client";
|
||||
import type {
|
||||
PageTemplateLookup,
|
||||
ToggleTemplateResponse,
|
||||
ToggleTemporaryResponse,
|
||||
} from "../types/page-embed.types";
|
||||
|
||||
export async function lookupTemplate(params: {
|
||||
@@ -18,3 +19,11 @@ export async function toggleTemplate(params: {
|
||||
const r = await api.post("/pages/toggle-template", params);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export async function toggleTemporary(params: {
|
||||
pageId: string;
|
||||
temporary?: boolean;
|
||||
}): Promise<ToggleTemporaryResponse> {
|
||||
const r = await api.post("/pages/toggle-temporary", params);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
@@ -14,3 +14,9 @@ export type ToggleTemplateResponse = {
|
||||
pageId: string;
|
||||
isTemplate: boolean;
|
||||
};
|
||||
|
||||
export type ToggleTemporaryResponse = {
|
||||
pageId: string;
|
||||
// null => the note was made permanent; ISO string => armed deadline.
|
||||
temporaryExpiresAt: string | null;
|
||||
};
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, screen, cleanup, within } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// Mantine Tooltip mounts its label lazily on hover via Floating UI, which is
|
||||
// flaky under jsdom. Replace ONLY the Tooltip with a thin wrapper that renders
|
||||
// the label inline (keeping Badge/Switch/etc. real), so the provenance label —
|
||||
// the contract we care about — is deterministically queryable.
|
||||
vi.mock("@mantine/core", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@mantine/core")>("@mantine/core");
|
||||
const Tooltip = ({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}) => (
|
||||
<>
|
||||
{children}
|
||||
<span data-testid="tooltip-label">{label}</span>
|
||||
</>
|
||||
);
|
||||
Tooltip.Group = ({ children }: { children?: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
);
|
||||
return { ...actual, Tooltip };
|
||||
});
|
||||
|
||||
// jsdom lacks matchMedia, which MantineProvider's color-scheme hook needs.
|
||||
beforeAll(() => {
|
||||
if (!window.matchMedia) {
|
||||
window.matchMedia = (query: string) =>
|
||||
({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}) as unknown as MediaQueryList;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Mocks for the heavy / networked module graph ---------------------------
|
||||
// HistoryItem pulls in i18n, jotai atoms (ai-chat / history), a config-backed
|
||||
// avatar and a time formatter. The provenance-badge contract is the unit under
|
||||
// test, so we stub everything else down to inert, deterministic renders and
|
||||
// keep the real Mantine Badge/Tooltip so role/label queries are meaningful.
|
||||
|
||||
// i18n: interpolate {{name}} so the git-sync tooltip carries the author name,
|
||||
// letting us assert provenance attribution without a real i18n backend.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, vars?: Record<string, unknown>) =>
|
||||
vars && typeof vars.name !== "undefined"
|
||||
? key.replace("{{name}}", String(vars.name))
|
||||
: key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// jotai setters: the badges call useSetAtom; return inert setters so a click on
|
||||
// the (deep-linkable) AiAgentBadge would fire these — proving the git-sync badge
|
||||
// does NOT wire any of them.
|
||||
const setAiChatWindowOpen = vi.fn();
|
||||
const setActiveChatId = vi.fn();
|
||||
const setDraft = vi.fn();
|
||||
const setHistoryModalOpen = vi.fn();
|
||||
vi.mock("jotai", async () => {
|
||||
const actual = await vi.importActual<typeof import("jotai")>("jotai");
|
||||
return {
|
||||
...actual,
|
||||
useSetAtom: (atom: unknown) => {
|
||||
switch (atom) {
|
||||
case aiChatWindowOpenAtom:
|
||||
return setAiChatWindowOpen;
|
||||
case activeAiChatIdAtom:
|
||||
return setActiveChatId;
|
||||
case aiChatDraftAtom:
|
||||
return setDraft;
|
||||
case historyAtoms:
|
||||
return setHistoryModalOpen;
|
||||
default:
|
||||
return vi.fn();
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Atoms are imported only as identity tokens for the useSetAtom switch above.
|
||||
vi.mock("@/features/ai-chat/atoms/ai-chat-atom.ts", () => ({
|
||||
activeAiChatIdAtom: { __tag: "activeAiChatIdAtom" },
|
||||
aiChatWindowOpenAtom: { __tag: "aiChatWindowOpenAtom" },
|
||||
aiChatDraftAtom: { __tag: "aiChatDraftAtom" },
|
||||
}));
|
||||
vi.mock("@/features/page-history/atoms/history-atoms.ts", () => ({
|
||||
historyAtoms: { __tag: "historyAtoms" },
|
||||
}));
|
||||
|
||||
// Avatar reaches into config (getAvatarUrl) — stub to a plain element.
|
||||
vi.mock("@/components/ui/custom-avatar.tsx", () => ({
|
||||
CustomAvatar: ({ name }: { name?: string }) => (
|
||||
<span data-testid="avatar">{name}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
// Deterministic, locale-free date string.
|
||||
vi.mock("@/lib/time", () => ({
|
||||
formattedDate: () => "2026-06-21",
|
||||
}));
|
||||
|
||||
import HistoryItem from "./history-item";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatDraftAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
import type { IPageHistory } from "@/features/page-history/types/page.types";
|
||||
|
||||
function makeItem(overrides: Partial<IPageHistory> = {}): IPageHistory {
|
||||
return {
|
||||
id: "h1",
|
||||
pageId: "p1",
|
||||
title: "Title",
|
||||
slug: "slug",
|
||||
icon: "",
|
||||
coverPhoto: "",
|
||||
version: 1,
|
||||
lastUpdatedById: "u1",
|
||||
workspaceId: "w1",
|
||||
createdAt: "2026-06-21T00:00:00.000Z",
|
||||
updatedAt: "2026-06-21T00:00:00.000Z",
|
||||
lastUpdatedBy: { id: "u1", name: "Alice", avatarUrl: "" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderItem(item: IPageHistory) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<HistoryItem
|
||||
historyItem={item}
|
||||
index={0}
|
||||
onSelect={vi.fn()}
|
||||
isActive={false}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("HistoryItem git-sync provenance badge", () => {
|
||||
// Test 1: the git-sync badge renders ONLY for lastUpdatedSource === 'git-sync'.
|
||||
it("renders the Git sync badge only when lastUpdatedSource is 'git-sync'", () => {
|
||||
renderItem(makeItem({ lastUpdatedSource: "git-sync" }));
|
||||
expect(screen.getByText("Git sync")).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["agent", "agent"],
|
||||
["user", "user"],
|
||||
["undefined", undefined],
|
||||
])(
|
||||
"does NOT render the Git sync badge when lastUpdatedSource is %s",
|
||||
(_label, source) => {
|
||||
renderItem(makeItem({ lastUpdatedSource: source }));
|
||||
expect(screen.queryByText("Git sync")).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
// Test 2: provenance attribution + the git-sync badge is NOT interactive.
|
||||
it("attributes the git-sync provenance to the correct author and is not clickable", () => {
|
||||
renderItem(
|
||||
makeItem({
|
||||
lastUpdatedSource: "git-sync",
|
||||
lastUpdatedBy: { id: "u2", name: "Bob", avatarUrl: "" },
|
||||
}),
|
||||
);
|
||||
|
||||
const badge = screen.getByText("Git sync");
|
||||
|
||||
// Provenance attribution: the tooltip label carries the author name (the
|
||||
// git-sync badge passes authorName -> "Synced from Git on behalf of {{name}}").
|
||||
expect(screen.getByText("Synced from Git on behalf of Bob")).toBeTruthy();
|
||||
|
||||
// The git-sync badge must NOT behave like AiAgentBadge: the badge element
|
||||
// itself is not a button, carries no role=button and no tabIndex, and
|
||||
// clicking it must not trigger any ai-chat deep-link. (The surrounding
|
||||
// history-row IS an UnstyledButton — that is the row's own select affordance,
|
||||
// not the badge — so we scope these checks to the badge element.)
|
||||
const badgeRoot = (badge.closest("[class*='mantine-Badge-root']") ??
|
||||
badge) as HTMLElement;
|
||||
expect(badgeRoot.getAttribute("role")).not.toBe("button");
|
||||
expect(badgeRoot.getAttribute("tabindex")).toBeNull();
|
||||
expect(badgeRoot.tagName.toLowerCase()).not.toBe("button");
|
||||
// No interactive descendant button lives inside the badge itself.
|
||||
expect(within(badgeRoot).queryByRole("button")).toBeNull();
|
||||
|
||||
badgeRoot.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(setActiveChatId).not.toHaveBeenCalled();
|
||||
expect(setAiChatWindowOpen).not.toHaveBeenCalled();
|
||||
expect(setDraft).not.toHaveBeenCalled();
|
||||
expect(setHistoryModalOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Sanity contrast: the agent badge (the copy-paste source) IS interactive when
|
||||
// it carries an aiChatId — proving the not-clickable assertion above is real.
|
||||
it("contrast: the AI-agent badge is a deep-link button when it has an aiChatId", () => {
|
||||
renderItem(
|
||||
makeItem({
|
||||
lastUpdatedSource: "agent",
|
||||
lastUpdatedAiChatId: "chat-1",
|
||||
}),
|
||||
);
|
||||
const agentBadge = screen.getByText("AI-agent");
|
||||
const root = agentBadge.closest("[role='button']");
|
||||
expect(root).not.toBeNull();
|
||||
within(root as HTMLElement).getByText("AI-agent");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
|
||||
import { GitSyncBadge } from "@/components/ui/git-sync-badge.tsx";
|
||||
import { formattedDate } from "@/lib/time";
|
||||
import classes from "./css/history.module.css";
|
||||
import clsx from "clsx";
|
||||
@@ -42,7 +41,6 @@ const HistoryItem = memo(function HistoryItem({
|
||||
const contributors = historyItem.contributors;
|
||||
const hasContributors = contributors && contributors.length > 0;
|
||||
const isAgentEdit = historyItem.lastUpdatedSource === "agent";
|
||||
const isGitSyncEdit = historyItem.lastUpdatedSource === "git-sync";
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
@@ -110,10 +108,6 @@ const HistoryItem = memo(function HistoryItem({
|
||||
onActivate={() => setHistoryModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isGitSyncEdit && (
|
||||
<GitSyncBadge authorName={historyItem.lastUpdatedBy?.name} />
|
||||
)}
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ActionIcon, Button, Group, Menu, Text, ThemeIcon, Tooltip } from "@mant
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconArrowsHorizontal,
|
||||
IconClockHour4,
|
||||
IconDots,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
@@ -24,6 +25,10 @@ import { useDisclosure, useHotkeys } from "@mantine/hooks";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import {
|
||||
useToggleTemporaryMutation,
|
||||
syncTemporaryExpiresInCache,
|
||||
} from "@/features/page-embed/queries/page-embed-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { getAppUrl } from "@/lib/config.ts";
|
||||
@@ -160,6 +165,29 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
const { data: watchStatus } = useWatchStatusQuery(page?.id);
|
||||
const watchPage = useWatchPageMutation();
|
||||
const unwatchPage = useUnwatchPageMutation();
|
||||
const toggleTemporary = useToggleTemporaryMutation();
|
||||
const isTemporary = !!page?.temporaryExpiresAt;
|
||||
|
||||
const handleToggleTemporary = async () => {
|
||||
if (!page?.id) return;
|
||||
const next = !isTemporary;
|
||||
try {
|
||||
const res = await toggleTemporary.mutateAsync({
|
||||
pageId: page.id,
|
||||
temporary: next,
|
||||
});
|
||||
// Reflect the new deadline in the page cache so the menu label flips and
|
||||
// any banner updates. The sidebar icon refreshes via its own query.
|
||||
syncTemporaryExpiresInCache(page, res.temporaryExpiresAt);
|
||||
notifications.show({
|
||||
message: next
|
||||
? t("Note will move to trash unless made permanent")
|
||||
: t("Note is now permanent"),
|
||||
});
|
||||
} catch {
|
||||
// mutation surfaces the error via notifications
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const pageUrl =
|
||||
@@ -309,6 +337,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
{!readOnly && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<IconClockHour4 size={16} />}
|
||||
onClick={handleToggleTemporary}
|
||||
>
|
||||
{isTemporary ? t("Make permanent") : t("Make temporary")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color={"red"}
|
||||
leftSection={<IconTrash size={16} />}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Button, Group, Paper, Text } from "@mantine/core";
|
||||
import { IconClockHour4 } from "@tabler/icons-react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import {
|
||||
useToggleTemporaryMutation,
|
||||
syncTemporaryExpiresInCache,
|
||||
} from "@/features/page-embed/queries/page-embed-query.ts";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from "@/features/space/permissions/permissions.type.ts";
|
||||
|
||||
type TemporaryNoteBannerProps = {
|
||||
slugId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Banner shown on an open temporary note ("structure or die"). Mirrors
|
||||
* DeletedPageBanner: it reads the page from the shared query cache and offers
|
||||
* the explicit rescue action — "Make permanent". Children ride along to trash
|
||||
* with the note, which is noted in the copy.
|
||||
*/
|
||||
export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: page } = usePageQuery({ pageId: slugId });
|
||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
|
||||
const expiresTimeAgo = useTimeAgo(page?.temporaryExpiresAt);
|
||||
const toggleTemporary = useToggleTemporaryMutation();
|
||||
|
||||
// Don't show on a note that is already in trash; the deleted-page banner
|
||||
// owns that state.
|
||||
if (!page?.temporaryExpiresAt || page?.deletedAt) return null;
|
||||
|
||||
const canEdit = spaceAbility.can(SpaceCaslAction.Edit, SpaceCaslSubject.Page);
|
||||
|
||||
const handleMakePermanent = async () => {
|
||||
try {
|
||||
const res = await toggleTemporary.mutateAsync({
|
||||
pageId: page.id,
|
||||
temporary: false,
|
||||
});
|
||||
syncTemporaryExpiresInCache(page, res.temporaryExpiresAt);
|
||||
} catch {
|
||||
// mutation surfaces the error via notifications
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper radius="sm" mb="md" px="md" py="xs" bg="orange.0">
|
||||
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||
<Group gap="xs" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
<IconClockHour4
|
||||
size={18}
|
||||
stroke={1.5}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
color: "var(--mantine-color-orange-7)",
|
||||
}}
|
||||
/>
|
||||
<Text size="sm">
|
||||
<Trans
|
||||
i18nKey="This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent."
|
||||
values={{ time: expiresTimeAgo }}
|
||||
/>
|
||||
</Text>
|
||||
</Group>
|
||||
{canEdit && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<IconClockHour4 size={16} />}
|
||||
onClick={handleMakePermanent}
|
||||
loading={toggleTemporary.isPending}
|
||||
>
|
||||
{t("Make permanent")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useDisclosure } from "@mantine/hooks";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconClockHour4,
|
||||
IconCopy,
|
||||
IconDotsVertical,
|
||||
IconFileExport,
|
||||
@@ -30,7 +31,10 @@ import {
|
||||
useRemoveFavoriteMutation,
|
||||
} from "@/features/favorite/queries/favorite-query";
|
||||
|
||||
import { useToggleTemplateMutation } from "@/features/page-embed/queries/page-embed-query";
|
||||
import {
|
||||
useToggleTemplateMutation,
|
||||
useToggleTemporaryMutation,
|
||||
} from "@/features/page-embed/queries/page-embed-query";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||
@@ -65,6 +69,8 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
const isFavorited = favoriteIds.has(node.id);
|
||||
const toggleTemplate = useToggleTemplateMutation();
|
||||
const isTemplate = !!node.isTemplate;
|
||||
const toggleTemporary = useToggleTemporaryMutation();
|
||||
const isTemporary = !!node.temporaryExpiresAt;
|
||||
|
||||
const handleToggleTemplate = async () => {
|
||||
const next = !isTemplate;
|
||||
@@ -84,6 +90,29 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleTemporary = async () => {
|
||||
const next = !isTemporary;
|
||||
try {
|
||||
const res = await toggleTemporary.mutateAsync({
|
||||
pageId: node.id,
|
||||
temporary: next,
|
||||
});
|
||||
// Reflect the new deadline locally so the icon/menu update immediately.
|
||||
setData((prev) =>
|
||||
treeModel.update(prev, node.id, {
|
||||
temporaryExpiresAt: res.temporaryExpiresAt,
|
||||
} as any),
|
||||
);
|
||||
notifications.show({
|
||||
message: next
|
||||
? t("Note will move to trash unless made permanent")
|
||||
: t("Note is now permanent"),
|
||||
});
|
||||
} catch {
|
||||
// mutation surfaces the error via notifications
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const pageUrl =
|
||||
getAppUrl() + buildPageUrl(spaceSlug, node.slugId, node.name);
|
||||
@@ -248,6 +277,17 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
{isTemplate ? t("Unset as template") : t("Make template")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconClockHour4 size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleToggleTemporary();
|
||||
}}
|
||||
>
|
||||
{isTemporary ? t("Make permanent") : t("Make temporary")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
c="red"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ActionIcon, rem, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconClockHour4,
|
||||
IconFileDescription,
|
||||
IconPlus,
|
||||
IconPointFilled,
|
||||
@@ -191,6 +192,28 @@ export function SpaceTreeRow({
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{node.temporaryExpiresAt && (
|
||||
<Tooltip
|
||||
// Children ride along to trash with the note (recursive removePage).
|
||||
label={t("Temporary note — moves to trash unless made permanent")}
|
||||
withArrow
|
||||
>
|
||||
<IconClockHour4
|
||||
size={14}
|
||||
stroke={1.5}
|
||||
// Same visual-only indicator pattern as the template icon, but
|
||||
// orange to flag the impending death timer.
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
marginLeft: rem(4),
|
||||
color: "var(--mantine-color-orange-6)",
|
||||
}}
|
||||
aria-label={t("Temporary note")}
|
||||
role="img"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<div className={classes.actions}>
|
||||
<NodeMenu node={node} canEdit={canEdit} />
|
||||
|
||||
|
||||
@@ -22,7 +22,10 @@ import { getSpaceUrl } from "@/lib/config.ts";
|
||||
|
||||
export type UseTreeMutation = {
|
||||
handleMove: (sourceId: string, op: DropOp) => Promise<void>;
|
||||
handleCreate: (parentId: string | null) => Promise<void>;
|
||||
handleCreate: (
|
||||
parentId: string | null,
|
||||
opts?: { temporary?: boolean },
|
||||
) => Promise<void>;
|
||||
handleRename: (id: string, name: string) => Promise<void>;
|
||||
handleDelete: (id: string) => Promise<void>;
|
||||
};
|
||||
@@ -119,9 +122,15 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (parentId: string | null) => {
|
||||
const payload: { spaceId: string; parentPageId?: string } = { spaceId };
|
||||
async (parentId: string | null, opts?: { temporary?: boolean }) => {
|
||||
const payload: {
|
||||
spaceId: string;
|
||||
parentPageId?: string;
|
||||
temporary?: boolean;
|
||||
} = { spaceId };
|
||||
if (parentId) payload.parentPageId = parentId;
|
||||
// Ask the server to arm the death timer for a "temporary note".
|
||||
if (opts?.temporary) payload.temporary = true;
|
||||
|
||||
let createdPage: IPage;
|
||||
try {
|
||||
@@ -138,6 +147,8 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
spaceId: createdPage.spaceId,
|
||||
parentPageId: createdPage.parentPageId,
|
||||
hasChildren: false,
|
||||
// Show the temporary-note icon immediately on optimistic insert.
|
||||
temporaryExpiresAt: createdPage.temporaryExpiresAt,
|
||||
children: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -752,6 +752,27 @@ describe("treeModel.placeByPosition", () => {
|
||||
});
|
||||
expect(t.map((n) => n.id)).toEqual(["r1", "child", "r2", "rp"]);
|
||||
});
|
||||
|
||||
it("returns same reference (no-op) when the destination parent is inside the source's own subtree (#206 ui-state-races-1)", () => {
|
||||
// Moving `a` under its own descendant `b` is a cycle. Without the guard,
|
||||
// remove(a) drops b too and insertByPosition can't re-place a -> the whole
|
||||
// subtree silently vanishes. The guard refuses the move (same reference).
|
||||
const cyclic: P[] = [
|
||||
{
|
||||
id: "a",
|
||||
name: "A",
|
||||
position: "a0",
|
||||
children: [{ id: "b", name: "B", position: "a1" }],
|
||||
},
|
||||
];
|
||||
const t = treeModel.placeByPosition(cyclic, "a", {
|
||||
parentId: "b",
|
||||
position: "a5",
|
||||
});
|
||||
expect(t).toBe(cyclic);
|
||||
expect(treeModel.find(t, "a")).not.toBeNull();
|
||||
expect(treeModel.find(t, "b")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("treeModel.move", () => {
|
||||
|
||||
@@ -294,6 +294,20 @@ export const treeModel = {
|
||||
const source = treeModel.find(tree, sourceId);
|
||||
if (!source) return tree;
|
||||
if (to.parentId !== null && !treeModel.find(tree, to.parentId)) return tree;
|
||||
// Cycle guard, mirroring `move`'s `isDescendant` check (#206 ui-state-races-1).
|
||||
// If the destination parent is INSIDE the moved node's own subtree (reachable
|
||||
// when server-authoritative move events arrive out of order — e.g. X moved
|
||||
// under Y, then Y under X, but on this receiver Y is still inside X), then
|
||||
// `remove(sourceId)` would drop the future parent along with the whole subtree
|
||||
// and `insertByPosition` could not find it again — the node and ALL its
|
||||
// descendants would silently vanish. Refuse the move and return the same
|
||||
// reference so callers can detect the no-op and reconcile (refetch) instead.
|
||||
if (
|
||||
to.parentId !== null &&
|
||||
treeModel.isDescendant(tree, sourceId, to.parentId)
|
||||
) {
|
||||
return tree;
|
||||
}
|
||||
const removed = treeModel.remove(tree, sourceId);
|
||||
// Reuse the same position-ordered insertion as `insertByPosition` by
|
||||
// stamping the authoritative position onto the moved node first.
|
||||
|
||||
@@ -9,5 +9,7 @@ export type SpaceTreeNode = {
|
||||
hasChildren: boolean;
|
||||
canEdit?: boolean;
|
||||
isTemplate?: boolean;
|
||||
// Death-timer deadline. null/absent => permanent; ISO string => temporary note.
|
||||
temporaryExpiresAt?: string | null;
|
||||
children: SpaceTreeNode[];
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ export function buildTree(pages: IPage[]): SpaceTreeNode[] {
|
||||
parentPageId: page.parentPageId,
|
||||
canEdit: page.canEdit ?? page.permissions?.canEdit,
|
||||
isTemplate: page.isTemplate,
|
||||
temporaryExpiresAt: page.temporaryExpiresAt,
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -13,6 +13,10 @@ export interface IPage {
|
||||
workspaceId: string;
|
||||
isLocked: boolean;
|
||||
isTemplate?: boolean;
|
||||
// Death-timer deadline. null/absent => permanent; ISO string => temporary note.
|
||||
temporaryExpiresAt?: string | null;
|
||||
// Create-only input flag: ask the server to arm the timer on a new page.
|
||||
temporary?: boolean;
|
||||
lastUpdatedById: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { IconExternalLink } from "@tabler/icons-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CopyTextButton from "@/components/common/copy.tsx";
|
||||
import { getAppUrl } from "@/lib/config.ts";
|
||||
import {
|
||||
useRemoveShareAliasMutation,
|
||||
useSetShareAliasMutation,
|
||||
useShareAliasForPageQuery,
|
||||
} from "@/features/share/queries/share-query.ts";
|
||||
import { checkShareAliasAvailability } from "@/features/share/services/share-service.ts";
|
||||
import {
|
||||
isValidShareAlias,
|
||||
normalizeShareAlias,
|
||||
} from "@/features/share/share-alias.util.ts";
|
||||
|
||||
interface ShareAliasSectionProps {
|
||||
pageId: string;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
// The prefix label shown next to the slug input, e.g. "docs.example.com/l/".
|
||||
function aliasPrefixLabel(): string {
|
||||
const url = getAppUrl();
|
||||
const host = url.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
||||
return `${host}/l/`;
|
||||
}
|
||||
|
||||
export default function ShareAliasSection({
|
||||
pageId,
|
||||
readOnly,
|
||||
}: ShareAliasSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: currentAlias } = useShareAliasForPageQuery(pageId);
|
||||
const setAliasMutation = useSetShareAliasMutation();
|
||||
const removeAliasMutation = useRemoveShareAliasMutation();
|
||||
|
||||
const [value, setValue] = useState("");
|
||||
const [availability, setAvailability] = useState<{
|
||||
valid: boolean;
|
||||
available: boolean;
|
||||
currentPageId: string | null;
|
||||
} | null>(null);
|
||||
const [reassign, setReassign] = useState<{
|
||||
alias: string;
|
||||
currentPageTitle: string | null;
|
||||
} | null>(null);
|
||||
|
||||
// Seed the input from the page's current alias (if any).
|
||||
useEffect(() => {
|
||||
setValue(currentAlias?.alias ?? "");
|
||||
}, [currentAlias?.alias, pageId]);
|
||||
|
||||
const normalized = useMemo(() => normalizeShareAlias(value), [value]);
|
||||
const isValid = isValidShareAlias(normalized);
|
||||
const unchanged = currentAlias?.alias === normalized;
|
||||
|
||||
// Debounced availability probe (skips when invalid or unchanged).
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
useEffect(() => {
|
||||
setAvailability(null);
|
||||
if (!isValid || unchanged) return;
|
||||
debounceRef.current && clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const res = await checkShareAliasAvailability(normalized);
|
||||
setAvailability({
|
||||
valid: res.valid,
|
||||
available: res.available,
|
||||
currentPageId: res.currentPageId,
|
||||
});
|
||||
} catch {
|
||||
setAvailability(null);
|
||||
}
|
||||
}, 400);
|
||||
return () => {
|
||||
debounceRef.current && clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [normalized, isValid, unchanged]);
|
||||
|
||||
const prettyLink = currentAlias?.alias
|
||||
? `${getAppUrl()}/l/${currentAlias.alias}`
|
||||
: null;
|
||||
|
||||
const handleSave = async (confirmReassign = false) => {
|
||||
try {
|
||||
await setAliasMutation.mutateAsync({
|
||||
pageId,
|
||||
alias: normalized,
|
||||
confirmReassign,
|
||||
});
|
||||
setReassign(null);
|
||||
} catch (error: any) {
|
||||
// The address already points at another page: prompt to move it here.
|
||||
if (error?.status === 409 || error?.response?.status === 409) {
|
||||
const data = error?.response?.data;
|
||||
if (data?.code === "ALIAS_REASSIGN_REQUIRED") {
|
||||
setReassign({
|
||||
alias: normalized,
|
||||
currentPageTitle: data?.currentPageTitle ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
if (!currentAlias?.id) return;
|
||||
await removeAliasMutation.mutateAsync(currentAlias.id);
|
||||
setValue("");
|
||||
};
|
||||
|
||||
const showInvalid = normalized.length > 0 && !isValid;
|
||||
const showTaken =
|
||||
isValid && !unchanged && availability && !availability.available;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text size="sm" fw={500} mt="md">
|
||||
{t("Custom address")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb={4}>
|
||||
{t("A short, memorable link you can point at any shared page.")}
|
||||
</Text>
|
||||
|
||||
{prettyLink && (
|
||||
<Group my="xs" gap={4} wrap="nowrap">
|
||||
<TextInput
|
||||
variant="filled"
|
||||
value={prettyLink}
|
||||
readOnly
|
||||
rightSection={<CopyTextButton text={prettyLink} />}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
<ActionIcon
|
||||
component="a"
|
||||
variant="default"
|
||||
target="_blank"
|
||||
href={prettyLink}
|
||||
size="sm"
|
||||
>
|
||||
<IconExternalLink size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.currentTarget.value)}
|
||||
// Show the canonical form once the user pauses so what they type maps
|
||||
// visibly to what gets stored.
|
||||
onBlur={() => setValue(normalized)}
|
||||
leftSection={
|
||||
<Text size="xs" c="dimmed" pl={4} style={{ whiteSpace: "nowrap" }}>
|
||||
{aliasPrefixLabel()}
|
||||
</Text>
|
||||
}
|
||||
leftSectionWidth={Math.min(aliasPrefixLabel().length * 7 + 12, 180)}
|
||||
placeholder={t("my-page")}
|
||||
disabled={readOnly}
|
||||
error={
|
||||
showInvalid
|
||||
? t("Use 2-60 lowercase letters, digits and hyphens")
|
||||
: showTaken
|
||||
? t("This address is already in use")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Group mt="xs" gap="xs">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
onClick={() => handleSave(false)}
|
||||
loading={setAliasMutation.isPending}
|
||||
disabled={readOnly || !isValid || unchanged}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
{currentAlias?.id && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
color="red"
|
||||
onClick={handleRemove}
|
||||
loading={removeAliasMutation.isPending}
|
||||
disabled={readOnly}
|
||||
>
|
||||
{t("Remove")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Modal
|
||||
opened={!!reassign}
|
||||
onClose={() => setReassign(null)}
|
||||
title={t("Move custom address?")}
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Text size="sm">
|
||||
{reassign?.currentPageTitle
|
||||
? t(
|
||||
'The address "{{alias}}" currently points to "{{title}}". Move it to this page?',
|
||||
{
|
||||
alias: reassign?.alias,
|
||||
title: reassign?.currentPageTitle,
|
||||
},
|
||||
)
|
||||
: t(
|
||||
'The address "{{alias}}" is already in use. Move it to this page?',
|
||||
{ alias: reassign?.alias },
|
||||
)}
|
||||
</Text>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={() => setReassign(null)}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => handleSave(true)}
|
||||
loading={setAliasMutation.isPending}
|
||||
>
|
||||
{t("Move here")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import CopyTextButton from "@/components/common/copy.tsx";
|
||||
import { getAppUrl } from "@/lib/config.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import classes from "@/features/share/components/share.module.css";
|
||||
import ShareAliasSection from "@/features/share/components/share-alias-section.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||
@@ -253,6 +254,9 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</Group>
|
||||
{pageId && (
|
||||
<ShareAliasSection pageId={pageId} readOnly={readOnly} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -10,6 +10,8 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ICreateShare,
|
||||
IShare,
|
||||
IShareAlias,
|
||||
ISetShareAlias,
|
||||
ISharedItem,
|
||||
ISharedPage,
|
||||
ISharedPageTree,
|
||||
@@ -20,11 +22,14 @@ import {
|
||||
import {
|
||||
createShare,
|
||||
deleteShare,
|
||||
getShareAliasForPage,
|
||||
getSharedPageTree,
|
||||
getShareForPage,
|
||||
getShareInfo,
|
||||
getSharePageInfo,
|
||||
getShares,
|
||||
removeShareAlias,
|
||||
setShareAlias,
|
||||
updateShare,
|
||||
} from "@/features/share/services/share-service.ts";
|
||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||
@@ -170,6 +175,72 @@ export function useDeleteShareMutation() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useShareAliasForPageQuery(
|
||||
pageId: string,
|
||||
): UseQueryResult<IShareAlias | null, Error> {
|
||||
return useQuery({
|
||||
// The endpoint resolves to null when the page has no alias; normalize the
|
||||
// absence so React Query never sees `undefined`.
|
||||
queryKey: ["share-alias-for-page", pageId],
|
||||
queryFn: async () => (await getShareAliasForPage(pageId)) ?? null,
|
||||
enabled: !!pageId,
|
||||
staleTime: 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetShareAliasMutation() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<IShareAlias, Error, ISetShareAlias>({
|
||||
mutationFn: (data) => setShareAlias(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
["share-alias-for-page", "share-list"].includes(
|
||||
item.queryKey[0] as string,
|
||||
),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
// A 409 reassign-required is handled inline by the modal (it shows the
|
||||
// "move address here?" confirmation), so don't surface a generic toast.
|
||||
if (error?.["status"] === 409) return;
|
||||
notifications.show({
|
||||
message:
|
||||
error?.["response"]?.data?.message || t("Failed to set custom address"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveShareAliasMutation() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<void, Error, string>({
|
||||
mutationFn: (aliasId) => removeShareAlias(aliasId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
["share-alias-for-page", "share-list"].includes(
|
||||
item.queryKey[0] as string,
|
||||
),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({
|
||||
message:
|
||||
error?.["response"]?.data?.message ||
|
||||
t("Failed to remove custom address"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGetSharedPageTreeQuery(
|
||||
shareId: string,
|
||||
): UseQueryResult<ISharedPageTree, Error> {
|
||||
|
||||
@@ -4,6 +4,9 @@ import { IPage } from "@/features/page/types/page.types";
|
||||
import {
|
||||
ICreateShare,
|
||||
IShare,
|
||||
IShareAlias,
|
||||
IShareAliasAvailability,
|
||||
ISetShareAlias,
|
||||
ISharedItem,
|
||||
ISharedPage,
|
||||
ISharedPageTree,
|
||||
@@ -57,3 +60,33 @@ export async function getSharedPageTree(
|
||||
const req = await api.post<ISharedPageTree>("/shares/tree", { shareId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getShareAliasForPage(
|
||||
pageId: string,
|
||||
): Promise<IShareAlias | null> {
|
||||
const req = await api.post<IShareAlias | null>("/share-aliases/for-page", {
|
||||
pageId,
|
||||
});
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function setShareAlias(
|
||||
data: ISetShareAlias,
|
||||
): Promise<IShareAlias> {
|
||||
const req = await api.post<IShareAlias>("/share-aliases/set", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function removeShareAlias(aliasId: string): Promise<void> {
|
||||
await api.post("/share-aliases/remove", { aliasId });
|
||||
}
|
||||
|
||||
export async function checkShareAliasAvailability(
|
||||
alias: string,
|
||||
): Promise<IShareAliasAvailability> {
|
||||
const req = await api.post<IShareAliasAvailability>(
|
||||
"/share-aliases/availability",
|
||||
{ alias },
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
32
apps/client/src/features/share/share-alias.util.test.ts
Normal file
32
apps/client/src/features/share/share-alias.util.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isValidShareAlias,
|
||||
normalizeShareAlias,
|
||||
} from "@/features/share/share-alias.util.ts";
|
||||
|
||||
// Mirrors the server-side util so the modal's live feedback matches what the
|
||||
// server will accept/store.
|
||||
describe("normalizeShareAlias", () => {
|
||||
it("lowercases, trims and maps separators to single hyphens", () => {
|
||||
expect(normalizeShareAlias(" My Cool_Page ")).toBe("my-cool-page");
|
||||
});
|
||||
|
||||
it("collapses repeated hyphens and trims edges", () => {
|
||||
expect(normalizeShareAlias("--a---b--")).toBe("a-b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidShareAlias", () => {
|
||||
it("accepts ascii hyphen-separated slugs of length 2..60", () => {
|
||||
expect(isValidShareAlias("hello-world")).toBe(true);
|
||||
expect(isValidShareAlias("a".repeat(60))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects too short, edge/double hyphens, uppercase and non-ascii", () => {
|
||||
expect(isValidShareAlias("a")).toBe(false);
|
||||
expect(isValidShareAlias("-a")).toBe(false);
|
||||
expect(isValidShareAlias("a--b")).toBe(false);
|
||||
expect(isValidShareAlias("Hello")).toBe(false);
|
||||
expect(isValidShareAlias("привет")).toBe(false);
|
||||
});
|
||||
});
|
||||
26
apps/client/src/features/share/share-alias.util.ts
Normal file
26
apps/client/src/features/share/share-alias.util.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Client copy of the vanity share-alias helpers. Kept in sync with the server
|
||||
* (`apps/server/src/core/share/share-alias.util.ts`) so live input feedback
|
||||
* matches what the server will store/accept. ASCII-only, lowercase, hyphen
|
||||
* separated, length 2..60.
|
||||
*/
|
||||
|
||||
// Normalize a user-provided vanity alias into canonical ASCII storage form.
|
||||
export function normalizeShareAlias(raw: string): string {
|
||||
return (raw ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_]+/g, "-")
|
||||
.replace(/-{2,}/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
const ALIAS_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
export function isValidShareAlias(alias: string): boolean {
|
||||
return (
|
||||
typeof alias === "string" &&
|
||||
alias.length >= 2 &&
|
||||
alias.length <= 60 &&
|
||||
ALIAS_RE.test(alias)
|
||||
);
|
||||
}
|
||||
@@ -75,6 +75,30 @@ export interface IShareInfoInput {
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
// Vanity /l/:alias pointer.
|
||||
export interface IShareAlias {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
alias: string;
|
||||
pageId: string | null;
|
||||
creatorId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ISetShareAlias {
|
||||
pageId: string;
|
||||
alias: string;
|
||||
confirmReassign?: boolean;
|
||||
}
|
||||
|
||||
export interface IShareAliasAvailability {
|
||||
alias: string;
|
||||
valid: boolean;
|
||||
available: boolean;
|
||||
currentPageId: string | null;
|
||||
}
|
||||
|
||||
export interface ISharedPageTree {
|
||||
share: IShare;
|
||||
pageTree: Partial<IPage[]>;
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeAll,
|
||||
afterEach,
|
||||
} from "vitest";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// --- Mocks for the heavy / networked module graph ---------------------------
|
||||
// EditSpaceForm wires the "Enable Git sync" Switch to a TanStack-Query mutation
|
||||
// (useUpdateSpaceMutation). We mock ONLY that hook so the test fully controls
|
||||
// mutateAsync (resolve / reject) and isPending, and stub i18n. The real Mantine
|
||||
// Switch is rendered so the checkbox role / disabled state is meaningful.
|
||||
|
||||
// i18n: identity translator — labels stay as their English keys for queries.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
// Mutation hook: a controllable mutateAsync plus a togglable isPending.
|
||||
const mutateAsync = vi.fn();
|
||||
let isPending = false;
|
||||
vi.mock("@/features/space/queries/space-query.ts", () => ({
|
||||
useUpdateSpaceMutation: () => ({
|
||||
mutateAsync,
|
||||
get isPending() {
|
||||
return isPending;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// jsdom lacks matchMedia, which MantineProvider's color-scheme hook needs.
|
||||
beforeAll(() => {
|
||||
if (!window.matchMedia) {
|
||||
window.matchMedia = (query: string) =>
|
||||
({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}) as unknown as MediaQueryList;
|
||||
}
|
||||
});
|
||||
|
||||
import { EditSpaceForm } from "./edit-space-form";
|
||||
import type { ISpace } from "@/features/space/types/space.types.ts";
|
||||
|
||||
function makeSpace(overrides: Partial<ISpace> = {}): ISpace {
|
||||
return {
|
||||
id: "space-1",
|
||||
name: "Engineering",
|
||||
description: "",
|
||||
slug: "eng",
|
||||
hostname: "host",
|
||||
creatorId: "u1",
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-01-01"),
|
||||
...overrides,
|
||||
} as ISpace;
|
||||
}
|
||||
|
||||
function renderForm(props: { space: ISpace; readOnly?: boolean }) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<EditSpaceForm space={props.space} readOnly={props.readOnly} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// The form now renders TWO switches (git-sync enable + auto-merge-conflicts) in
|
||||
// that DOM order. Mantine renders each as an <input type="checkbox"
|
||||
// role="switch"> but does NOT expose its label as the accessible name, so we
|
||||
// disambiguate by DOM order (index 0 = enable, 1 = auto-merge) and assert the
|
||||
// human-readable label text is present alongside.
|
||||
function getToggle(): HTMLInputElement {
|
||||
screen.getByText("Enable Git sync");
|
||||
return screen.getAllByRole("switch")[0] as HTMLInputElement;
|
||||
}
|
||||
|
||||
function getAutoMergeToggle(): HTMLInputElement {
|
||||
screen.getByText("Auto-merge conflicts on push");
|
||||
return screen.getAllByRole("switch")[1] as HTMLInputElement;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mutateAsync.mockReset();
|
||||
isPending = false;
|
||||
});
|
||||
|
||||
describe("EditSpaceForm git-sync toggle", () => {
|
||||
// Test 3: initial checked state derives from settings.gitSync.enabled ?? false.
|
||||
it("derives initial checked state from space.settings.gitSync.enabled (true -> checked)", () => {
|
||||
renderForm({
|
||||
space: makeSpace({ settings: { gitSync: { enabled: true } } }),
|
||||
});
|
||||
expect(getToggle().checked).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults to unchecked when gitSync settings are missing", () => {
|
||||
renderForm({ space: makeSpace() });
|
||||
expect(getToggle().checked).toBe(false);
|
||||
});
|
||||
|
||||
// Test 4: toggling fires the mutation with { spaceId, gitSyncEnabled } and
|
||||
// optimistically flips the switch.
|
||||
it("fires the mutation with the correct payload and optimistically flips on", async () => {
|
||||
mutateAsync.mockResolvedValue(undefined);
|
||||
renderForm({ space: makeSpace() });
|
||||
|
||||
const toggle = getToggle();
|
||||
expect(toggle.checked).toBe(false);
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
// Optimistic update: the switch reflects the new state immediately.
|
||||
expect(toggle.checked).toBe(true);
|
||||
|
||||
expect(mutateAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
spaceId: "space-1",
|
||||
gitSyncEnabled: true,
|
||||
});
|
||||
|
||||
// Resolution leaves the toggle on.
|
||||
await waitFor(() => expect(toggle.checked).toBe(true));
|
||||
});
|
||||
|
||||
// Test 5: rollback on mutation error — the most valuable test.
|
||||
it("rolls back the toggle to its prior state when the mutation rejects", async () => {
|
||||
mutateAsync.mockRejectedValue(new Error("network"));
|
||||
renderForm({
|
||||
space: makeSpace({ settings: { gitSync: { enabled: false } } }),
|
||||
});
|
||||
|
||||
const toggle = getToggle();
|
||||
expect(toggle.checked).toBe(false);
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
// Optimistically flips on before the rejection lands.
|
||||
expect(toggle.checked).toBe(true);
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
spaceId: "space-1",
|
||||
gitSyncEnabled: true,
|
||||
});
|
||||
|
||||
// After the rejected promise settles, the component reverts to OFF so the
|
||||
// user is not misled into believing sync is enabled.
|
||||
await waitFor(() => expect(toggle.checked).toBe(false));
|
||||
});
|
||||
|
||||
// Test 6: disabled when readOnly and when the mutation is pending.
|
||||
it("disables the toggle when readOnly", () => {
|
||||
renderForm({ space: makeSpace(), readOnly: true });
|
||||
expect(getToggle().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("disables the toggle while the mutation is pending", () => {
|
||||
isPending = true;
|
||||
renderForm({ space: makeSpace() });
|
||||
expect(getToggle().disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EditSpaceForm auto-merge-conflicts toggle", () => {
|
||||
it("derives initial checked state from space.settings.gitSync.autoMergeConflicts (true -> checked)", () => {
|
||||
renderForm({
|
||||
space: makeSpace({
|
||||
settings: { gitSync: { autoMergeConflicts: true } },
|
||||
}),
|
||||
});
|
||||
expect(getAutoMergeToggle().checked).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults to unchecked when autoMergeConflicts is missing (SAFE default)", () => {
|
||||
renderForm({ space: makeSpace() });
|
||||
expect(getAutoMergeToggle().checked).toBe(false);
|
||||
});
|
||||
|
||||
it("fires the mutation with { spaceId, autoMergeConflicts } and optimistically flips on", async () => {
|
||||
mutateAsync.mockResolvedValue(undefined);
|
||||
renderForm({ space: makeSpace() });
|
||||
|
||||
const toggle = getAutoMergeToggle();
|
||||
expect(toggle.checked).toBe(false);
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
// Optimistic update.
|
||||
expect(toggle.checked).toBe(true);
|
||||
expect(mutateAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
spaceId: "space-1",
|
||||
autoMergeConflicts: true,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(toggle.checked).toBe(true));
|
||||
});
|
||||
|
||||
it("rolls back to its prior state when the mutation rejects", async () => {
|
||||
mutateAsync.mockRejectedValue(new Error("network"));
|
||||
renderForm({
|
||||
space: makeSpace({
|
||||
settings: { gitSync: { autoMergeConflicts: false } },
|
||||
}),
|
||||
});
|
||||
|
||||
const toggle = getAutoMergeToggle();
|
||||
expect(toggle.checked).toBe(false);
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(toggle.checked).toBe(true);
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
spaceId: "space-1",
|
||||
autoMergeConflicts: true,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(toggle.checked).toBe(false));
|
||||
});
|
||||
|
||||
it("disables the toggle when readOnly", () => {
|
||||
renderForm({ space: makeSpace(), readOnly: true });
|
||||
expect(getAutoMergeToggle().disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,5 @@
|
||||
import {
|
||||
Group,
|
||||
Box,
|
||||
Button,
|
||||
TextInput,
|
||||
Stack,
|
||||
Textarea,
|
||||
Divider,
|
||||
Switch,
|
||||
} from "@mantine/core";
|
||||
import React, { useState } from "react";
|
||||
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
|
||||
import React from "react";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { z } from "zod/v4";
|
||||
@@ -38,44 +29,6 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const updateSpaceMutation = useUpdateSpaceMutation();
|
||||
|
||||
const [gitSyncEnabled, setGitSyncEnabled] = useState<boolean>(
|
||||
space?.settings?.gitSync?.enabled ?? false,
|
||||
);
|
||||
|
||||
const [autoMergeConflicts, setAutoMergeConflicts] = useState<boolean>(
|
||||
space?.settings?.gitSync?.autoMergeConflicts ?? false,
|
||||
);
|
||||
|
||||
const handleGitSyncToggle = async (value: boolean) => {
|
||||
const previous = gitSyncEnabled;
|
||||
setGitSyncEnabled(value); // optimistic update
|
||||
try {
|
||||
await updateSpaceMutation.mutateAsync({
|
||||
spaceId: space.id,
|
||||
gitSyncEnabled: value,
|
||||
});
|
||||
} catch (err) {
|
||||
setGitSyncEnabled(previous); // revert on failure
|
||||
// The mutation surfaces a toast via onError; still log the raw error so it
|
||||
// is not silently swallowed (AGENTS.md).
|
||||
console.error("Failed to toggle git-sync for space", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoMergeConflictsToggle = async (value: boolean) => {
|
||||
const previous = autoMergeConflicts;
|
||||
setAutoMergeConflicts(value); // optimistic update
|
||||
try {
|
||||
await updateSpaceMutation.mutateAsync({
|
||||
spaceId: space.id,
|
||||
autoMergeConflicts: value,
|
||||
});
|
||||
} catch (err) {
|
||||
setAutoMergeConflicts(previous); // revert on failure
|
||||
console.error("Failed to toggle git-sync auto-merge-conflicts", err);
|
||||
}
|
||||
};
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
@@ -151,31 +104,6 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
|
||||
</Group>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<Switch
|
||||
label={t("Enable Git sync")}
|
||||
description={t("Sync this space's pages to a Git repository.")}
|
||||
checked={gitSyncEnabled}
|
||||
disabled={readOnly || updateSpaceMutation.isPending}
|
||||
onChange={(event) =>
|
||||
handleGitSyncToggle(event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
mt="md"
|
||||
label={t("Auto-merge conflicts on push")}
|
||||
description={t(
|
||||
"When off (recommended), a page whose content still has unresolved Git conflict markers is skipped on push until you resolve the conflict in Git. When on, the markers are stripped and both sides' content is pushed.",
|
||||
)}
|
||||
checked={autoMergeConflicts}
|
||||
disabled={readOnly || updateSpaceMutation.isPending}
|
||||
onChange={(event) =>
|
||||
handleAutoMergeConflictsToggle(event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconFileExport,
|
||||
IconHourglass,
|
||||
IconPlus,
|
||||
IconSettings,
|
||||
IconStar,
|
||||
@@ -71,6 +72,10 @@ export function SpaceSidebar() {
|
||||
handleCreate(null);
|
||||
}
|
||||
|
||||
function handleCreateTemporaryPage() {
|
||||
handleCreate(null, { temporary: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.navbar}>
|
||||
@@ -111,16 +116,39 @@ export function SpaceSidebar() {
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Page,
|
||||
) && (
|
||||
<Tooltip label={t("Create page")} withArrow position="right">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={18}
|
||||
onClick={handleCreatePage}
|
||||
aria-label={t("Create page")}
|
||||
<>
|
||||
<Tooltip
|
||||
label={t("Create page")}
|
||||
withArrow
|
||||
position="right"
|
||||
>
|
||||
<IconPlus />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={18}
|
||||
onClick={handleCreatePage}
|
||||
aria-label={t("Create page")}
|
||||
>
|
||||
<IconPlus />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Standalone second button: a "temporary note" auto-moves to
|
||||
trash after the workspace lifetime unless made permanent. */}
|
||||
<Tooltip
|
||||
label={t("New temporary note")}
|
||||
withArrow
|
||||
position="right"
|
||||
>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={18}
|
||||
onClick={handleCreateTemporaryPage}
|
||||
aria-label={t("New temporary note")}
|
||||
>
|
||||
<IconHourglass />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -13,15 +13,9 @@ export interface ISpaceCommentsSettings {
|
||||
allowViewerComments?: boolean;
|
||||
}
|
||||
|
||||
export interface ISpaceGitSyncSettings {
|
||||
enabled?: boolean;
|
||||
autoMergeConflicts?: boolean;
|
||||
}
|
||||
|
||||
export interface ISpaceSettings {
|
||||
sharing?: ISpaceSharingSettings;
|
||||
comments?: ISpaceCommentsSettings;
|
||||
gitSync?: ISpaceGitSyncSettings;
|
||||
}
|
||||
|
||||
export interface ISpace {
|
||||
@@ -41,8 +35,6 @@ export interface ISpace {
|
||||
// for updates
|
||||
disablePublicSharing?: boolean;
|
||||
allowViewerComments?: boolean;
|
||||
gitSyncEnabled?: boolean;
|
||||
autoMergeConflicts?: boolean;
|
||||
}
|
||||
|
||||
interface IMembership {
|
||||
|
||||
@@ -183,6 +183,34 @@ describe("applyMoveTreeNode", () => {
|
||||
expect(moved?.hasChildren).toBe(true);
|
||||
expect(moved?.position).toBe("a4");
|
||||
});
|
||||
|
||||
it("does NOT drop a subtree on a cyclic/out-of-order move (parent inside source) (#206 ui-state-races-1)", () => {
|
||||
// Locally `b` is still nested inside `a` (an earlier "a under b" echo hasn't
|
||||
// applied yet). An out-of-order "move a under b" event now arrives — b is a
|
||||
// descendant of a, so re-parenting would make placeByPosition remove a (and
|
||||
// its whole subtree, incl. b) and fail to re-insert. Before the fix BOTH a
|
||||
// and b silently vanished; now the reducer leaves the tree untouched.
|
||||
const tree: SpaceTreeNode[] = [
|
||||
node("a", {
|
||||
position: "a0",
|
||||
hasChildren: true,
|
||||
children: [node("b", { position: "a1", parentPageId: "a" })],
|
||||
}),
|
||||
];
|
||||
const next = applyMoveTreeNode(tree, {
|
||||
id: "a",
|
||||
parentId: "b",
|
||||
oldParentId: null,
|
||||
index: 0,
|
||||
position: "a4",
|
||||
pageData: {},
|
||||
});
|
||||
// No silent data loss: both nodes survive.
|
||||
expect(treeModel.find(next, "a")).not.toBeNull();
|
||||
expect(treeModel.find(next, "b")).not.toBeNull();
|
||||
// The cyclic move is refused as a no-op (same reference) pending reconcile.
|
||||
expect(next).toBe(tree);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyDeleteTreeNode", () => {
|
||||
|
||||
@@ -76,6 +76,19 @@ export function applyMoveTreeNode(
|
||||
const oldParentId = (sourceBefore as SpaceTreeNode).parentPageId ?? null;
|
||||
const newParentId = payload.parentId as string | null;
|
||||
|
||||
// Cyclic / out-of-order move guard (#206 ui-state-races-1): if the
|
||||
// authoritative new parent is currently INSIDE the moved node's own subtree on
|
||||
// this client (e.g. server moved X under Y then Y under X and the events
|
||||
// arrived such that Y is still nested in X here), re-parenting is impossible to
|
||||
// represent locally. `placeByPosition` returns `prev` for this, but the
|
||||
// `placed === prev` fallback below would then `remove` the source — dropping
|
||||
// the node AND every descendant (incl. the would-be parent) silently. Leave the
|
||||
// tree untouched instead; a later corrective event or a reconnect refetch
|
||||
// reconciles it. Never delete a subtree we cannot safely re-place.
|
||||
if (newParentId && treeModel.isDescendant(prev, payload.id, newParentId)) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
// Place the node by its fractional `position` among the new siblings — NOT by
|
||||
// the sender's absolute `index` (the sender computed that against its own
|
||||
// loaded set, which differs from this receiver's). Using the position keeps
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mcpTestButtonView } from "./ai-mcp-server-test-view";
|
||||
|
||||
/**
|
||||
* Pure-helper tests for the inline "Test" button presentation. Covers the four
|
||||
* states (idle / loading is handled by the component's `isPending`, so here:
|
||||
* idle / ok-with-tools / ok-without-tools / failed) and the tooltip text
|
||||
* branches that are easiest to break silently.
|
||||
*/
|
||||
// Identity-ish translator that echoes the key and interpolates {{n}} so the
|
||||
// label/tooltip branches are observable without the real i18n bundle.
|
||||
const t = (key: string, options?: Record<string, unknown>): string =>
|
||||
options && "n" in options
|
||||
? key.replace("{{n}}", String((options as { n: unknown }).n))
|
||||
: key;
|
||||
|
||||
describe("mcpTestButtonView", () => {
|
||||
it("idle when there is no result", () => {
|
||||
expect(mcpTestButtonView(undefined, t)).toEqual({
|
||||
state: "idle",
|
||||
color: undefined,
|
||||
variant: "default",
|
||||
label: "Test",
|
||||
tooltip: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("ok with tools lists them in the tooltip", () => {
|
||||
expect(mcpTestButtonView({ ok: true, tools: ["a", "b"] }, t)).toEqual({
|
||||
state: "ok",
|
||||
color: "green",
|
||||
variant: "light",
|
||||
label: "OK · 2",
|
||||
tooltip: "a, b",
|
||||
});
|
||||
});
|
||||
|
||||
it('ok with zero tools shows "No tools available"', () => {
|
||||
expect(mcpTestButtonView({ ok: true, tools: [] }, t)).toEqual({
|
||||
state: "ok",
|
||||
color: "green",
|
||||
variant: "light",
|
||||
label: "OK · 0",
|
||||
tooltip: "No tools available",
|
||||
});
|
||||
});
|
||||
|
||||
it("failed surfaces the error text in the tooltip", () => {
|
||||
expect(
|
||||
mcpTestButtonView({ ok: false, error: "402: nope" }, t),
|
||||
).toEqual({
|
||||
state: "failed",
|
||||
color: "red",
|
||||
variant: "light",
|
||||
label: "Failed",
|
||||
tooltip: "402: nope",
|
||||
});
|
||||
});
|
||||
|
||||
it("failed when the request itself rejects (no result payload)", () => {
|
||||
// 401/403/500/network: there is no { ok } body, only a thrown error. The
|
||||
// row must still show a red "Failed" rather than reverting to idle "Test".
|
||||
expect(
|
||||
mcpTestButtonView(undefined, t, {
|
||||
response: { data: { message: "Unauthorized" } },
|
||||
}),
|
||||
).toEqual({
|
||||
state: "failed",
|
||||
color: "red",
|
||||
variant: "light",
|
||||
label: "Failed",
|
||||
tooltip: "Unauthorized",
|
||||
});
|
||||
});
|
||||
|
||||
it("reject without a server message falls back to the generic label", () => {
|
||||
// A bare network error (no response body) still surfaces as failed, using
|
||||
// the i18n fallback for the tooltip.
|
||||
expect(mcpTestButtonView(undefined, t, new Error("network down"))).toEqual({
|
||||
state: "failed",
|
||||
color: "red",
|
||||
variant: "light",
|
||||
label: "Failed",
|
||||
tooltip: "Failed to update data",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { IAiMcpServerTestResult } from "@/features/workspace/services/ai-mcp-server-service.ts";
|
||||
|
||||
/** Minimal translator shape (i18next `t`): key + optional interpolation. */
|
||||
type Translate = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
/** Subset of an axios-style rejection we read for the reject tooltip. */
|
||||
type McpTestRequestError = {
|
||||
response?: { data?: { message?: string } };
|
||||
};
|
||||
|
||||
/**
|
||||
* Best-effort extraction of a server-sent message from a rejected test request
|
||||
* (axios stores it at `error.response.data.message`). Returns undefined for a
|
||||
* bare/network error so the caller can fall back to a generic label.
|
||||
*/
|
||||
function readRequestErrorMessage(error: unknown): string | undefined {
|
||||
if (error && typeof error === "object" && "response" in error) {
|
||||
return (error as McpTestRequestError).response?.data?.message;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Presentation for the inline "Test" button, derived from the current test
|
||||
* result tristate (no result yet / ok / failed). Color is never the only signal
|
||||
* — the label and icon change too (a11y / colorblind-friendly). Kept as a single
|
||||
* pure derivation (rather than two parallel if/else chains) so the button and
|
||||
* tooltip can never drift apart, and so the text branches are unit-testable
|
||||
* without rendering the row.
|
||||
*/
|
||||
export interface McpTestButtonView {
|
||||
/** Tristate; the component maps this to the leftSection icon. */
|
||||
state: "idle" | "ok" | "failed";
|
||||
/** Mantine Button color; undefined = theme default (idle). */
|
||||
color?: string;
|
||||
/** Mantine Button variant. */
|
||||
variant: string;
|
||||
/** Translated button label. */
|
||||
label: string;
|
||||
/** Translated tooltip text; "" while there is no result (tooltip disabled). */
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
export function mcpTestButtonView(
|
||||
result: IAiMcpServerTestResult | undefined,
|
||||
t: Translate,
|
||||
error?: unknown,
|
||||
): McpTestButtonView {
|
||||
if (result?.ok) {
|
||||
return {
|
||||
state: "ok",
|
||||
color: "green",
|
||||
variant: "light",
|
||||
label: t("OK · {{n}}", { n: result.tools.length }),
|
||||
tooltip:
|
||||
result.tools.length > 0
|
||||
? result.tools.join(", ")
|
||||
: t("No tools available"),
|
||||
};
|
||||
}
|
||||
if (result && result.ok === false) {
|
||||
return {
|
||||
state: "failed",
|
||||
color: "red",
|
||||
variant: "light",
|
||||
label: t("Failed"),
|
||||
tooltip: result.error,
|
||||
};
|
||||
}
|
||||
if (error) {
|
||||
// The test request itself rejected (401/403/500/network) — there is no
|
||||
// `{ ok }` payload, so without this branch the row would silently revert to
|
||||
// the idle "Test" instead of reporting the failure. Tooltip prefers the
|
||||
// server-sent message, else the generic i18n fallback.
|
||||
return {
|
||||
state: "failed",
|
||||
color: "red",
|
||||
variant: "light",
|
||||
label: t("Failed"),
|
||||
tooltip: readRequestErrorMessage(error) ?? t("Failed to update data"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: "idle",
|
||||
color: undefined,
|
||||
variant: "default",
|
||||
label: t("Test"),
|
||||
tooltip: "",
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
@@ -10,18 +10,28 @@ import {
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import {
|
||||
IconCheck,
|
||||
IconPencil,
|
||||
IconPlugConnected,
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import {
|
||||
useAiMcpServersQuery,
|
||||
useDeleteAiMcpServerMutation,
|
||||
useTestAiMcpServerMutation,
|
||||
useUpdateAiMcpServerMutation,
|
||||
} from "@/features/workspace/queries/ai-mcp-server-query.ts";
|
||||
import { IAiMcpServer } from "@/features/workspace/services/ai-mcp-server-service.ts";
|
||||
import { mcpTestButtonView } from "@/features/workspace/components/settings/components/ai-mcp-server-test-view.ts";
|
||||
import AiMcpServerForm from "./ai-mcp-server-form.tsx";
|
||||
|
||||
/**
|
||||
@@ -112,55 +122,15 @@ export default function AiMcpServers() {
|
||||
|
||||
<Stack gap="xs" mt="sm">
|
||||
{servers?.map((server) => (
|
||||
<Group key={server.id} justify="space-between" wrap="nowrap">
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} truncate>
|
||||
{server.name}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light">
|
||||
{server.transport.toUpperCase()}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
truncate
|
||||
style={{ fontFamily: "ui-monospace, Menlo, monospace" }}
|
||||
>
|
||||
{server.url}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={server.enabled}
|
||||
aria-label={t("Enabled")}
|
||||
onChange={(event) =>
|
||||
updateMutation.mutate({
|
||||
id: server.id,
|
||||
enabled: event.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
aria-label={t("Edit")}
|
||||
onClick={() => openEdit(server)}
|
||||
>
|
||||
<IconPencil size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={t("Delete")}
|
||||
onClick={() => confirmDelete(server)}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
<AiMcpServerRow
|
||||
key={server.id}
|
||||
server={server}
|
||||
onEdit={openEdit}
|
||||
onDelete={confirmDelete}
|
||||
onToggleEnabled={(enabled) =>
|
||||
updateMutation.mutate({ id: server.id, enabled })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -180,3 +150,127 @@ export default function AiMcpServers() {
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
interface AiMcpServerRowProps {
|
||||
server: IAiMcpServer;
|
||||
onEdit: (server: IAiMcpServer) => void;
|
||||
onDelete: (server: IAiMcpServer) => void;
|
||||
onToggleEnabled: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single external MCP server row: name/badge/url on the left and the
|
||||
* Test / Switch / Edit / Delete controls on the right. Each row owns its own
|
||||
* `useTestAiMcpServerMutation()` so the inline Test result and loading state are
|
||||
* independent per row (a shared mutation would make `isPending` global and make
|
||||
* every row flicker).
|
||||
*/
|
||||
function AiMcpServerRow({
|
||||
server,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleEnabled,
|
||||
}: AiMcpServerRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const testMutation = useTestAiMcpServerMutation();
|
||||
const result = testMutation.data;
|
||||
|
||||
// The row is keyed by `server.id`, so editing the connection-relevant fields
|
||||
// (url/transport/headers) does NOT remount it — an old success/failure result
|
||||
// would otherwise stick. Clear the result when those fields change.
|
||||
useEffect(() => {
|
||||
testMutation.reset();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [server.url, server.transport, server.hasHeaders]);
|
||||
|
||||
// Single derivation of the button/tooltip presentation from the test tristate
|
||||
// (idle / ok / failed), so the two can never drift apart. Tooltip is "" while
|
||||
// there is no result; the icon is mapped from `view.state` below. When the
|
||||
// request itself rejects (401/403/500/network) there is no `data` payload, so
|
||||
// we feed the mutation error in too — otherwise the row would silently revert
|
||||
// to "Test" instead of showing a red "Failed".
|
||||
const view = mcpTestButtonView(
|
||||
result,
|
||||
t,
|
||||
testMutation.isError ? testMutation.error : undefined,
|
||||
);
|
||||
const tooltipLabel = view.tooltip;
|
||||
const buttonColor = view.color;
|
||||
const buttonVariant = view.variant;
|
||||
const buttonLabel = view.label;
|
||||
const buttonIcon =
|
||||
view.state === "ok" ? (
|
||||
<IconCheck size={16} />
|
||||
) : view.state === "failed" ? (
|
||||
<IconX size={16} />
|
||||
) : (
|
||||
<IconPlugConnected size={16} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} truncate>
|
||||
{server.name}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light">
|
||||
{server.transport.toUpperCase()}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
truncate
|
||||
style={{ fontFamily: "ui-monospace, Menlo, monospace" }}
|
||||
>
|
||||
{server.url}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{/* Always clickable: testing a disabled server before enabling it is useful. */}
|
||||
<Tooltip
|
||||
label={tooltipLabel}
|
||||
disabled={view.state === "idle"}
|
||||
multiline
|
||||
maw={320}
|
||||
withinPortal
|
||||
>
|
||||
<Button
|
||||
size="xs"
|
||||
miw={88}
|
||||
color={buttonColor}
|
||||
variant={buttonVariant}
|
||||
leftSection={testMutation.isPending ? undefined : buttonIcon}
|
||||
loading={testMutation.isPending}
|
||||
onClick={() => testMutation.mutate(server.id)}
|
||||
>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={server.enabled}
|
||||
aria-label={t("Enabled")}
|
||||
onChange={(event) => onToggleEnabled(event.currentTarget.checked)}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
aria-label={t("Edit")}
|
||||
onClick={() => onEdit(server)}
|
||||
>
|
||||
<IconPencil size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={t("Delete")}
|
||||
onClick={() => onDelete(server)}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Select,
|
||||
@@ -83,6 +84,9 @@ const STT_LANGUAGE_OPTIONS: { value: string; label: string }[] = [
|
||||
// (empty means "leave unchanged" unless explicitly cleared).
|
||||
const formSchema = z.object({
|
||||
chatModel: z.string(),
|
||||
// Max context window in tokens shown in the chat header badge. A number, or ""
|
||||
// when the NumberInput is empty (no limit).
|
||||
chatContextWindow: z.union([z.number(), z.literal("")]),
|
||||
// Chat provider implementation (reasoning surfacing). Default openai-compatible.
|
||||
chatApiStyle: z.enum(["openai-compatible", "openai"]),
|
||||
// Cheap model id for the anonymous public-share assistant; empty = use chatModel.
|
||||
@@ -311,6 +315,7 @@ export default function AiProviderSettings() {
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
chatModel: "",
|
||||
chatContextWindow: "",
|
||||
chatApiStyle: "openai-compatible" as ChatApiStyle,
|
||||
publicShareChatModel: "",
|
||||
publicShareAssistantRoleId: "",
|
||||
@@ -334,6 +339,7 @@ export default function AiProviderSettings() {
|
||||
if (!settings) return;
|
||||
form.setValues({
|
||||
chatModel: settings.chatModel ?? "",
|
||||
chatContextWindow: settings.chatContextWindow ?? "",
|
||||
chatApiStyle: settings.chatApiStyle ?? "openai-compatible",
|
||||
publicShareChatModel: settings.publicShareChatModel ?? "",
|
||||
publicShareAssistantRoleId: settings.publicShareAssistantRoleId ?? "",
|
||||
@@ -364,6 +370,12 @@ export default function AiProviderSettings() {
|
||||
// Everything is OpenAI-compatible.
|
||||
driver: "openai",
|
||||
chatModel: values.chatModel,
|
||||
// Max context window for the chat header badge; empty NumberInput ("") →
|
||||
// 0, which clears the limit server-side (no denominator shown).
|
||||
chatContextWindow:
|
||||
typeof values.chatContextWindow === "number"
|
||||
? values.chatContextWindow
|
||||
: 0,
|
||||
chatApiStyle: values.chatApiStyle,
|
||||
// Cheap model id for the anonymous public-share assistant; empty falls
|
||||
// back to chatModel server-side.
|
||||
@@ -767,6 +779,18 @@ export default function AiProviderSettings() {
|
||||
{t("Resolves to {{url}}", { url: chatResolved })}
|
||||
</Text>
|
||||
|
||||
<NumberInput
|
||||
mt="sm"
|
||||
label={t("Context window (tokens)")}
|
||||
description={t(
|
||||
"Shown as used / total in the chat header. Leave empty to hide the limit.",
|
||||
)}
|
||||
min={0}
|
||||
allowDecimal={false}
|
||||
disabled={isLoading}
|
||||
{...form.getInputProps("chatContextWindow")}
|
||||
/>
|
||||
|
||||
<Select
|
||||
mt="sm"
|
||||
label={t("Protocol")}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
||||
|
||||
// Mirrors DEFAULT_TEMPORARY_NOTE_HOURS on the server. Shown when the workspace
|
||||
// has no explicit value configured yet.
|
||||
const DEFAULT_TEMPORARY_NOTE_HOURS = 24;
|
||||
|
||||
/**
|
||||
* Workspace-level editor for the temporary-note lifetime, in HOURS. The deadline
|
||||
* is frozen per-note at creation, so changing this only affects notes created
|
||||
* afterwards. `temporaryNoteHours` is a top-level workspace column (like
|
||||
* trashRetentionDays), not a nested setting.
|
||||
*/
|
||||
export default function TemporaryNoteSettings() {
|
||||
const { t } = useTranslation();
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const { isAdmin } = useUserRole();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [value, setValue] = useState<number>(
|
||||
workspace?.temporaryNoteHours ?? DEFAULT_TEMPORARY_NOTE_HOURS,
|
||||
);
|
||||
|
||||
async function handleSave() {
|
||||
if (!value || value < 1) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const updated = await updateWorkspace({
|
||||
temporaryNoteHours: value,
|
||||
} as Partial<IWorkspace>);
|
||||
setWorkspace({ ...updated, temporaryNoteHours: value });
|
||||
notifications.show({ message: t("Updated successfully") });
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message:
|
||||
(err as any)?.response?.data?.message ?? t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack mt="sm">
|
||||
<Text fw={700} size="lg">
|
||||
{t("Temporary notes")}
|
||||
</Text>
|
||||
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{t(
|
||||
"A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.",
|
||||
)}
|
||||
</Text>
|
||||
<NumberInput
|
||||
label={t("Temporary note lifetime (hours)")}
|
||||
min={1}
|
||||
allowDecimal={false}
|
||||
value={value}
|
||||
onChange={(v) => setValue(typeof v === "number" ? v : Number(v))}
|
||||
disabled={!isAdmin || isLoading}
|
||||
w={220}
|
||||
/>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button onClick={handleSave} loading={isLoading} disabled={!isAdmin}>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export type ChatApiStyle = "openai-compatible" | "openai";
|
||||
export interface IAiSettings {
|
||||
driver?: AiDriver;
|
||||
chatModel?: string;
|
||||
// Max context window in tokens shown in the chat header badge; 0/unset = no limit.
|
||||
chatContextWindow?: number;
|
||||
chatApiStyle?: ChatApiStyle;
|
||||
// Cheap model id for the anonymous public-share assistant; empty = chatModel.
|
||||
publicShareChatModel?: string;
|
||||
@@ -56,6 +58,8 @@ export interface IAiSettings {
|
||||
export interface IAiSettingsUpdate {
|
||||
driver?: AiDriver;
|
||||
chatModel?: string;
|
||||
// Max context window in tokens for the chat header badge; 0 = clear the limit.
|
||||
chatContextWindow?: number;
|
||||
chatApiStyle?: ChatApiStyle;
|
||||
publicShareChatModel?: string;
|
||||
// Agent-role id whose persona the public-share assistant adopts; empty =
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface IWorkspace {
|
||||
aiDictationStreaming?: boolean;
|
||||
aiPublicShareAssistant?: boolean;
|
||||
trashRetentionDays?: number;
|
||||
// Default lifetime (HOURS) for new temporary notes; frozen per-note at creation.
|
||||
temporaryNoteHours?: number;
|
||||
restrictApiToAdmins?: boolean;
|
||||
allowMemberTemplates?: boolean;
|
||||
isScimEnabled?: boolean;
|
||||
|
||||
@@ -3,6 +3,7 @@ import WorkspaceNameForm from "@/features/workspace/components/settings/componen
|
||||
import WorkspaceIcon from "@/features/workspace/components/settings/components/workspace-icon.tsx";
|
||||
import HtmlEmbedSettings from "@/features/workspace/components/settings/components/html-embed-settings.tsx";
|
||||
import TrackerSettings from "@/features/workspace/components/settings/components/tracker-settings.tsx";
|
||||
import TemporaryNoteSettings from "@/features/workspace/components/settings/components/temporary-note-settings.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getAppName } from "@/lib/config.ts";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
@@ -19,6 +20,7 @@ export default function WorkspaceSettings() {
|
||||
<WorkspaceNameForm />
|
||||
<HtmlEmbedSettings />
|
||||
<TrackerSettings />
|
||||
<TemporaryNoteSettings />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.93.0",
|
||||
"version": "0.94.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -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/git-sync build && pnpm --filter @docmost/mcp build",
|
||||
"pretest": "pnpm --filter @docmost/editor-ext build",
|
||||
"test": "jest",
|
||||
"test:int": "jest --config test/jest-integration.json",
|
||||
"test:watch": "jest --watch",
|
||||
@@ -41,7 +41,6 @@
|
||||
"@aws-sdk/s3-request-presigner": "3.1050.0",
|
||||
"@azure/storage-blob": "12.31.0",
|
||||
"@clickhouse/client": "^1.18.2",
|
||||
"@docmost/git-sync": "workspace:*",
|
||||
"@docmost/mcp": "workspace:*",
|
||||
"@docmost/pdf-inspector": "1.9.6",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
@@ -189,12 +188,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"^.+\\.(t|j)sx?$": [
|
||||
"ts-jest",
|
||||
{
|
||||
"isolatedModules": true
|
||||
}
|
||||
]
|
||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0)(@|/))"
|
||||
@@ -204,17 +198,11 @@
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node",
|
||||
"setupFiles": [
|
||||
"<rootDir>/../test/jest.setup.ts"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
||||
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
||||
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
|
||||
"^src/(.*)$": "<rootDir>/$1",
|
||||
"^@docmost/git-sync$": "<rootDir>/../../../packages/git-sync/src/index.ts",
|
||||
"^@docmost/git-sync/(.*)$": "<rootDir>/../../../packages/git-sync/src/$1",
|
||||
"^(\\.{1,2}/.*)\\.js$": "$1"
|
||||
"^src/(.*)$": "<rootDir>/$1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ import { ClsModule } from 'nestjs-cls';
|
||||
import { NoopAuditModule } from './integrations/audit/audit.module';
|
||||
import { ThrottleModule } from './integrations/throttle/throttle.module';
|
||||
import { McpModule } from './integrations/mcp/mcp.module';
|
||||
import { GitSyncModule } from './integrations/git-sync/git-sync.module';
|
||||
import { AiModule } from './integrations/ai/ai.module';
|
||||
import { AiChatModule } from './core/ai-chat/ai-chat.module';
|
||||
|
||||
@@ -90,7 +89,6 @@ try {
|
||||
TelemetryModule,
|
||||
ThrottleModule,
|
||||
McpModule,
|
||||
GitSyncModule,
|
||||
AiModule,
|
||||
AiChatModule,
|
||||
...enterpriseModules,
|
||||
|
||||
@@ -149,45 +149,6 @@ export class CollaborationGateway {
|
||||
return this.hocuspocus.openDirectConnection(documentName, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a git-originated body into a page, applying the merge on the instance
|
||||
* that OWNS the live Y.Doc so a connected editor CONVERGES on the change.
|
||||
*
|
||||
* git-sync must NOT use openDirectConnection directly for this: that opens the
|
||||
* document on whichever instance/process runs git-sync (the API/worker). When
|
||||
* an editor is connected to a DIFFERENT collab instance/process, that is a
|
||||
* SEPARATE, detached Y.Doc — the merge lands in the detached doc and the DB,
|
||||
* but the live editor never receives the Yjs update; its next debounced
|
||||
* autosave then overwrites the DB with its stale state and SILENTLY REVERTS
|
||||
* the git change (the data-loss bug). Routing through the custom-event channel
|
||||
* runs the merge on the owning instance's shared Document, whose update is
|
||||
* broadcast to every connection (handleUpdate), so the editor's CRDT converges
|
||||
* on the merged result.
|
||||
*
|
||||
* Without redis there is a single instance, so the write runs locally — which
|
||||
* is already the owning (and only) instance the editor is connected to.
|
||||
*/
|
||||
async writePageBody(
|
||||
documentName: string,
|
||||
payload: {
|
||||
prosemirrorJson: unknown;
|
||||
baseProsemirrorJson?: unknown;
|
||||
userId: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (this.redisSync) {
|
||||
await this.handleYjsEvent(
|
||||
'gitSyncWriteBody',
|
||||
documentName,
|
||||
payload as any,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await this.collabEventsService
|
||||
.getHandlers(this.hocuspocus)
|
||||
.gitSyncWriteBody(documentName, payload as any);
|
||||
}
|
||||
|
||||
/*
|
||||
*Can be used before calling openDirectConnection directly
|
||||
*/
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
// Exercises the REAL `gitSyncWriteBody` collab handler (the owner-routed body
|
||||
// write the data-loss fix introduces). The handler imports the editor graph via
|
||||
// collaboration.util / yjs.util (tiptapExtensions -> editor-ext -> react-dom,
|
||||
// unloadable under jest's node env, same coupling noted in
|
||||
// gitmost-datasource.service.spec.ts), so we stub those + the transformer. The
|
||||
// stubbed toYdoc builds paragraph blocks straight from the ProseMirror JSON so
|
||||
// we can assert convergence on real text.
|
||||
jest.mock('./collaboration.util', () => ({
|
||||
tiptapExtensions: [],
|
||||
getPageId: (name: string) => name.replace(/^page\./, ''),
|
||||
prosemirrorNodeToYElement: jest.fn(),
|
||||
}));
|
||||
jest.mock('./yjs.util', () => ({
|
||||
setYjsMark: jest.fn(),
|
||||
updateYjsMarkAttribute: jest.fn(),
|
||||
}));
|
||||
jest.mock('@hocuspocus/transformer', () => {
|
||||
const Yjs = require('yjs');
|
||||
return {
|
||||
TiptapTransformer: {
|
||||
toYdoc: (json: any) => {
|
||||
if (json?.__throw) throw new Error('boom: malformed doc');
|
||||
const d = new Yjs.Doc();
|
||||
const frag = d.getXmlFragment('default');
|
||||
const blocks = (json?.content ?? []).map((node: any) => {
|
||||
const el = new Yjs.XmlElement(node.type || 'paragraph');
|
||||
const text = (node.content ?? [])
|
||||
.map((t: any) => t.text ?? '')
|
||||
.join('');
|
||||
const t = new Yjs.XmlText();
|
||||
if (text) t.insert(0, text);
|
||||
el.insert(0, [t]);
|
||||
return el;
|
||||
});
|
||||
if (blocks.length) frag.insert(0, blocks);
|
||||
return d;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import * as Y from 'yjs';
|
||||
import { CollaborationHandler } from './collaboration.handler';
|
||||
|
||||
const pmDoc = (...paras: string[]) => ({
|
||||
type: 'doc',
|
||||
content: paras.map((text) => ({
|
||||
type: 'paragraph',
|
||||
content: text ? [{ type: 'text', text }] : [],
|
||||
})),
|
||||
});
|
||||
|
||||
const texts = (frag: Y.XmlFragment): string[] =>
|
||||
frag.toArray().map((el) =>
|
||||
(el as Y.XmlElement)
|
||||
.toArray()
|
||||
.map((c) => (c as Y.XmlText).toString())
|
||||
.join(''),
|
||||
);
|
||||
|
||||
// Build a fake Hocuspocus whose openDirectConnection yields a DirectConnection
|
||||
// over a REAL shared Document, with a connected "editor" doc that receives the
|
||||
// shared doc's updates (modelling Document.handleUpdate's broadcast on the
|
||||
// OWNING instance). Initial content carries live block ids; the editor starts
|
||||
// fully synced with the shared doc.
|
||||
function fakeHocuspocus(initial: { text: string; id: string }[]) {
|
||||
const shared = new Y.Doc();
|
||||
const frag = shared.getXmlFragment('default');
|
||||
shared.transact(() => {
|
||||
frag.insert(
|
||||
0,
|
||||
initial.map((s) => {
|
||||
const el = new Y.XmlElement('paragraph');
|
||||
el.setAttribute('id', s.id);
|
||||
const t = new Y.XmlText();
|
||||
if (s.text) t.insert(0, s.text);
|
||||
el.insert(0, [t]);
|
||||
return el;
|
||||
}),
|
||||
);
|
||||
});
|
||||
const editor = new Y.Doc();
|
||||
Y.applyUpdate(editor, Y.encodeStateAsUpdate(shared));
|
||||
// Broadcast relay: server-originated updates flow to the connected editor.
|
||||
shared.on('update', (u: Uint8Array, origin: any) => {
|
||||
if (origin !== 'editor') Y.applyUpdate(editor, u, 'server');
|
||||
});
|
||||
|
||||
const openDirectConnection = jest.fn(async () => ({
|
||||
// DirectConnection.transact runs the fn directly against the Document (no
|
||||
// wrapping Y transaction), exactly like @hocuspocus/server.
|
||||
transact: async (fn: (doc: Y.Doc) => void) => fn(shared),
|
||||
disconnect: jest.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
return { hocuspocus: { openDirectConnection } as any, shared, editor };
|
||||
}
|
||||
|
||||
describe('CollaborationHandler.gitSyncWriteBody (owner-routed body write)', () => {
|
||||
it('converges a connected editor on the git change (no silent revert)', async () => {
|
||||
const { hocuspocus, shared, editor } = fakeHocuspocus([
|
||||
{ text: 'alpha', id: 'p1' },
|
||||
{ text: 'beta', id: 'p2' },
|
||||
]);
|
||||
const handler = new CollaborationHandler();
|
||||
const handlers = handler.getHandlers(hocuspocus);
|
||||
|
||||
// git changed block 1 beta -> beta2; base is the pre-change content.
|
||||
await handlers.gitSyncWriteBody('page.x', {
|
||||
prosemirrorJson: pmDoc('alpha', 'beta2'),
|
||||
baseProsemirrorJson: pmDoc('alpha', 'beta'),
|
||||
userId: 'svc-user',
|
||||
});
|
||||
|
||||
// The shared (owning-instance) doc holds the merge...
|
||||
expect(texts(shared.getXmlFragment('default'))).toEqual(['alpha', 'beta2']);
|
||||
// ...and the connected editor CONVERGED via the broadcast (the bug would
|
||||
// leave it on 'beta' and revert the page on its next autosave).
|
||||
expect(texts(editor.getXmlFragment('default'))).toEqual(['alpha', 'beta2']);
|
||||
});
|
||||
|
||||
it('preserves a concurrent edit to a DIFFERENT block (3-way, finding #2)', async () => {
|
||||
const { hocuspocus, shared, editor } = fakeHocuspocus([
|
||||
{ text: 'alpha', id: 'p1' },
|
||||
{ text: 'beta', id: 'p2' },
|
||||
]);
|
||||
// The editor is actively editing block 0 while the push arrives.
|
||||
const eFrag = editor.getXmlFragment('default');
|
||||
editor.transact(
|
||||
() => (eFrag.get(0) as Y.XmlElement).get(0) instanceof Y.XmlText &&
|
||||
((eFrag.get(0) as Y.XmlElement).get(0) as Y.XmlText).insert(5, ' EDIT'),
|
||||
'editor',
|
||||
);
|
||||
Y.applyUpdate(shared, Y.encodeStateAsUpdate(editor), 'editor');
|
||||
|
||||
const handler = new CollaborationHandler();
|
||||
await handler.getHandlers(hocuspocus).gitSyncWriteBody('page.x', {
|
||||
prosemirrorJson: pmDoc('alpha', 'beta2'),
|
||||
baseProsemirrorJson: pmDoc('alpha', 'beta'),
|
||||
userId: 'svc-user',
|
||||
});
|
||||
|
||||
// Human's block-0 edit AND git's block-1 change both survive on the editor.
|
||||
expect(texts(editor.getXmlFragment('default'))).toEqual([
|
||||
'alpha EDIT',
|
||||
'beta2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('crash-safe: a transform failure never opens the connection or mutates the live doc', async () => {
|
||||
const { hocuspocus, shared } = fakeHocuspocus([{ text: 'alpha', id: 'p1' }]);
|
||||
const before = texts(shared.getXmlFragment('default'));
|
||||
const handler = new CollaborationHandler();
|
||||
|
||||
await expect(
|
||||
handler.getHandlers(hocuspocus).gitSyncWriteBody('page.x', {
|
||||
prosemirrorJson: { __throw: true } as any,
|
||||
userId: 'svc-user',
|
||||
}),
|
||||
).rejects.toThrow('boom');
|
||||
|
||||
// The incoming doc is built BEFORE opening the connection, so the throw
|
||||
// happens first: the live doc is untouched and no connection was opened.
|
||||
expect(hocuspocus.openDirectConnection).not.toHaveBeenCalled();
|
||||
expect(texts(shared.getXmlFragment('default'))).toEqual(before);
|
||||
});
|
||||
|
||||
it('falls back to a 2-way merge when no base is supplied', async () => {
|
||||
const { hocuspocus, shared, editor } = fakeHocuspocus([
|
||||
{ text: 'alpha', id: 'p1' },
|
||||
]);
|
||||
const handler = new CollaborationHandler();
|
||||
|
||||
await handler.getHandlers(hocuspocus).gitSyncWriteBody('page.x', {
|
||||
prosemirrorJson: pmDoc('alpha', 'gamma'),
|
||||
userId: 'svc-user',
|
||||
});
|
||||
|
||||
expect(texts(shared.getXmlFragment('default'))).toEqual(['alpha', 'gamma']);
|
||||
expect(texts(editor.getXmlFragment('default'))).toEqual(['alpha', 'gamma']);
|
||||
});
|
||||
});
|
||||
@@ -8,10 +8,6 @@ import {
|
||||
import { setYjsMark, updateYjsMarkAttribute, YjsSelection } from './yjs.util';
|
||||
import * as Y from 'yjs';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import {
|
||||
mergeXmlFragments,
|
||||
mergeXmlFragments3Way,
|
||||
} from '../integrations/git-sync/services/yjs-body-merge';
|
||||
|
||||
export type CollabEventHandlers = ReturnType<
|
||||
CollaborationHandler['getHandlers']
|
||||
@@ -116,69 +112,6 @@ export class CollaborationHandler {
|
||||
},
|
||||
);
|
||||
},
|
||||
/**
|
||||
* Git-sync body write, applied as a block-level MERGE into the LIVE doc on
|
||||
* the instance that OWNS it (routed here via the custom-event channel —
|
||||
* see CollaborationGateway.writePageBody). Running on the owning instance
|
||||
* is what makes a connected editor CONVERGE: the merge mutates the shared
|
||||
* Document, whose update is broadcast to every connection, so the editor's
|
||||
* CRDT applies the git change instead of silently reverting it on its next
|
||||
* autosave (the data-loss bug this fixes).
|
||||
*
|
||||
* With a `baseProsemirrorJson` (the last-synced common ancestor) it does a
|
||||
* THREE-WAY merge — a block only the human changed is kept, a block only
|
||||
* git changed is taken (conflicts -> git). Without a base it falls back to
|
||||
* the 2-way merge.
|
||||
*/
|
||||
gitSyncWriteBody: async (
|
||||
documentName: string,
|
||||
payload: {
|
||||
prosemirrorJson: any;
|
||||
baseProsemirrorJson?: any;
|
||||
userId: string;
|
||||
},
|
||||
) => {
|
||||
const { prosemirrorJson, baseProsemirrorJson, userId } = payload;
|
||||
|
||||
// Build the incoming (and base) Yjs docs BEFORE opening the connection /
|
||||
// touching the live doc. If a transform throws (a malformed/unsupported
|
||||
// doc) we must NOT have mutated the live body — otherwise a conversion
|
||||
// failure could leave the page empty (crash-safe conversion).
|
||||
const targetDoc = TiptapTransformer.toYdoc(
|
||||
prosemirrorJson,
|
||||
'default',
|
||||
tiptapExtensions,
|
||||
);
|
||||
const baseDoc =
|
||||
baseProsemirrorJson != null
|
||||
? TiptapTransformer.toYdoc(
|
||||
baseProsemirrorJson,
|
||||
'default',
|
||||
tiptapExtensions,
|
||||
)
|
||||
: null;
|
||||
|
||||
// actor:'git-sync' + the service user flow into PersistenceExtension
|
||||
// (lastUpdatedSource='git-sync', lastUpdatedById=userId).
|
||||
await this.withYdocConnection(
|
||||
hocuspocus,
|
||||
documentName,
|
||||
{ actor: 'git-sync', user: { id: userId } },
|
||||
(doc) => {
|
||||
const liveFrag = doc.getXmlFragment('default');
|
||||
const targetFrag = targetDoc.getXmlFragment('default');
|
||||
if (baseDoc) {
|
||||
mergeXmlFragments3Way(
|
||||
liveFrag,
|
||||
targetFrag,
|
||||
baseDoc.getXmlFragment('default'),
|
||||
);
|
||||
} else {
|
||||
mergeXmlFragments(liveFrag, targetFrag);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -182,4 +182,46 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
|
||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||
expect(historyQueue.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// persist-1 — a transient DB failure during store must not silently lose the
|
||||
// edit. hocuspocus unloads (destroys) the in-memory Y.Doc right after this
|
||||
// hook resolves, so the store has to retry while it still holds the only copy.
|
||||
it('retries a transient DB failure and still persists the edit (persist-1)', async () => {
|
||||
const document = ydocFor(doc('NEW HUMAN CONTENT'));
|
||||
pageRepo.findById.mockResolvedValue(persistedHumanPage('NEW HUMAN CONTENT'));
|
||||
let attempts = 0;
|
||||
pageRepo.updatePage.mockImplementation(async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) throw new Error('deadlock detected'); // transient
|
||||
callOrder.push('updatePage');
|
||||
});
|
||||
|
||||
await ext.onStoreDocument(buildData(document, 'user') as any);
|
||||
|
||||
// First attempt failed and rolled back; the retry persisted the edit.
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(2);
|
||||
// The edit WAS saved, so the post-store success path runs as normal.
|
||||
expect((document as any).broadcastStateless).toHaveBeenCalledTimes(1);
|
||||
expect(historyQueue.add).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// persist-1 — when every attempt fails the hook must NOT report a phantom
|
||||
// success: no "page.updated" badge broadcast and no history snapshot for
|
||||
// content that was never written.
|
||||
it('does not run post-store side effects when every store attempt fails (persist-1)', async () => {
|
||||
const document = ydocFor(doc('NEW HUMAN CONTENT'));
|
||||
pageRepo.findById.mockResolvedValue(persistedHumanPage('NEW HUMAN CONTENT'));
|
||||
pageRepo.updatePage.mockRejectedValue(new Error('connection reset'));
|
||||
|
||||
await expect(
|
||||
ext.onStoreDocument(buildData(document, 'user') as any),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
// Bounded retry exhausted (MAX_STORE_ATTEMPTS).
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(3);
|
||||
// No false-success: nothing downstream fires for the unsaved content.
|
||||
expect((document as any).broadcastStateless).not.toHaveBeenCalled();
|
||||
expect(historyQueue.add).not.toHaveBeenCalled();
|
||||
expect(aiQueue.add).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
// Stub collaboration.util so importing the extension does not drag in the
|
||||
// editor-ext -> @tiptap/react -> react-dom graph (unloadable under jest's node
|
||||
// env, same coupling the gitmost-datasource / mcp specs document). The
|
||||
// extension only calls getPageId, jsonToText and isEmptyParagraphDoc from it on
|
||||
// the store path; tiptapExtensions is unused by onStoreDocument.
|
||||
jest.mock('../collaboration.util', () => ({
|
||||
tiptapExtensions: [],
|
||||
getPageId: (name: string) => name.replace(/^page\./, ''),
|
||||
jsonToText: () => 'text',
|
||||
isEmptyParagraphDoc: () => false,
|
||||
// The post-write mention extraction walks the doc via jsonToNode().descendants;
|
||||
// return a node-like stub with no descendants so no mentions are produced
|
||||
// (mention handling is out of scope here — we only assert provenance).
|
||||
jsonToNode: () => ({ descendants: () => undefined }),
|
||||
}));
|
||||
|
||||
// Control the Yjs<->JSON bridge: fromYdoc returns the "incoming" doc the writer
|
||||
// is storing. We keep it distinct from the page's persisted content so the
|
||||
// no-op guard (isDeepStrictEqual) never short-circuits the write.
|
||||
const INCOMING_JSON = { type: 'doc', content: [{ type: 'paragraph' }, { t: 1 }] };
|
||||
jest.mock('@hocuspocus/transformer', () => ({
|
||||
TiptapTransformer: {
|
||||
fromYdoc: jest.fn(() => INCOMING_JSON),
|
||||
toYdoc: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Run the executeTx callback inline with a passthrough trx.
|
||||
jest.mock('@docmost/db/utils', () => ({
|
||||
executeTx: jest.fn(async (_db: any, cb: any) => cb({} as any)),
|
||||
}));
|
||||
|
||||
import * as Y from 'yjs';
|
||||
import { PersistenceExtension } from './persistence.extension';
|
||||
import {
|
||||
onChangePayload,
|
||||
onStoreDocumentPayload,
|
||||
} from '@hocuspocus/server';
|
||||
|
||||
/**
|
||||
* Provenance-precedence coverage for PersistenceExtension.onStoreDocument
|
||||
* (test-strategy Module 4 / item #2): the contract `agent > git-sync > user`,
|
||||
* plus the negative that a git-sync store does NOT pin a boundary history
|
||||
* snapshot. We drive the precedence through the real public method (onChange to
|
||||
* arm the sticky agent marker, then onStoreDocument), mocking the repos / db /
|
||||
* Yjs bridge so no real database or collab server is needed. The store's
|
||||
* persisted `lastUpdatedSource` and the saveHistory call are the observable
|
||||
* outputs.
|
||||
*/
|
||||
describe('PersistenceExtension.onStoreDocument — provenance precedence (#2)', () => {
|
||||
const DOCUMENT_NAME = 'page.page-1';
|
||||
const PAGE_ID = 'page-1';
|
||||
|
||||
// `page.content` differs from INCOMING_JSON so the write is never skipped.
|
||||
const persistedPage = (overrides?: { lastUpdatedSource?: string }) => ({
|
||||
id: PAGE_ID,
|
||||
slugId: 'slug-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
creatorId: 'creator-1',
|
||||
contributorIds: ['creator-1'],
|
||||
content: { type: 'doc', content: [{ type: 'paragraph', content: [] }] },
|
||||
lastUpdatedSource: overrides?.lastUpdatedSource ?? 'user',
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const build = (pageOverrides?: { lastUpdatedSource?: string }) => {
|
||||
const pageRepo = {
|
||||
findById: jest.fn().mockResolvedValue(persistedPage(pageOverrides)),
|
||||
updatePage: jest.fn().mockResolvedValue({ numUpdatedRows: 1n }),
|
||||
};
|
||||
const pageHistoryRepo = {
|
||||
// No prior snapshot -> humanBaselineMissing is true, so the ONLY thing
|
||||
// gating the boundary snapshot in these tests is the source precedence.
|
||||
findPageLastHistory: jest.fn().mockResolvedValue(null),
|
||||
saveHistory: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
const historyQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
const notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
const collabHistory = {
|
||||
addContributors: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const transclusionService = {
|
||||
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
|
||||
syncPageReferences: jest.fn().mockResolvedValue(undefined),
|
||||
syncPageTemplateReferences: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const ext = new PersistenceExtension(
|
||||
pageRepo as any,
|
||||
pageHistoryRepo as any,
|
||||
{} as any, // db
|
||||
aiQueue as any,
|
||||
historyQueue as any,
|
||||
notificationQueue as any,
|
||||
collabHistory as any,
|
||||
transclusionService as any,
|
||||
);
|
||||
|
||||
return { ext, pageRepo, pageHistoryRepo, historyQueue };
|
||||
};
|
||||
|
||||
// A real Y.Doc is required for Y.encodeStateAsUpdate(document); broadcastStateless
|
||||
// is a no-op spy. The fromYdoc bridge is mocked, so the doc's contents are
|
||||
// irrelevant to the JSON path.
|
||||
const makeStorePayload = (context: any): onStoreDocumentPayload =>
|
||||
({
|
||||
documentName: DOCUMENT_NAME,
|
||||
document: Object.assign(new Y.Doc(), {
|
||||
broadcastStateless: jest.fn(),
|
||||
}),
|
||||
context,
|
||||
}) as any;
|
||||
|
||||
const makeChangePayload = (actor: string): onChangePayload =>
|
||||
({
|
||||
documentName: DOCUMENT_NAME,
|
||||
context: { user: { id: 'user-1' }, actor },
|
||||
}) as any;
|
||||
|
||||
const sourceOf = (pageRepo: { updatePage: jest.Mock }) =>
|
||||
pageRepo.updatePage.mock.calls[0][0].lastUpdatedSource;
|
||||
|
||||
it("tags 'user' for a plain write (no agent touch, no git-sync actor)", async () => {
|
||||
const { ext, pageRepo } = build();
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'user-1' }, actor: 'user' }),
|
||||
);
|
||||
|
||||
expect(sourceOf(pageRepo)).toBe('user');
|
||||
});
|
||||
|
||||
it("tags 'git-sync' when the writer's actor is 'git-sync' and no agent touched the window", async () => {
|
||||
const { ext, pageRepo } = build();
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'svc-user' }, actor: 'git-sync' }),
|
||||
);
|
||||
|
||||
expect(sourceOf(pageRepo)).toBe('git-sync');
|
||||
});
|
||||
|
||||
it("keeps 'git-sync' for an explicit git-sync store even with a sticky agent marker (#14 loop-guard)", async () => {
|
||||
const { ext, pageRepo } = build();
|
||||
|
||||
// An agent edit landed earlier in the coalescing window (sticky marker),
|
||||
// then a git-sync writer performs the store. Red-team finding #14: an
|
||||
// EXPLICIT current-write actor is authoritative for THIS write, so the
|
||||
// store must stay 'git-sync' — otherwise the PageChangeListener loop-guard
|
||||
// (keyed on lastUpdatedSource === 'git-sync') fails to recognize git-sync's
|
||||
// own write and re-exports it. Explicit 'agent' still wins (see below); the
|
||||
// sticky marker only promotes a plain human writer to 'agent'.
|
||||
await ext.onChange(makeChangePayload('agent'));
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'svc-user' }, actor: 'git-sync' }),
|
||||
);
|
||||
|
||||
expect(sourceOf(pageRepo)).toBe('git-sync');
|
||||
});
|
||||
|
||||
it("tags 'agent' when the storing writer itself is the agent (no prior onChange)", async () => {
|
||||
const { ext, pageRepo } = build();
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'agent-user' }, actor: 'agent' }),
|
||||
);
|
||||
|
||||
expect(sourceOf(pageRepo)).toBe('agent');
|
||||
});
|
||||
|
||||
// --- negative: a git-sync store must NOT pin a boundary history snapshot ----
|
||||
// The boundary-snapshot branch only fires when the resolved source is 'agent'
|
||||
// AND the prior persisted source is not 'agent'. A git-sync store resolves to
|
||||
// 'git-sync', so saveHistory must NOT be called.
|
||||
it('does NOT write a boundary history snapshot for a git-sync store', async () => {
|
||||
const { ext, pageHistoryRepo } = build({ lastUpdatedSource: 'user' });
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'svc-user' }, actor: 'git-sync' }),
|
||||
);
|
||||
|
||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('DOES pin a boundary snapshot for an agent store over a prior human state (control)', async () => {
|
||||
// Confirms the negative above is meaningful: under the SAME mocks, an agent
|
||||
// store over a 'user' baseline DOES trigger the boundary snapshot.
|
||||
const { ext, pageHistoryRepo } = build({ lastUpdatedSource: 'user' });
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'agent-user' }, actor: 'agent' }),
|
||||
);
|
||||
|
||||
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does NOT pin a boundary snapshot for a plain user store', async () => {
|
||||
const { ext, pageHistoryRepo } = build({ lastUpdatedSource: 'user' });
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'user-1' }, actor: 'user' }),
|
||||
);
|
||||
|
||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -52,17 +52,7 @@ export function resolveSource(
|
||||
stickyTouched: boolean,
|
||||
contextActor?: string,
|
||||
): ProvenanceSource {
|
||||
// An EXPLICIT current-write actor is authoritative for THIS write and wins
|
||||
// over the sticky-agent fallback. Order: explicit 'agent' > explicit
|
||||
// 'git-sync' > sticky agent marker > plain human 'user'. The git-sync case
|
||||
// must NOT be masked by the sticky marker, or the PageChangeListener
|
||||
// loop-guard (which keys on lastUpdatedSource === 'git-sync') would re-export
|
||||
// git-sync's own writes (#14). Explicit agent still wins so a window that
|
||||
// mixed an agent edit stays tagged 'agent'.
|
||||
if (contextActor === 'agent') return 'agent';
|
||||
if (contextActor === 'git-sync') return 'git-sync';
|
||||
if (stickyTouched) return 'agent';
|
||||
return 'user';
|
||||
return stickyTouched || contextActor === 'agent' ? 'agent' : 'user';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,93 +176,118 @@ export class PersistenceExtension implements Extension {
|
||||
// Sticky agent marker: 'agent' if any agent edit landed in this window, OR
|
||||
// if the current writer is the agent (covers a store with no prior onChange
|
||||
// agent event in the same window). §15 H2.
|
||||
// Provenance precedence: agent > git-sync > user (see resolveSource). A
|
||||
// 'git-sync' store is NOT given an immediate history snapshot — it is
|
||||
// debounced like a human edit (a git-sync write is a block-level merge into
|
||||
// the live doc, so it reads like an incremental human edit, not a bulk
|
||||
// import that would warrant its own immediate snapshot).
|
||||
const lastUpdatedSource = resolveSource(
|
||||
this.consumeAgentTouched(documentName),
|
||||
context?.actor,
|
||||
);
|
||||
|
||||
try {
|
||||
await executeTx(this.db, async (trx) => {
|
||||
page = await this.pageRepo.findById(pageId, {
|
||||
withLock: true,
|
||||
includeContent: true,
|
||||
trx,
|
||||
});
|
||||
// Persist with a small bounded retry. The in-memory Y.Doc is the ONLY copy
|
||||
// of the latest edit until this hook returns: hocuspocus destroys/unloads the
|
||||
// doc right after onStoreDocument resolves (see storeDocumentHooks' finally
|
||||
// -> unloadDocument). If a transient DB error (deadlock, serialization
|
||||
// failure, dropped connection) is merely logged and swallowed, the function
|
||||
// resolves "successfully", the doc is unloaded, and the edit is lost silently
|
||||
// (#206 persist-1). Retrying here re-attempts the write while we still hold
|
||||
// the doc; on total failure we clear `page` so the post-store side effects
|
||||
// (badge broadcast, history snapshot) never report a save that didn't happen.
|
||||
const MAX_STORE_ATTEMPTS = 3;
|
||||
for (let attempt = 1; attempt <= MAX_STORE_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
await executeTx(this.db, async (trx) => {
|
||||
page = await this.pageRepo.findById(pageId, {
|
||||
withLock: true,
|
||||
includeContent: true,
|
||||
trx,
|
||||
});
|
||||
|
||||
if (!page) {
|
||||
this.logger.error(`Page with id ${pageId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDeepStrictEqual(tiptapJson, page.content)) {
|
||||
page = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let contributorIds = undefined;
|
||||
try {
|
||||
const existingContributors = page.contributorIds || [];
|
||||
contributorIds = Array.from(
|
||||
new Set([
|
||||
...existingContributors,
|
||||
...editingUserIds,
|
||||
page.creatorId,
|
||||
]),
|
||||
);
|
||||
} catch (err) {
|
||||
//this.logger.debug('Contributors error:' + err?.['message']);
|
||||
}
|
||||
|
||||
// Approach A — boundary snapshot before the agent's first edit.
|
||||
// When this store is the agent's and the page's currently persisted
|
||||
// state was authored by a human, pin that human state as its own
|
||||
// history version BEFORE the agent overwrites it. `page` still holds the
|
||||
// OLD content/provenance here, so saveHistory(page) captures the
|
||||
// pre-agent state tagged 'user'. The agent's new content is snapshotted
|
||||
// later by the debounced PAGE_HISTORY job ('agent'). Skip if the prior
|
||||
// state is already agent-authored (boundary already pinned on the
|
||||
// user->agent transition), if the page is effectively empty, or if the
|
||||
// latest existing snapshot already equals this human state (avoid
|
||||
// duplicates).
|
||||
if (lastUpdatedSource === 'agent' && page.lastUpdatedSource !== 'agent') {
|
||||
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
|
||||
pageId,
|
||||
{ includeContent: true, trx },
|
||||
);
|
||||
const humanBaselineMissing =
|
||||
!lastHistory || !isDeepStrictEqual(lastHistory.content, page.content);
|
||||
if (!isEmptyParagraphDoc(page.content as any) && humanBaselineMissing) {
|
||||
await this.pageHistoryRepo.saveHistory(page, {
|
||||
contributorIds: page.contributorIds ?? undefined,
|
||||
trx,
|
||||
});
|
||||
if (!page) {
|
||||
this.logger.error(`Page with id ${pageId} not found`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.pageRepo.updatePage(
|
||||
{
|
||||
content: tiptapJson,
|
||||
textContent: textContent,
|
||||
ydoc: ydocState,
|
||||
lastUpdatedById: context.user.id,
|
||||
// Human stays the responsible author; these annotate the source.
|
||||
lastUpdatedSource,
|
||||
lastUpdatedAiChatId: context?.aiChatId ?? null,
|
||||
contributorIds: contributorIds,
|
||||
},
|
||||
pageId,
|
||||
trx,
|
||||
if (isDeepStrictEqual(tiptapJson, page.content)) {
|
||||
page = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let contributorIds = undefined;
|
||||
try {
|
||||
const existingContributors = page.contributorIds || [];
|
||||
contributorIds = Array.from(
|
||||
new Set([
|
||||
...existingContributors,
|
||||
...editingUserIds,
|
||||
page.creatorId,
|
||||
]),
|
||||
);
|
||||
} catch (err) {
|
||||
//this.logger.debug('Contributors error:' + err?.['message']);
|
||||
}
|
||||
|
||||
// Approach A — boundary snapshot before the agent's first edit.
|
||||
// When this store is the agent's and the page's currently persisted
|
||||
// state was authored by a human, pin that human state as its own
|
||||
// history version BEFORE the agent overwrites it. `page` still holds
|
||||
// the OLD content/provenance here, so saveHistory(page) captures the
|
||||
// pre-agent state tagged 'user'. The agent's new content is
|
||||
// snapshotted later by the debounced PAGE_HISTORY job ('agent'). Skip
|
||||
// if the prior state is already agent-authored (boundary already
|
||||
// pinned on the user->agent transition), if the page is effectively
|
||||
// empty, or if the latest existing snapshot already equals this human
|
||||
// state (avoid duplicates).
|
||||
if (
|
||||
lastUpdatedSource === 'agent' &&
|
||||
page.lastUpdatedSource !== 'agent'
|
||||
) {
|
||||
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
|
||||
pageId,
|
||||
{ includeContent: true, trx },
|
||||
);
|
||||
const humanBaselineMissing =
|
||||
!lastHistory ||
|
||||
!isDeepStrictEqual(lastHistory.content, page.content);
|
||||
if (
|
||||
!isEmptyParagraphDoc(page.content as any) &&
|
||||
humanBaselineMissing
|
||||
) {
|
||||
await this.pageHistoryRepo.saveHistory(page, {
|
||||
contributorIds: page.contributorIds ?? undefined,
|
||||
trx,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.pageRepo.updatePage(
|
||||
{
|
||||
content: tiptapJson,
|
||||
textContent: textContent,
|
||||
ydoc: ydocState,
|
||||
lastUpdatedById: context.user.id,
|
||||
// Human stays the responsible author; these annotate the source.
|
||||
lastUpdatedSource,
|
||||
lastUpdatedAiChatId: context?.aiChatId ?? null,
|
||||
contributorIds: contributorIds,
|
||||
},
|
||||
pageId,
|
||||
trx,
|
||||
);
|
||||
|
||||
this.logger.debug(`Page updated: ${pageId} - SlugId: ${page.slugId}`);
|
||||
});
|
||||
break;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to update page ${pageId} (attempt ${attempt}/${MAX_STORE_ATTEMPTS})`,
|
||||
err,
|
||||
);
|
||||
|
||||
this.logger.debug(`Page updated: ${pageId} - SlugId: ${page.slugId}`);
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to update page ${pageId}`, err);
|
||||
// The write failed and rolled back; clear the partially-assigned `page`
|
||||
// so the post-store success branch below is skipped (no false "saved"
|
||||
// broadcast / history snapshot for content that was never persisted).
|
||||
page = null;
|
||||
if (attempt < MAX_STORE_ATTEMPTS) {
|
||||
await new Promise((resolve) => setTimeout(resolve, attempt * 50));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (page) {
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
// Regression coverage for the custom-event request/reply protocol in the
|
||||
// RedisSyncExtension. git-sync routes its body write through a custom event
|
||||
// (`gitSyncWriteBody`) which, when the target doc is owned by a DIFFERENT collab
|
||||
// instance, runs REMOTELY inside `handleRedisMessage` on the owning instance. The
|
||||
// remote handler can THROW (markdown->ProseMirror transform on a malformed body).
|
||||
//
|
||||
// Before the fix the throw was uncaught: (1) no `customEventComplete` reply was
|
||||
// published, so the origin's awaiting promise only rejected after `customEventTTL`
|
||||
// (~30s) as a generic 'TIMEOUT', and (2) an unhandledRejection escaped the async
|
||||
// `messageBuffer` listener on the owning instance. These tests assert the throw is
|
||||
// turned into an error-carrying reply that rejects the origin PROMPTLY with the
|
||||
// real message, with the no-throw and local paths unchanged.
|
||||
|
||||
import { RedisSyncExtension } from './redis-sync.extension';
|
||||
|
||||
type Listener = (channel: Buffer, message: Buffer) => unknown;
|
||||
|
||||
// Minimal in-memory pub/sub + lock store shared across FakeRedis duplicates,
|
||||
// modelling the two-instance topology (origin + owner) over one Redis.
|
||||
class FakeRedisBus {
|
||||
instances: FakeRedis[] = [];
|
||||
locks = new Map<string, string>();
|
||||
published: { channel: string; message: Buffer }[] = [];
|
||||
|
||||
register(inst: FakeRedis) {
|
||||
this.instances.push(inst);
|
||||
}
|
||||
|
||||
publish(channel: string, message: Buffer) {
|
||||
this.published.push({ channel, message });
|
||||
for (const inst of this.instances) {
|
||||
if (!inst.subscribed.has(channel)) continue;
|
||||
for (const listener of inst.messageListeners) {
|
||||
// ioredis delivers async; `void` mirrors the production listener
|
||||
// registration (`sub.on('messageBuffer', ...)`), whose rejection would
|
||||
// surface as an unhandledRejection if the handler did not catch.
|
||||
void listener(Buffer.from(channel), message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FakeRedis {
|
||||
subscribed = new Set<string>();
|
||||
messageListeners: Listener[] = [];
|
||||
|
||||
constructor(private bus: FakeRedisBus) {
|
||||
bus.register(this);
|
||||
}
|
||||
|
||||
duplicate() {
|
||||
return new FakeRedis(this.bus);
|
||||
}
|
||||
|
||||
subscribe(...channels: string[]) {
|
||||
for (const c of channels) this.subscribed.add(c);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
on(event: string, cb: any) {
|
||||
if (event === 'messageBuffer') this.messageListeners.push(cb as Listener);
|
||||
return this;
|
||||
}
|
||||
|
||||
publish(channel: string, message: Buffer) {
|
||||
this.bus.publish(channel, message);
|
||||
return Promise.resolve(1);
|
||||
}
|
||||
|
||||
// Models `SET key val PX ttl NX GET`: only writes when absent (NX); returns the
|
||||
// previous value (GET) so the origin observes the owner already holding the lock.
|
||||
set(key: string, val: string, ...args: any[]) {
|
||||
const hasNX = args.includes('NX');
|
||||
const hasGET = args.includes('GET');
|
||||
const old = this.bus.locks.get(key) ?? null;
|
||||
if (!hasNX || old === null) this.bus.locks.set(key, val);
|
||||
return Promise.resolve(hasGET ? old : 'OK');
|
||||
}
|
||||
|
||||
del(key: string) {
|
||||
this.bus.locks.delete(key);
|
||||
return Promise.resolve(1);
|
||||
}
|
||||
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
const pack = (m: any) => Buffer.from(JSON.stringify(m));
|
||||
const unpack = (b: Buffer) => JSON.parse(b.toString());
|
||||
|
||||
function makeExtension(
|
||||
bus: FakeRedisBus,
|
||||
serverId: string,
|
||||
customEvents: Record<string, (doc: string, payload: any) => Promise<any>>,
|
||||
) {
|
||||
const ext = new RedisSyncExtension({
|
||||
redis: new FakeRedis(bus) as any,
|
||||
pack: pack as any,
|
||||
unpack: unpack as any,
|
||||
serverId,
|
||||
customEvents: customEvents as any,
|
||||
customEventTTL: 30_000,
|
||||
});
|
||||
// Doc is NOT loaded on this instance -> handleEvent takes the remote/proxy path.
|
||||
(ext as any).instance = { documents: new Map() };
|
||||
return ext;
|
||||
}
|
||||
|
||||
describe('RedisSyncExtension custom-event error propagation', () => {
|
||||
let unhandled: unknown[];
|
||||
let onUnhandled: (e: unknown) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
// Fake timers so the 30s TTL fallback timer never fires (and never dangles).
|
||||
jest.useFakeTimers();
|
||||
unhandled = [];
|
||||
onUnhandled = (e) => unhandled.push(e);
|
||||
process.on('unhandledRejection', onUnhandled);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.off('unhandledRejection', onUnhandled);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
it('owner publishes an error-carrying reply (no unhandledRejection) when the remote handler throws', async () => {
|
||||
const bus = new FakeRedisBus();
|
||||
const owner = makeExtension(bus, 'owner', {
|
||||
boom: async () => {
|
||||
throw new Error('kaboom');
|
||||
},
|
||||
});
|
||||
|
||||
// Drive the remote branch directly, as if the origin's customEventStart arrived.
|
||||
await (owner as any).handleRedisMessage(
|
||||
Buffer.from('collabMsg:owner'),
|
||||
pack({
|
||||
type: 'customEventStart',
|
||||
documentName: 'page.x',
|
||||
eventName: 'boom',
|
||||
payload: {},
|
||||
replyTo: 'collabMsg:origin',
|
||||
replyId: 7,
|
||||
}),
|
||||
);
|
||||
await flush();
|
||||
|
||||
const replies = bus.published
|
||||
.filter((p) => p.channel === 'collabMsg:origin')
|
||||
.map((p) => unpack(p.message));
|
||||
expect(replies).toHaveLength(1);
|
||||
expect(replies[0]).toMatchObject({
|
||||
type: 'customEventComplete',
|
||||
replyId: 7,
|
||||
error: 'kaboom',
|
||||
});
|
||||
expect(unhandled).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('origin rejects PROMPTLY with the real error (not a TTL TIMEOUT) when the remote handler throws', async () => {
|
||||
const bus = new FakeRedisBus();
|
||||
// Owner already holds the document lock.
|
||||
bus.locks.set('collabLock:page.x', 'owner');
|
||||
makeExtension(bus, 'owner', {
|
||||
boom: async () => {
|
||||
throw new Error('kaboom');
|
||||
},
|
||||
});
|
||||
const origin = makeExtension(bus, 'origin', {
|
||||
boom: async () => undefined,
|
||||
});
|
||||
|
||||
const promise = (origin as any).handleEvent('boom', 'page.x', { foo: 1 });
|
||||
// Attach a catch immediately so a rejection is never momentarily unhandled.
|
||||
const settled = promise.then(
|
||||
() => ({ ok: true as const }),
|
||||
(e: unknown) => ({ ok: false as const, error: e }),
|
||||
);
|
||||
|
||||
await flush();
|
||||
// Resolves WITHOUT advancing any timer -> the 30s TIMEOUT fallback did not fire.
|
||||
const result = await settled;
|
||||
expect(result.ok).toBe(false);
|
||||
expect((result as any).error).toBeInstanceOf(Error);
|
||||
expect(((result as any).error as Error).message).toBe('kaboom');
|
||||
expect(unhandled).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('origin resolves with the payload when the remote handler succeeds (unchanged behavior)', async () => {
|
||||
const bus = new FakeRedisBus();
|
||||
bus.locks.set('collabLock:page.x', 'owner');
|
||||
makeExtension(bus, 'owner', {
|
||||
ok: async (_doc: string, payload: any) => ({ echoed: payload }),
|
||||
});
|
||||
const origin = makeExtension(bus, 'origin', {
|
||||
ok: async () => undefined,
|
||||
});
|
||||
|
||||
const promise = (origin as any).handleEvent('ok', 'page.x', { foo: 1 });
|
||||
await flush();
|
||||
await expect(promise).resolves.toEqual({ echoed: { foo: 1 } });
|
||||
expect(unhandled).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -51,15 +51,9 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
|
||||
private instance!: Hocuspocus;
|
||||
private readonly customEvents: TCE;
|
||||
private replyIdCounter: number = 0;
|
||||
private pendingReplies: Record<
|
||||
number,
|
||||
{
|
||||
// @ts-ignore
|
||||
resolve: PromiseWithResolvers<any>['resolve'];
|
||||
// @ts-ignore
|
||||
reject: PromiseWithResolvers<any>['reject'];
|
||||
}
|
||||
> = {};
|
||||
// @ts-ignore
|
||||
private pendingReplies: Record<number, PromiseWithResolvers<any>['resolve']> =
|
||||
{};
|
||||
|
||||
constructor(configuration: Configuration<TCE>) {
|
||||
const {
|
||||
@@ -182,45 +176,25 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
|
||||
}
|
||||
if (type === 'customEventStart') {
|
||||
const { documentName, eventName, payload, replyTo, replyId } = msg;
|
||||
let reply: RSAMessageCustomEventComplete;
|
||||
try {
|
||||
const res = await this.handleEventLocally(
|
||||
eventName as Extract<keyof TCE, string>,
|
||||
documentName,
|
||||
payload,
|
||||
);
|
||||
reply = {
|
||||
type: 'customEventComplete',
|
||||
replyId,
|
||||
payload: res,
|
||||
};
|
||||
} catch (err) {
|
||||
// The remote handler threw (e.g. the markdown->ProseMirror transform in
|
||||
// gitSyncWriteBody can throw on a malformed body). Reply with the error on
|
||||
// the SAME correlation channel so the origin rejects promptly with the real
|
||||
// message instead of waiting out customEventTTL as a generic 'TIMEOUT'.
|
||||
// Catching here also keeps the throw from escaping this async messageBuffer
|
||||
// listener as an unhandledRejection on the owning instance.
|
||||
reply = {
|
||||
type: 'customEventComplete',
|
||||
replyId,
|
||||
payload: undefined,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
const res = await this.handleEventLocally(
|
||||
eventName as Extract<keyof TCE, string>,
|
||||
documentName,
|
||||
payload,
|
||||
);
|
||||
const reply: RSAMessageCustomEventComplete = {
|
||||
type: 'customEventComplete',
|
||||
replyId,
|
||||
payload: res,
|
||||
};
|
||||
this.pub.publish(`${replyTo}`, this.pack(reply));
|
||||
return;
|
||||
}
|
||||
if (type === 'customEventComplete') {
|
||||
const { replyId, payload, error } = msg;
|
||||
const pending = this.pendingReplies[replyId];
|
||||
if (!pending) return;
|
||||
const { replyId, payload } = msg;
|
||||
const resolveFn = this.pendingReplies[replyId];
|
||||
if (!resolveFn) return;
|
||||
delete this.pendingReplies[replyId];
|
||||
if (error !== undefined) {
|
||||
pending.reject(new Error(error));
|
||||
} else {
|
||||
pending.resolve(payload);
|
||||
}
|
||||
resolveFn(payload);
|
||||
return;
|
||||
}
|
||||
const { socketId } = msg;
|
||||
@@ -299,22 +273,11 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
|
||||
};
|
||||
const msg = this.pack(proxyMessage);
|
||||
this.pub.publish(`${this.msgChannel}:${proxyTo}`, msg);
|
||||
// Manual deferred (no Promise.withResolvers) so this runs on Node < 22 too.
|
||||
let resolve!: (v: unknown) => void;
|
||||
let reject!: (e: unknown) => void;
|
||||
const promise = new Promise((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
this.pendingReplies[replyId] = { resolve, reject };
|
||||
// @ts-ignore
|
||||
const { promise, resolve, reject } = Promise.withResolvers();
|
||||
this.pendingReplies[replyId] = resolve;
|
||||
setTimeout(() => {
|
||||
// Fallback for a genuinely lost reply. A handler that threw now rejects
|
||||
// promptly via the error-carrying customEventComplete above; this TIMEOUT
|
||||
// only fires when no reply ever comes back.
|
||||
if (this.pendingReplies[replyId]) {
|
||||
delete this.pendingReplies[replyId];
|
||||
reject('TIMEOUT');
|
||||
}
|
||||
reject('TIMEOUT');
|
||||
}, this.customEventTTL);
|
||||
return promise as Promise<ReturnType<TCE[TName]>>;
|
||||
}
|
||||
|
||||
@@ -72,10 +72,6 @@ export type RSAMessageCustomEventComplete = {
|
||||
type: 'customEventComplete';
|
||||
replyId: number;
|
||||
payload: unknown;
|
||||
// When the remote handler THREW, the owner sends back the error message here
|
||||
// instead of a payload, so the origin can reject its awaiting promise promptly
|
||||
// (with the real error) rather than waiting out the customEventTTL timeout.
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type RSAMessage =
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { resolveSource } from './persistence.extension';
|
||||
|
||||
// Red-team finding #14: an explicit git-sync write (no agent edit in the
|
||||
// coalescing window) must keep the 'git-sync' source so the git-sync
|
||||
// listener's loop-guard can recognize its own writes and not re-export them.
|
||||
describe('resolveSource — #14 git-sync provenance loop-guard', () => {
|
||||
it('keeps git-sync source for an explicit git-sync write (stickyTouched=true, actor=git-sync)', () => {
|
||||
expect(resolveSource(true, 'git-sync')).toBe('git-sync');
|
||||
});
|
||||
});
|
||||
@@ -1,535 +0,0 @@
|
||||
/**
|
||||
* JEST CONFIG NOTE (#119 ESM refactor): this is the one spec that needs the REAL
|
||||
* `@docmost/git-sync` converter (not a mock). The package is now ESM, which jest
|
||||
* cannot `require()` nor `import()` without --experimental-vm-modules, so the
|
||||
* server jest config `moduleNameMapper`s `@docmost/git-sync` to its TS SOURCE and
|
||||
* strips the ESM `.js` import suffixes. ts-jest then type-checks that source under
|
||||
* the server's (looser) tsconfig and trips a benign narrowing; the global
|
||||
* `isolatedModules: true` on the ts-jest transform (apps/server/package.json)
|
||||
* makes it transpile-only so this spec loads. Full type-checking of the package
|
||||
* is still enforced by its own `tsc`/vitest gates and the server `tsc --noEmit`.
|
||||
*
|
||||
* §13.1 IDEMPOTENCY GATE — the blocking gate for git-sync Phase B.
|
||||
*
|
||||
* Proves the `@docmost/git-sync` pure converter is schema-compatible
|
||||
* with the server's REAL editor-ext document schema: a representative corpus of
|
||||
* editor-ext ProseMirror documents must survive a full round trip through the
|
||||
* actual server write path without losing any node / mark / attribute.
|
||||
*
|
||||
* Pipeline per document (issue #194 §13.1):
|
||||
* 1. md = convertProseMirrorToMarkdown(content) // git-sync export
|
||||
* 2. doc = await markdownToProseMirror(md) // git-sync import
|
||||
* 3. push `doc` through the REAL editor-ext Yjs write path the server uses:
|
||||
* ydoc = TiptapTransformer.toYdoc(doc, 'default', tiptapExtensions)
|
||||
* normalized = TiptapTransformer.fromYdoc(ydoc, 'default')
|
||||
* This is exactly what PersistenceExtension does on store
|
||||
* (apps/server/src/collaboration/extensions/persistence.extension.ts:96/115)
|
||||
* with the same `tiptapExtensions` (collaboration.util.ts) and the same
|
||||
* `@hocuspocus/transformer`, so the gate exercises the real schema
|
||||
* validation that runs on a git-sync write (issue #194 §3.3).
|
||||
* 4. assert docsCanonicallyEqual(canon(original), canon(normalized)) === true
|
||||
*
|
||||
* Any node / mark / attr that editor-ext drops (because the git-sync
|
||||
* docmost-schema named it differently, or declares a different default) makes
|
||||
* the gate FAIL for that document — exactly the schema-divergence issue #194 §3.3 /
|
||||
* §13.1 warn about. Genuine, irreducible divergences are isolated into the
|
||||
* clearly-named `KNOWN DIVERGENCE` block at the bottom (never silently hidden).
|
||||
*
|
||||
* Requires the workspace packages built first:
|
||||
* pnpm --filter @docmost/editor-ext build
|
||||
* pnpm --filter @docmost/git-sync build
|
||||
*/
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
// Import the server's real schema FIRST so `@docmost/editor-ext` resolves to its
|
||||
// built CJS `dist` (its `main`). The ESM-only `@docmost/git-sync` package is
|
||||
// mapped to its TS SOURCE by the jest `moduleNameMapper` (the built ESM cannot
|
||||
// be `require()`d nor dynamically `import()`ed under jest's node VM), so ts-jest
|
||||
// transpiles the real converter to CJS here — exercising the actual converter
|
||||
// the server ships, not a stub.
|
||||
import { tiptapExtensions } from './collaboration.util';
|
||||
import {
|
||||
convertProseMirrorToMarkdown,
|
||||
markdownToProseMirror,
|
||||
canonicalizeContent,
|
||||
docsCanonicallyEqual,
|
||||
} from '@docmost/git-sync';
|
||||
|
||||
/**
|
||||
* Run a single editor-ext document through the full gate pipeline and return
|
||||
* the canonical original vs the canonical doc as it lands after the real Yjs
|
||||
* write path, plus the intermediate markdown for diagnostics.
|
||||
*/
|
||||
async function runGate(original: any): Promise<{
|
||||
md: string;
|
||||
imported: any;
|
||||
normalized: any;
|
||||
canonOriginal: any;
|
||||
canonNormalized: any;
|
||||
}> {
|
||||
// 1) editor-ext JSON -> markdown (git-sync export).
|
||||
const md = convertProseMirrorToMarkdown(original);
|
||||
|
||||
// 2) markdown -> ProseMirror JSON (git-sync import, docmost-schema).
|
||||
const imported = await markdownToProseMirror(md);
|
||||
|
||||
// 3) push through the REAL editor-ext schema via the server's Yjs write path.
|
||||
// toYdoc validates `imported` against tiptapExtensions (throws on an
|
||||
// unknown node, drops unknown attrs); fromYdoc reads it back as the
|
||||
// normalized editor-ext JSON the server would persist.
|
||||
const ydoc = TiptapTransformer.toYdoc(imported, 'default', tiptapExtensions);
|
||||
const normalized = TiptapTransformer.fromYdoc(ydoc, 'default');
|
||||
|
||||
return {
|
||||
md,
|
||||
imported,
|
||||
normalized,
|
||||
canonOriginal: canonicalizeContent(original),
|
||||
canonNormalized: canonicalizeContent(normalized),
|
||||
};
|
||||
}
|
||||
|
||||
const doc = (...content: any[]) => ({ type: 'doc', content });
|
||||
const text = (t: string, marks?: any[]) =>
|
||||
marks ? { type: 'text', text: t, marks } : { type: 'text', text: t };
|
||||
const para = (...content: any[]) => ({ type: 'paragraph', content });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Corpus: editor-ext ProseMirror documents covering the common node/mark types.
|
||||
// Node / mark / attr names and DEFAULTS are taken from the real schema —
|
||||
// editor-ext (packages/editor-ext/src) + the server's tiptapExtensions
|
||||
// (collaboration.util.ts) — NOT guessed. Where editor-ext materializes a
|
||||
// non-null default on import (e.g. image.align="center", callout.type, list
|
||||
// start) the fixture pre-authors that materialized value so the round trip is
|
||||
// already at its fixpoint (matches how the engine normalizes-on-write, SPEC §11).
|
||||
// ---------------------------------------------------------------------------
|
||||
const CORPUS: Record<string, any> = {
|
||||
'paragraphs + headings (h1-h3)': doc(
|
||||
{ type: 'heading', attrs: { level: 1 }, content: [text('Heading one')] },
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [text('Heading two')] },
|
||||
{ type: 'heading', attrs: { level: 3 }, content: [text('Heading three')] },
|
||||
para(text('A plain paragraph of text.')),
|
||||
para(text('Second paragraph.')),
|
||||
),
|
||||
|
||||
'inline marks (bold/italic/strike/code)': doc(
|
||||
para(
|
||||
text('normal '),
|
||||
text('bold', [{ type: 'bold' }]),
|
||||
text(' '),
|
||||
text('italic', [{ type: 'italic' }]),
|
||||
text(' '),
|
||||
text('struck', [{ type: 'strike' }]),
|
||||
text(' '),
|
||||
text('code', [{ type: 'code' }]),
|
||||
),
|
||||
),
|
||||
|
||||
'links': doc(
|
||||
para(
|
||||
text('see '),
|
||||
text('the site', [
|
||||
{ type: 'link', attrs: { href: 'https://example.com' } },
|
||||
]),
|
||||
text(' for more'),
|
||||
),
|
||||
),
|
||||
|
||||
'bullet list': doc({
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{ type: 'listItem', content: [para(text('first'))] },
|
||||
{ type: 'listItem', content: [para(text('second'))] },
|
||||
{ type: 'listItem', content: [para(text('third'))] },
|
||||
],
|
||||
}),
|
||||
|
||||
'ordered list': doc({
|
||||
type: 'orderedList',
|
||||
attrs: { start: 1 },
|
||||
content: [
|
||||
{ type: 'listItem', content: [para(text('one'))] },
|
||||
{ type: 'listItem', content: [para(text('two'))] },
|
||||
],
|
||||
}),
|
||||
|
||||
'task list (checkbox)': doc({
|
||||
type: 'taskList',
|
||||
content: [
|
||||
{
|
||||
type: 'taskItem',
|
||||
attrs: { checked: true },
|
||||
content: [para(text('done item'))],
|
||||
},
|
||||
{
|
||||
type: 'taskItem',
|
||||
attrs: { checked: false },
|
||||
content: [para(text('todo item'))],
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
'blockquote': doc({
|
||||
type: 'blockquote',
|
||||
content: [para(text('a quoted line')), para(text('second quoted line'))],
|
||||
}),
|
||||
|
||||
'callout (info)': doc({
|
||||
type: 'callout',
|
||||
attrs: { type: 'info' },
|
||||
content: [para(text('an informational callout'))],
|
||||
}),
|
||||
|
||||
'callout (warning)': doc({
|
||||
type: 'callout',
|
||||
attrs: { type: 'warning' },
|
||||
content: [para(text('a warning callout'))],
|
||||
}),
|
||||
|
||||
'code block (with language)': doc({
|
||||
type: 'codeBlock',
|
||||
attrs: { language: 'typescript' },
|
||||
// A fenced code block's body is stored with a trailing newline (the form a
|
||||
// markdown ``` fence round-trips to: marked normalizes the code text to end
|
||||
// in "\n"). Authoring the fixture at that fixpoint mirrors how the engine
|
||||
// normalizes-on-write (SPEC §11): codeBlock + `language` round-trip exactly.
|
||||
content: [text('const a: number = 1;\nconsole.log(a);\n')],
|
||||
}),
|
||||
|
||||
'horizontal rule': doc(
|
||||
para(text('before')),
|
||||
{ type: 'horizontalRule' },
|
||||
para(text('after')),
|
||||
),
|
||||
|
||||
'table (header row + cells)': doc({
|
||||
type: 'table',
|
||||
content: [
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{
|
||||
type: 'tableHeader',
|
||||
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||
content: [para(text('Name'))],
|
||||
},
|
||||
{
|
||||
type: 'tableHeader',
|
||||
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||
content: [para(text('Value'))],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{
|
||||
type: 'tableCell',
|
||||
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||
content: [para(text('alpha'))],
|
||||
},
|
||||
{
|
||||
type: 'tableCell',
|
||||
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||
content: [para(text('1'))],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
// --- editor-ext nodes/marks beyond the original corpus (item #7) ----------
|
||||
// Each of these was verified to round-trip CLEANLY through the real gate
|
||||
// (export -> markdown -> import -> editor-ext Yjs write path). Fixtures are
|
||||
// pre-authored at the engine's normalize-on-write fixpoint (SPEC §11), e.g.
|
||||
// details carries the materialized `open:false`, and color marks use the
|
||||
// `rgb(...)` form the HTML re-parser normalizes to.
|
||||
|
||||
'mention (user)': doc(
|
||||
para(
|
||||
text('hi '),
|
||||
{
|
||||
type: 'mention',
|
||||
attrs: {
|
||||
id: 'user-123',
|
||||
label: 'Alice',
|
||||
entityType: 'user',
|
||||
entityId: 'user-123',
|
||||
creatorId: 'creator-1',
|
||||
},
|
||||
},
|
||||
text(' there'),
|
||||
),
|
||||
),
|
||||
|
||||
'inline math': doc(
|
||||
para(
|
||||
text('inline '),
|
||||
{ type: 'mathInline', attrs: { text: 'x^2' } },
|
||||
text(' math'),
|
||||
),
|
||||
),
|
||||
|
||||
'block math': doc({ type: 'mathBlock', attrs: { text: 'x^2 + y^2 = z^2' } }),
|
||||
|
||||
'details (collapsible)': doc({
|
||||
type: 'details',
|
||||
// `open:false` is the value editor-ext materializes on import; pre-authoring
|
||||
// it puts the fixture at its round-trip fixpoint.
|
||||
attrs: { open: false },
|
||||
content: [
|
||||
{ type: 'detailsSummary', content: [text('Summary line')] },
|
||||
{ type: 'detailsContent', content: [para(text('hidden body'))] },
|
||||
],
|
||||
}),
|
||||
|
||||
'highlight (mark, no color)': doc(
|
||||
para(
|
||||
text('a '),
|
||||
text('highlighted', [{ type: 'highlight' }]),
|
||||
text(' word'),
|
||||
),
|
||||
),
|
||||
|
||||
'highlight (mark, with color)': doc(
|
||||
para(
|
||||
text('a '),
|
||||
text('red', [{ type: 'highlight', attrs: { color: 'rgb(255, 0, 0)' } }]),
|
||||
text(' word'),
|
||||
),
|
||||
),
|
||||
|
||||
'subscript': doc(
|
||||
para(text('H'), text('2', [{ type: 'subscript' }]), text('O')),
|
||||
),
|
||||
|
||||
'superscript': doc(
|
||||
para(text('E=mc'), text('2', [{ type: 'superscript' }])),
|
||||
),
|
||||
|
||||
'text color (textStyle)': doc(
|
||||
// The HTML re-parser normalizes CSS colors to the `rgb(...)` form, so the
|
||||
// fixture pre-authors that form; a `#hex` color would round-trip to the
|
||||
// equivalent rgb() and is therefore a value-normalization divergence (see
|
||||
// the KNOWN DIVERGENCE block below).
|
||||
para(text('green', [{ type: 'textStyle', attrs: { color: 'rgb(0, 255, 0)' } }])),
|
||||
),
|
||||
|
||||
'nested / mixed document': doc(
|
||||
{ type: 'heading', attrs: { level: 1 }, content: [text('Mixed')] },
|
||||
para(
|
||||
text('intro with '),
|
||||
text('bold', [{ type: 'bold' }]),
|
||||
text(' and a '),
|
||||
text('link', [{ type: 'link', attrs: { href: 'https://example.com' } }]),
|
||||
text('.'),
|
||||
),
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
para(text('item with '), text('code', [{ type: 'code' }])),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
para(text('item with sublist')),
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{ type: 'listItem', content: [para(text('nested a'))] },
|
||||
{ type: 'listItem', content: [para(text('nested b'))] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'callout',
|
||||
attrs: { type: 'success' },
|
||||
content: [
|
||||
para(text('callout body')),
|
||||
{ type: 'codeBlock', attrs: { language: 'bash' }, content: [text('echo hi\n')] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'blockquote',
|
||||
content: [para(text('quote at the end'))],
|
||||
},
|
||||
),
|
||||
|
||||
// Atom embeds that carry no inline text: they must round-trip via their
|
||||
// schema-matching HTML (data-type div), NOT a literal that re-imports as plain
|
||||
// text. `subpages` used to export as the literal "{{SUBPAGES}}" and came back
|
||||
// as visible text on the page (red-team round-trip data loss) — this locks it.
|
||||
// editor-ext materializes the `recursive: false` default on import, so the
|
||||
// fixture pre-authors it to sit at the round-trip fixpoint (matches the other
|
||||
// default-materializing fixtures above).
|
||||
'subpages embed': doc({ type: 'subpages', attrs: { recursive: false } }),
|
||||
};
|
||||
|
||||
describe('git-sync converter §13.1 idempotency gate (editor-ext schema)', () => {
|
||||
for (const [name, original] of Object.entries(CORPUS)) {
|
||||
it(`round-trips losslessly: ${name}`, async () => {
|
||||
const { md, canonOriginal, canonNormalized } = await runGate(original);
|
||||
|
||||
const equal = docsCanonicallyEqual(original, canonNormalized);
|
||||
if (!equal) {
|
||||
// Surface a readable diff so a real divergence is actionable.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`\n[GATE FAIL] ${name}\n--- markdown ---\n${md}\n` +
|
||||
`--- canonical original ---\n${JSON.stringify(canonOriginal, null, 2)}\n` +
|
||||
`--- canonical round-tripped ---\n${JSON.stringify(canonNormalized, null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
expect(equal).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KNOWN DIVERGENCE — images (isolated so it does NOT silently weaken the gate).
|
||||
//
|
||||
// This is NOT a schema-name divergence: the `image` NODE itself round-trips
|
||||
// through editor-ext fine (it survives toYdoc under the real tiptapExtensions).
|
||||
// The loss is intrinsic to MARKDOWN, the on-disk transport format git-sync uses:
|
||||
//
|
||||
// 1. `convertProseMirrorToMarkdown` emits a standard `` image
|
||||
// (markdown-converter.ts case "image"). Standard markdown image syntax has
|
||||
// no way to express `width` / `height` / `align`, so those attrs are
|
||||
// DROPPED on export and cannot be recovered on import.
|
||||
// 2. A block-level image is hoisted out of its line by the HTML re-parser,
|
||||
// leaving a leading EMPTY paragraph (the same block-image-hoist limitation
|
||||
// documented in packages/git-sync/test/fixtures/known-limitations).
|
||||
//
|
||||
// The gate documents the EXACT lossy shape below. If the converter is ever
|
||||
// taught to preserve image dimensions (e.g. by emitting an HTML <img> with
|
||||
// data-* attrs, as it already does for video/diagrams), these assertions flip
|
||||
// and the image fixture should be promoted into the green CORPUS above.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('git-sync converter §13.1 image dimensions preserved (was KNOWN DIVERGENCE)', () => {
|
||||
const imageDoc = doc({
|
||||
type: 'image',
|
||||
attrs: {
|
||||
src: 'https://example.com/pic.png',
|
||||
width: 640,
|
||||
height: 480,
|
||||
align: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
it('preserves width/height/align by exporting an HTML <img> (PR #119 round-trip fix)', async () => {
|
||||
const { md, canonNormalized } = await runGate(imageDoc);
|
||||
|
||||
// A top-level image carrying layout attrs is now exported as a schema-
|
||||
// matching HTML <img> (the same path video/diagrams already use), so the
|
||||
// dimensions and alignment survive the round trip instead of collapsing to
|
||||
// bare ``.
|
||||
expect(md.trim()).toBe(
|
||||
'<img src="https://example.com/pic.png" width="640" height="480" align="center">',
|
||||
);
|
||||
|
||||
// The round-tripped image keeps src + the layout attrs. width/height are
|
||||
// re-imported as strings (matching the video/audio/pdf string convention),
|
||||
// so assert the values rather than the JS type.
|
||||
const imgAttrs = (canonNormalized as any).content[0].attrs;
|
||||
expect((canonNormalized as any).content[0].type).toBe('image');
|
||||
expect(imgAttrs.src).toBe('https://example.com/pic.png');
|
||||
expect(imgAttrs.align).toBe('center');
|
||||
expect(String(imgAttrs.width)).toBe('640');
|
||||
expect(String(imgAttrs.height)).toBe('480');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KNOWN DIVERGENCE — text alignment (item #7; isolated, not silently dropped).
|
||||
//
|
||||
// editor-ext registers TextAlign for heading+paragraph, and the SERVER schema
|
||||
// fully supports it — the loss is intrinsic to the MARKDOWN transport:
|
||||
//
|
||||
// • A paragraph's `textAlign` is EXPORTED as `<div align="...">text</div>`
|
||||
// (markdown-converter case "paragraph"), but on import the converter's
|
||||
// docmost-schema declares `textAlign` WITHOUT a parseHTML mapping, so the
|
||||
// `align` attribute is never recovered -> it imports as `textAlign:null`
|
||||
// and canonicalizes away. A heading's alignment is not even exported.
|
||||
// • Therefore any non-default alignment is dropped on a full round trip.
|
||||
//
|
||||
// If the converter is ever taught to parse `align`/`text-align` back onto the
|
||||
// block, this assertion flips and an aligned-paragraph fixture should be
|
||||
// promoted into the green CORPUS above.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('git-sync converter §13.1 KNOWN DIVERGENCE (text alignment dropped)', () => {
|
||||
it('drops a paragraph textAlign on the markdown round trip', async () => {
|
||||
const alignedDoc = doc({
|
||||
type: 'paragraph',
|
||||
attrs: { textAlign: 'center' },
|
||||
content: [text('centered')],
|
||||
});
|
||||
|
||||
const { canonNormalized } = await runGate(alignedDoc);
|
||||
|
||||
// The round-tripped paragraph carries no alignment.
|
||||
expect(canonNormalized).toEqual({
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'centered' }] }],
|
||||
});
|
||||
expect(docsCanonicallyEqual(alignedDoc, canonNormalized)).toBe(false);
|
||||
});
|
||||
|
||||
it('drops a heading textAlign (headings do not export alignment at all)', async () => {
|
||||
const alignedHeading = doc({
|
||||
type: 'heading',
|
||||
attrs: { level: 2, textAlign: 'center' },
|
||||
content: [text('centered heading')],
|
||||
});
|
||||
|
||||
const { md, canonNormalized } = await runGate(alignedHeading);
|
||||
|
||||
// Export is a plain markdown heading — no alignment syntax.
|
||||
expect(md.trim()).toBe('## centered heading');
|
||||
expect(docsCanonicallyEqual(alignedHeading, canonNormalized)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KNOWN DIVERGENCE — textStyle color is VALUE-NORMALIZED, not lost (item #7).
|
||||
//
|
||||
// The textStyle/color mark itself round-trips (the green CORPUS has the rgb()
|
||||
// form). But a `#hex` color is normalized to the equivalent `rgb(...)` string
|
||||
// by the HTML re-parser on import, and canonicalize.ts does NOT normalize color
|
||||
// formats — so a `#hex` original is not STRING-identical to its round trip even
|
||||
// though the color is semantically preserved. Locked here so the boundary is
|
||||
// explicit: author color fixtures in rgb() form to stay in the green corpus.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('git-sync converter §13.1 KNOWN DIVERGENCE (textStyle color #hex -> rgb)', () => {
|
||||
it('normalizes a #hex text color to rgb() (semantically preserved, string-divergent)', async () => {
|
||||
const hexDoc = doc(
|
||||
para(text('green', [{ type: 'textStyle', attrs: { color: '#00ff00' } }])),
|
||||
);
|
||||
|
||||
const { canonNormalized } = await runGate(hexDoc);
|
||||
|
||||
// Color survives, but as the normalized rgb() string.
|
||||
expect(canonNormalized).toEqual({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'green',
|
||||
marks: [{ type: 'textStyle', attrs: { color: 'rgb(0, 255, 0)' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
// Not string-identical to the #hex original.
|
||||
expect(docsCanonicallyEqual(hexDoc, canonNormalized)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,6 @@ import { ProvenanceSource } from '../../core/auth/dto/jwt-payload';
|
||||
* cannot fake an 'agent' marker.
|
||||
*/
|
||||
export interface AuthProvenanceData {
|
||||
// ProvenanceSource includes 'git-sync' — set by the in-process git-sync data
|
||||
// plane (issue #194 §8.1) when it drives PageService writes; never from a request token.
|
||||
actor: ProvenanceSource;
|
||||
aiChatId: string | null;
|
||||
}
|
||||
@@ -62,14 +60,6 @@ export function agentSourceFields<S extends string, C extends string>(
|
||||
sourceKey: S,
|
||||
chatKey: C,
|
||||
): Partial<Record<S, ProvenanceSource> & Record<C, string | null>> {
|
||||
// git-sync data-plane write (issue #194 §8.1): stamp the source 'git-sync' with NO
|
||||
// aiChatId (it has no internal ai_chats row). Mirrors the agent branch; each
|
||||
// write has a single actor, so precedence is irrelevant here.
|
||||
if (provenance?.actor === 'git-sync') {
|
||||
return { [sourceKey]: 'git-sync' } as Partial<
|
||||
Record<S, ProvenanceSource> & Record<C, string | null>
|
||||
>;
|
||||
}
|
||||
if (provenance?.actor !== 'agent') return {};
|
||||
return {
|
||||
[sourceKey]: 'agent',
|
||||
|
||||
@@ -275,11 +275,12 @@ describe('flushAssistant', () => {
|
||||
expect(f.toolCalls).not.toBeNull();
|
||||
});
|
||||
|
||||
it('completed: attaches finishReason + normalized usage + contextTokens', () => {
|
||||
it('completed: attaches finishReason + normalized usage + contextTokens + maxContextTokens', () => {
|
||||
const f = flushAssistant([toolStep], '', 'completed', {
|
||||
finishReason: 'stop',
|
||||
usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
|
||||
contextTokens: 15,
|
||||
maxContextTokens: 200000,
|
||||
});
|
||||
expect(f.status).toBe('completed');
|
||||
expect(f.metadata.finishReason).toBe('stop');
|
||||
@@ -290,6 +291,23 @@ describe('flushAssistant', () => {
|
||||
reasoningTokens: undefined,
|
||||
});
|
||||
expect(f.metadata.contextTokens).toBe(15);
|
||||
expect(f.metadata.maxContextTokens).toBe(200000);
|
||||
});
|
||||
|
||||
it('completed: omits maxContextTokens when unset or 0', () => {
|
||||
// No maxContextTokens in the extra (admin set no context window).
|
||||
const f = flushAssistant([toolStep], '', 'completed', {
|
||||
finishReason: 'stop',
|
||||
contextTokens: 15,
|
||||
});
|
||||
expect('maxContextTokens' in f.metadata).toBe(false);
|
||||
// Explicit 0 is treated the same as unset (no limit -> key omitted).
|
||||
const f0 = flushAssistant([toolStep], '', 'completed', {
|
||||
finishReason: 'stop',
|
||||
contextTokens: 15,
|
||||
maxContextTokens: 0,
|
||||
});
|
||||
expect('maxContextTokens' in f0.metadata).toBe(false);
|
||||
});
|
||||
|
||||
it('error: records the error and a derived finishReason', () => {
|
||||
|
||||
@@ -616,6 +616,10 @@ export class AiChatService implements OnModuleInit {
|
||||
contextTokens:
|
||||
(usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0) ||
|
||||
undefined,
|
||||
// Max context window for the chat header badge denominator;
|
||||
// resolved from the admin-configured provider settings (in
|
||||
// closure scope here). Omitted/0 = no limit.
|
||||
maxContextTokens: resolved?.chatContextWindow,
|
||||
}),
|
||||
);
|
||||
// Lifecycle: release the external MCP clients leased for this turn.
|
||||
@@ -1212,8 +1216,9 @@ export async function applyFinalize(
|
||||
* `metadata.parts` is built by assistantParts over the finished steps, then the
|
||||
* in-progress text appended as a trailing text part, so rowToUiMessage /
|
||||
* findRecent keep replaying the turn unchanged. `metadata.finishReason`,
|
||||
* `metadata.error`, `metadata.usage` and `metadata.contextTokens` are attached
|
||||
* only when provided/relevant, matching the pre-#183 onFinish/onError records.
|
||||
* `metadata.error`, `metadata.usage`, `metadata.contextTokens` and
|
||||
* `metadata.maxContextTokens` are attached only when provided/relevant, matching
|
||||
* the pre-#183 onFinish/onError records.
|
||||
*/
|
||||
export function flushAssistant(
|
||||
capturedSteps: ReadonlyArray<StepLike> | undefined,
|
||||
@@ -1223,6 +1228,7 @@ export function flushAssistant(
|
||||
finishReason?: string;
|
||||
usage?: ChatStreamUsage | StreamUsage | undefined;
|
||||
contextTokens?: number;
|
||||
maxContextTokens?: number;
|
||||
error?: string;
|
||||
},
|
||||
): AssistantFlush {
|
||||
@@ -1253,6 +1259,8 @@ export function flushAssistant(
|
||||
normalizeStreamUsage(extra.usage as StreamUsage) ?? extra.usage;
|
||||
}
|
||||
if (extra?.contextTokens) metadata.contextTokens = extra.contextTokens;
|
||||
if (extra?.maxContextTokens)
|
||||
metadata.maxContextTokens = extra.maxContextTokens;
|
||||
if (extra?.error) metadata.error = extra.error;
|
||||
|
||||
return {
|
||||
|
||||
@@ -34,6 +34,7 @@ describe('resolveShareAssistantRequest (extracted controller funnel)', () => {
|
||||
resolveShareRole?: jest.Mock;
|
||||
getShareChatModel?: jest.Mock;
|
||||
tryConsumeWorkspaceQuota?: jest.Mock;
|
||||
withinShareTokenBudget?: jest.Mock;
|
||||
} = {}) {
|
||||
const aiSettings = {
|
||||
isPublicShareAssistantEnabled: jest
|
||||
@@ -65,6 +66,8 @@ describe('resolveShareAssistantRequest (extracted controller funnel)', () => {
|
||||
over.getShareChatModel ?? jest.fn().mockResolvedValue('MODEL'),
|
||||
tryConsumeWorkspaceQuota:
|
||||
over.tryConsumeWorkspaceQuota ?? jest.fn().mockResolvedValue(true),
|
||||
withinShareTokenBudget:
|
||||
over.withinShareTokenBudget ?? jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
const deps: ShareAssistantDeps = {
|
||||
aiSettings: aiSettings as never,
|
||||
@@ -191,6 +194,39 @@ describe('resolveShareAssistantRequest (extracted controller funnel)', () => {
|
||||
expect(publicShareChat.tryConsumeWorkspaceQuota).toHaveBeenCalledWith('ws-1');
|
||||
});
|
||||
|
||||
it('withinShareTokenBudget false => 429 thrown BEFORE any stream (cost cap, #159 #5)', async () => {
|
||||
const { deps, publicShareChat } = makeDeps({
|
||||
withinShareTokenBudget: jest.fn().mockResolvedValue(false),
|
||||
});
|
||||
expect(await statusOf(deps, body())).toBe(429);
|
||||
expect(publicShareChat.withinShareTokenBudget).toHaveBeenCalledWith('ws-1');
|
||||
// The token budget is the COST backstop: an over-budget workspace must be
|
||||
// rejected WITHOUT consuming a request slot, so the request cap never runs.
|
||||
expect(publicShareChat.tryConsumeWorkspaceQuota).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('the token budget is checked BEFORE the request cap (over-budget wins, no slot spent)', async () => {
|
||||
// Over budget AND the request cap would also reject: the read-only budget
|
||||
// gate must win so the (mutating) request-slot consume is never reached.
|
||||
const { deps, publicShareChat } = makeDeps({
|
||||
withinShareTokenBudget: jest.fn().mockResolvedValue(false),
|
||||
tryConsumeWorkspaceQuota: jest.fn().mockResolvedValue(false),
|
||||
});
|
||||
expect(await statusOf(deps, body())).toBe(429);
|
||||
expect(publicShareChat.tryConsumeWorkspaceQuota).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('the token-budget gate is checked BEFORE the payload caps (429 wins over 413)', async () => {
|
||||
const { deps } = makeDeps({
|
||||
withinShareTokenBudget: jest.fn().mockResolvedValue(false),
|
||||
});
|
||||
const huge = {
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'x'.repeat(MAX_SHARE_MESSAGE_CHARS + 1) }],
|
||||
};
|
||||
expect(await statusOf(deps, body({ messages: [huge] }))).toBe(429);
|
||||
});
|
||||
|
||||
it('messages over MAX_SHARE_MESSAGES => 413', async () => {
|
||||
const { deps } = makeDeps();
|
||||
const tooMany = Array.from({ length: MAX_SHARE_MESSAGES + 1 }, () => ({
|
||||
|
||||
@@ -151,6 +151,7 @@ export interface ShareAssistantDeps {
|
||||
| 'resolveShareRole'
|
||||
| 'getShareChatModel'
|
||||
| 'tryConsumeWorkspaceQuota'
|
||||
| 'withinShareTokenBudget'
|
||||
>;
|
||||
}
|
||||
|
||||
@@ -267,9 +268,21 @@ export async function resolveShareAssistantRequest(
|
||||
throw new NotFoundException('Not found');
|
||||
}
|
||||
|
||||
// 5. Per-WORKSPACE anti-abuse cap (IP-independent; defense in depth). Checked
|
||||
// BEFORE res.hijack(), so an over-cap workspace gets a clean 429 and spends
|
||||
// nothing.
|
||||
// 5a. Per-WORKSPACE rolling-day TOKEN budget (the COST backstop). Read-only and
|
||||
// checked FIRST so a workspace that has already burned its day's token
|
||||
// budget gets a clean 429 WITHOUT consuming a request slot, and spends
|
||||
// nothing. Counting requests alone does not bound the owner's provider
|
||||
// bill (issue #159, finding #5).
|
||||
if (!(await deps.publicShareChat.withinShareTokenBudget(workspaceId))) {
|
||||
throw new HttpException(
|
||||
'This documentation assistant has reached its usage budget. Please try again later.',
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
|
||||
// 5b. Per-WORKSPACE anti-abuse request cap (IP-independent; defense in depth).
|
||||
// Checked BEFORE res.hijack(), so an over-cap workspace gets a clean 429
|
||||
// and spends nothing.
|
||||
if (!(await deps.publicShareChat.tryConsumeWorkspaceQuota(workspaceId))) {
|
||||
throw new HttpException(
|
||||
'This documentation assistant is temporarily busy. Please try again later.',
|
||||
|
||||
@@ -17,7 +17,9 @@ import { buildShareSystemPrompt } from './public-share-chat.prompt';
|
||||
import { roleModelOverride } from './roles/role-model-config';
|
||||
import {
|
||||
PublicShareWorkspaceLimiter,
|
||||
PublicShareWorkspaceTokenBudget,
|
||||
createPublicShareWorkspaceLimiter,
|
||||
createPublicShareWorkspaceTokenBudget,
|
||||
} from './public-share-workspace-limiter';
|
||||
import { describeProviderError } from '../../integrations/ai/ai-error.util';
|
||||
import {
|
||||
@@ -125,6 +127,16 @@ export class PublicShareChatService {
|
||||
*/
|
||||
private readonly workspaceLimiter: PublicShareWorkspaceLimiter;
|
||||
|
||||
/**
|
||||
* COST contour two: a per-workspace TOKEN budget over a rolling day. The
|
||||
* request-count limiter above bounds how many anonymous calls run; this bounds
|
||||
* how many provider TOKENS they spend (input re-sent per step + output),
|
||||
* which is what the owner is actually billed for (issue #159, finding #5).
|
||||
* Checked read-only before a turn streams; the real usage is recorded once the
|
||||
* turn finishes (`onFinish`).
|
||||
*/
|
||||
private readonly tokenBudget: PublicShareWorkspaceTokenBudget;
|
||||
|
||||
constructor(
|
||||
private readonly ai: AiService,
|
||||
private readonly aiSettings: AiSettingsService,
|
||||
@@ -133,6 +145,7 @@ export class PublicShareChatService {
|
||||
private readonly aiAgentRoleRepo: AiAgentRoleRepo,
|
||||
) {
|
||||
this.workspaceLimiter = createPublicShareWorkspaceLimiter(redisService);
|
||||
this.tokenBudget = createPublicShareWorkspaceTokenBudget(redisService);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,6 +157,48 @@ export class PublicShareChatService {
|
||||
return this.workspaceLimiter.tryConsume(workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only pre-stream COST gate: true while the workspace is under its
|
||||
* rolling-day token budget, false once the trailing-day token spend has
|
||||
* reached it (the controller must then 429 BEFORE starting the stream). This
|
||||
* bounds the owner's actual provider bill, which counting requests alone does
|
||||
* not (issue #159, finding #5).
|
||||
*/
|
||||
async withinShareTokenBudget(workspaceId: string): Promise<boolean> {
|
||||
return this.tokenBudget.withinBudget(workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a finished turn's real token spend against the rolling-day budget.
|
||||
* Best-effort (the turn already ran): failures are swallowed by the budget.
|
||||
*/
|
||||
async recordShareTokens(workspaceId: string, tokens: number): Promise<void> {
|
||||
return this.tokenBudget.record(workspaceId, tokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* `streamText` onFinish hook body: account a finished turn's REAL token spend
|
||||
* (input re-sent per step + output, summed across all steps) against the
|
||||
* per-workspace rolling-day budget, so a future turn over budget is rejected up
|
||||
* front (issue #159, finding #5). `totalUsage` fields are `number | undefined`;
|
||||
* fall back to the sum of input+output when the provider omits `totalTokens`.
|
||||
* Fire-and-forget: the turn already streamed, so a record failure must not
|
||||
* break it.
|
||||
*/
|
||||
recordTurnUsage(
|
||||
workspaceId: string,
|
||||
totalUsage: {
|
||||
totalTokens?: number;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
},
|
||||
): void {
|
||||
const tokens =
|
||||
totalUsage.totalTokens ??
|
||||
(totalUsage.inputTokens ?? 0) + (totalUsage.outputTokens ?? 0);
|
||||
void this.recordShareTokens(workspaceId, tokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the admin-selected agent role for the anonymous public-share
|
||||
* assistant, scoped to the workspace and soft-delete aware. Returns null when
|
||||
@@ -231,6 +286,8 @@ export class PublicShareChatService {
|
||||
// bill even if the per-IP throttle is evaded; worst case = steps × this.
|
||||
maxOutputTokens: resolveShareAiMaxOutputTokens(),
|
||||
abortSignal: signal,
|
||||
onFinish: ({ totalUsage }) =>
|
||||
this.recordTurnUsage(workspaceId, totalUsage),
|
||||
onError: ({ error }) => {
|
||||
// Reuse the shared formatter so provider error formatting stays
|
||||
// unified (statusCode + body) with the authenticated path.
|
||||
|
||||
@@ -11,8 +11,11 @@ import {
|
||||
import { PublicShareChatToolsService } from './tools/public-share-chat-tools.service';
|
||||
import {
|
||||
PublicShareWorkspaceLimiter,
|
||||
PublicShareWorkspaceTokenBudget,
|
||||
resolveShareAiWorkspaceMax,
|
||||
resolveShareAiWorkspaceTokenBudget,
|
||||
SHARE_AI_WORKSPACE_MAX_PER_WINDOW,
|
||||
SHARE_AI_WORKSPACE_TOKEN_BUDGET_DEFAULT,
|
||||
} from './public-share-workspace-limiter';
|
||||
|
||||
/**
|
||||
@@ -546,6 +549,228 @@ describe('PublicShareWorkspaceLimiter (cluster-wide sliding-window per-workspace
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* In-memory fake of the ioredis slice the TOKEN budget uses. Unlike the request
|
||||
* limiter (one Lua), the budget runs TWO scripts over the same sorted set:
|
||||
* - the read-only CHECK (sums the token counts encoded as each member's leading
|
||||
* integer, admits while the sum is under budget, never mutates), and
|
||||
* - the RECORD (ZADDs a finished turn's `<tokens>:<unique>` member).
|
||||
* The fake faithfully reproduces both (branching on the script body) so the spec
|
||||
* exercises the REAL budget math, not a re-implementation.
|
||||
*/
|
||||
class FakeTokenRedis {
|
||||
private sets = new Map<string, Array<{ score: number; member: string }>>();
|
||||
|
||||
async eval(
|
||||
script: string,
|
||||
_numKeys: number,
|
||||
key: string,
|
||||
nowStr: string,
|
||||
windowMsStr: string,
|
||||
arg3: string,
|
||||
): Promise<number> {
|
||||
const now = Number(nowStr);
|
||||
const windowMs = Number(windowMsStr);
|
||||
const cutoff = now - windowMs;
|
||||
const arr = (this.sets.get(key) ?? []).filter((e) => e.score > cutoff);
|
||||
if (script.includes('ZADD')) {
|
||||
// RECORD: arg3 is the `<tokens>:<unique>` member; append at score=now.
|
||||
arr.push({ score: now, member: arg3 });
|
||||
this.sets.set(key, arr);
|
||||
return 1;
|
||||
}
|
||||
// CHECK: arg3 is the budget; sum the leading integer of each survivor.
|
||||
const budget = Number(arg3);
|
||||
this.sets.set(key, arr);
|
||||
const total = arr.reduce((sum, e) => {
|
||||
const m = /^(\d+)/.exec(e.member);
|
||||
return sum + (m ? Number(m[1]) : 0);
|
||||
}, 0);
|
||||
return total >= budget ? 0 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
function makeTokenBudget(budget: number, windowMs: number, clock: () => number) {
|
||||
const redis = new FakeTokenRedis() as unknown as import('ioredis').Redis;
|
||||
return new PublicShareWorkspaceTokenBudget(redis, budget, windowMs, clock);
|
||||
}
|
||||
|
||||
describe('resolveShareAiWorkspaceTokenBudget (env-overridable per-day token budget)', () => {
|
||||
const KEY = 'SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY';
|
||||
const saved = process.env[KEY];
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env[KEY];
|
||||
else process.env[KEY] = saved;
|
||||
});
|
||||
|
||||
it('falls back to the default when unset', () => {
|
||||
delete process.env[KEY];
|
||||
expect(resolveShareAiWorkspaceTokenBudget()).toBe(
|
||||
SHARE_AI_WORKSPACE_TOKEN_BUDGET_DEFAULT,
|
||||
);
|
||||
});
|
||||
|
||||
it('honors a positive override', () => {
|
||||
process.env[KEY] = '250000';
|
||||
expect(resolveShareAiWorkspaceTokenBudget()).toBe(250000);
|
||||
});
|
||||
|
||||
it('ignores a non-positive / unparseable value (uses the default)', () => {
|
||||
for (const bad of ['0', '-5', 'nope', '']) {
|
||||
process.env[KEY] = bad;
|
||||
expect(resolveShareAiWorkspaceTokenBudget()).toBe(
|
||||
SHARE_AI_WORKSPACE_TOKEN_BUDGET_DEFAULT,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('PublicShareWorkspaceTokenBudget (cluster-wide rolling-day token cap)', () => {
|
||||
it('admits while under budget and rejects once the recorded spend reaches it', async () => {
|
||||
const budget = makeTokenBudget(1000, 60_000, () => 1_000);
|
||||
expect(await budget.withinBudget('ws-1')).toBe(true); // nothing spent yet
|
||||
await budget.record('ws-1', 600);
|
||||
expect(await budget.withinBudget('ws-1')).toBe(true); // 600 < 1000
|
||||
await budget.record('ws-1', 400);
|
||||
// 1000 >= 1000: the budget is exhausted, so the next turn is rejected up front.
|
||||
expect(await budget.withinBudget('ws-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('counts TOKENS, not requests: one fat turn can exhaust the budget alone', async () => {
|
||||
const budget = makeTokenBudget(1000, 60_000, () => 1_000);
|
||||
// A single accepted turn re-sends the whole transcript across 5 steps; here
|
||||
// it lands as 1200 tokens — already over the day budget on its own.
|
||||
await budget.record('ws-1', 1200);
|
||||
expect(await budget.withinBudget('ws-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('ages out spend older than the window so the budget recovers', async () => {
|
||||
let now = 0;
|
||||
const budget = makeTokenBudget(1000, 60_000, () => now);
|
||||
await budget.record('ws-1', 1000); // at budget
|
||||
now += 59_999; // still inside the day window
|
||||
expect(await budget.withinBudget('ws-1')).toBe(false);
|
||||
now += 2; // the spend is now strictly older than windowMs
|
||||
expect(await budget.withinBudget('ws-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores non-positive / non-finite usage (never records phantom spend)', async () => {
|
||||
const budget = makeTokenBudget(1000, 60_000, () => 1_000);
|
||||
await budget.record('ws-1', 0);
|
||||
await budget.record('ws-1', -50);
|
||||
await budget.record('ws-1', Number.NaN);
|
||||
await budget.record('ws-1', Infinity);
|
||||
expect(await budget.withinBudget('ws-1')).toBe(true); // nothing accumulated
|
||||
});
|
||||
|
||||
it('keeps separate budgets per workspace', async () => {
|
||||
const budget = makeTokenBudget(500, 60_000, () => 1_000);
|
||||
await budget.record('ws-a', 500); // ws-a exhausted
|
||||
expect(await budget.withinBudget('ws-a')).toBe(false);
|
||||
expect(await budget.withinBudget('ws-b')).toBe(true); // ws-b untouched
|
||||
});
|
||||
|
||||
it('FAILS CLOSED on the read-only check when Redis rejects', async () => {
|
||||
const failingRedis = {
|
||||
eval: () => Promise.reject(new Error('redis down')),
|
||||
} as unknown as import('ioredis').Redis;
|
||||
const budget = new PublicShareWorkspaceTokenBudget(
|
||||
failingRedis,
|
||||
1000,
|
||||
60_000,
|
||||
() => 1_000,
|
||||
);
|
||||
const errSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
expect(await budget.withinBudget('ws-1')).toBe(false);
|
||||
expect(errSpy).toHaveBeenCalled();
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('SWALLOWS a record failure (best-effort post-accounting, never throws)', async () => {
|
||||
// The turn already streamed; a record failure must not surface to the caller.
|
||||
const failingRedis = {
|
||||
eval: () => Promise.reject(new Error('redis down')),
|
||||
} as unknown as import('ioredis').Redis;
|
||||
const budget = new PublicShareWorkspaceTokenBudget(
|
||||
failingRedis,
|
||||
1000,
|
||||
60_000,
|
||||
() => 1_000,
|
||||
);
|
||||
const errSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
await expect(budget.record('ws-1', 100)).resolves.toBeUndefined();
|
||||
expect(errSpy).toHaveBeenCalled();
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PublicShareChatService.withinShareTokenBudget / recordShareTokens', () => {
|
||||
it('delegates the cost gate + accounting to the redis-backed token budget', async () => {
|
||||
const redis = new FakeTokenRedis();
|
||||
const redisService = { getOrThrow: () => redis } as never;
|
||||
const service = new PublicShareChatService(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
redisService,
|
||||
{} as never,
|
||||
);
|
||||
// Default budget is large, so a fresh workspace is under budget; recording a
|
||||
// modest spend keeps it under budget (asserts the wiring the controller +
|
||||
// onFinish rely on).
|
||||
expect(await service.withinShareTokenBudget('ws-1')).toBe(true);
|
||||
await service.recordShareTokens('ws-1', 1234);
|
||||
expect(await service.withinShareTokenBudget('ws-1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PublicShareChatService.recordTurnUsage (streamText onFinish accounting)', () => {
|
||||
function makeService() {
|
||||
const redisService = { getOrThrow: () => new FakeTokenRedis() } as never;
|
||||
const service = new PublicShareChatService(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
redisService,
|
||||
{} as never,
|
||||
);
|
||||
const recordSpy = jest
|
||||
.spyOn(service, 'recordShareTokens')
|
||||
.mockResolvedValue(undefined);
|
||||
return { service, recordSpy };
|
||||
}
|
||||
|
||||
it('sums input+output when the provider omits totalTokens', () => {
|
||||
const { service, recordSpy } = makeService();
|
||||
// The onFinish payload shape: a totalUsage with per-component counts but no
|
||||
// authoritative total (provider omitted it).
|
||||
service.recordTurnUsage('ws-1', { inputTokens: 1200, outputTokens: 300 });
|
||||
expect(recordSpy).toHaveBeenCalledWith('ws-1', 1500);
|
||||
});
|
||||
|
||||
it('treats missing input/output components as 0 in the fallback sum', () => {
|
||||
const { service, recordSpy } = makeService();
|
||||
service.recordTurnUsage('ws-1', { outputTokens: 42 });
|
||||
expect(recordSpy).toHaveBeenCalledWith('ws-1', 42);
|
||||
});
|
||||
|
||||
it('prefers the authoritative totalTokens when present (not the sum)', () => {
|
||||
const { service, recordSpy } = makeService();
|
||||
// totalTokens is the provider's authoritative figure and may differ from a
|
||||
// naive input+output sum (e.g. cached/ reasoning tokens); it must win.
|
||||
service.recordTurnUsage('ws-1', {
|
||||
totalTokens: 5000,
|
||||
inputTokens: 1200,
|
||||
outputTokens: 300,
|
||||
});
|
||||
expect(recordSpy).toHaveBeenCalledWith('ws-1', 5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PublicShareChatService.tryConsumeWorkspaceQuota', () => {
|
||||
it('delegates to the redis-backed per-workspace limiter', async () => {
|
||||
const redis = new FakeRedis();
|
||||
|
||||
@@ -136,6 +136,177 @@ export class PublicShareWorkspaceLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SECOND cost contour: a per-workspace TOKEN budget over a rolling DAY.
|
||||
*
|
||||
* The request-count cap above bounds how MANY anonymous calls a workspace
|
||||
* admits, but NOT how expensive each one is: one accepted call runs the agent
|
||||
* loop up to `stepCountIs(5)`, and every step re-sends the WHOLE client-held
|
||||
* transcript (~hundreds of KB) as input, so the provider input alone can be tens
|
||||
* of thousands of tokens PER step while `maxOutputTokens` only caps the output.
|
||||
* The request cap is also hourly with no daily ceiling, so a steady stream at
|
||||
* the hourly cap sustains ~24x its count per day. Counting requests therefore
|
||||
* does not bound the owner's actual LLM bill (issue #159, finding #5).
|
||||
*
|
||||
* This contour caps the SPEND directly: the actual tokens consumed (input +
|
||||
* output, summed across all steps of every accepted turn) over the trailing
|
||||
* `windowMs` (one rolling day) must stay under `budget`. It is checked BEFORE a
|
||||
* turn streams (read-only) and the turn's real usage is recorded AFTER it
|
||||
* finishes (`streamText` onFinish). Like the request cap it is cluster-wide
|
||||
* (shared Redis) and uses a sliding-window LOG so the day boundary cannot be
|
||||
* gamed for a 2x burst.
|
||||
*
|
||||
* Pre-check is read-only, so a turn already over budget is rejected, but the
|
||||
* tokens of an in-flight turn are not yet known and are accounted only once it
|
||||
* finishes. The worst-case overshoot past the budget is therefore one turn
|
||||
* (bounded by steps x (maxOutputTokens + transcript size)) — acceptable for a
|
||||
* cost backstop on an optional anonymous assistant.
|
||||
*/
|
||||
|
||||
/** Default per-workspace token budget over the rolling day. */
|
||||
export const SHARE_AI_WORKSPACE_TOKEN_BUDGET_DEFAULT = 1_000_000;
|
||||
/** Default token-budget window length: one rolling day. */
|
||||
export const SHARE_AI_WORKSPACE_TOKEN_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Redis key namespace for the per-workspace token-spend sliding-window log. */
|
||||
const TOKEN_KEY_PREFIX = 'share-ai:ws-tokens:';
|
||||
|
||||
/**
|
||||
* Read-only sliding-window token-budget check.
|
||||
*
|
||||
* KEYS[1] = the per-workspace token sorted-set key
|
||||
* ARGV[1] = now (epoch ms)
|
||||
* ARGV[2] = windowMs
|
||||
* ARGV[3] = budget (max tokens in the trailing window)
|
||||
*
|
||||
* Drops entries older than the window, then sums the token counts encoded as the
|
||||
* leading integer of each surviving member. Returns 1 if the running total is
|
||||
* still UNDER budget (admit), 0 once it has reached/exceeded the budget. Does NOT
|
||||
* add anything — the turn's real usage is recorded separately once it finishes.
|
||||
*/
|
||||
const TOKEN_BUDGET_CHECK_LUA = `
|
||||
local key = KEYS[1]
|
||||
local now = tonumber(ARGV[1])
|
||||
local windowMs = tonumber(ARGV[2])
|
||||
local budget = tonumber(ARGV[3])
|
||||
redis.call('ZREMRANGEBYSCORE', key, 0, now - windowMs)
|
||||
local members = redis.call('ZRANGE', key, 0, -1)
|
||||
local total = 0
|
||||
for i = 1, #members do
|
||||
local t = tonumber(string.match(members[i], '^(%d+)'))
|
||||
if t then total = total + t end
|
||||
end
|
||||
if total >= budget then
|
||||
return 0
|
||||
end
|
||||
return 1
|
||||
`;
|
||||
|
||||
/**
|
||||
* Record one finished turn's token spend in the sliding-window log.
|
||||
*
|
||||
* KEYS[1] = the per-workspace token sorted-set key
|
||||
* ARGV[1] = now (epoch ms) — the entry score
|
||||
* ARGV[2] = windowMs
|
||||
* ARGV[3] = member (`<tokens>:<unique>`; the leading integer is the token count)
|
||||
*
|
||||
* Always ZADDs (the turn already ran and spent the tokens) and refreshes the
|
||||
* key TTL so idle workspaces cost no memory. Trims expired entries first so the
|
||||
* set never grows unbounded for a busy workspace.
|
||||
*/
|
||||
const TOKEN_RECORD_LUA = `
|
||||
local key = KEYS[1]
|
||||
local now = tonumber(ARGV[1])
|
||||
local windowMs = tonumber(ARGV[2])
|
||||
local member = ARGV[3]
|
||||
redis.call('ZREMRANGEBYSCORE', key, 0, now - windowMs)
|
||||
redis.call('ZADD', key, now, member)
|
||||
redis.call('PEXPIRE', key, windowMs)
|
||||
return 1
|
||||
`;
|
||||
|
||||
/**
|
||||
* Cluster-wide, sliding-window per-workspace TOKEN budget backed by Redis.
|
||||
* `withinBudget(key)` is a read-only pre-stream gate; `record(key, tokens)`
|
||||
* accounts a finished turn's real usage. Decoupled from NestJS so it is testable
|
||||
* against a mocked/real ioredis client, mirroring the request-count limiter.
|
||||
*/
|
||||
export class PublicShareWorkspaceTokenBudget {
|
||||
private readonly logger = new Logger(PublicShareWorkspaceTokenBudget.name);
|
||||
private counter = 0;
|
||||
|
||||
constructor(
|
||||
private readonly redis: Redis,
|
||||
private readonly budget: number = SHARE_AI_WORKSPACE_TOKEN_BUDGET_DEFAULT,
|
||||
private readonly windowMs: number = SHARE_AI_WORKSPACE_TOKEN_WINDOW_MS,
|
||||
private readonly now: () => number = Date.now,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Read-only pre-stream check. Returns true while the workspace is under its
|
||||
* rolling-day token budget, false once the trailing-window spend has reached
|
||||
* it (caller must then 429 BEFORE streaming any tokens).
|
||||
*
|
||||
* FAILS CLOSED (false) on a Redis error: identical reasoning to the request
|
||||
* limiter — when we cannot prove the workspace is under budget we DENY rather
|
||||
* than admit an unmetered billable call. The assistant is optional, so a
|
||||
* transient Redis blip briefly disabling it beats an unbounded provider bill.
|
||||
*/
|
||||
async withinBudget(key: string): Promise<boolean> {
|
||||
const t = this.now();
|
||||
try {
|
||||
const admitted = await this.redis.eval(
|
||||
TOKEN_BUDGET_CHECK_LUA,
|
||||
1,
|
||||
TOKEN_KEY_PREFIX + key,
|
||||
String(t),
|
||||
String(this.windowMs),
|
||||
String(this.budget),
|
||||
);
|
||||
return admitted === 1;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`share-ai token budget Redis failure for key "${key}"; failing closed`,
|
||||
err as Error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a finished turn's token spend. Best-effort: the turn already ran, so
|
||||
* a Redis failure here is logged but not propagated — it would only cause a
|
||||
* slight under-count of the running budget, never a wrong answer to the
|
||||
* caller. Non-positive / non-finite usage is ignored.
|
||||
*/
|
||||
async record(key: string, tokens: number): Promise<void> {
|
||||
if (!Number.isFinite(tokens) || tokens <= 0) return;
|
||||
const spend = Math.floor(tokens);
|
||||
const t = this.now();
|
||||
// Member: `<tokens>:<unique>` — the check Lua sums the leading integer, and
|
||||
// the unique suffix keeps distinct turns in the same ms from colliding on
|
||||
// the sorted-set member (which would drop one entry and under-count).
|
||||
const member = `${spend}:${t}-${this.counter++}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2)}`;
|
||||
try {
|
||||
await this.redis.eval(
|
||||
TOKEN_RECORD_LUA,
|
||||
1,
|
||||
TOKEN_KEY_PREFIX + key,
|
||||
String(t),
|
||||
String(this.windowMs),
|
||||
member,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`share-ai token budget record failure for key "${key}" (${spend} tokens); ignoring`,
|
||||
err as Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the per-workspace cap from the environment (overridable seam), falling
|
||||
* back to the sane default. A non-positive / unparseable value uses the default.
|
||||
@@ -162,3 +333,31 @@ export function createPublicShareWorkspaceLimiter(
|
||||
SHARE_AI_WORKSPACE_WINDOW_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the per-workspace rolling-day token budget from the environment
|
||||
* (overridable seam), falling back to the sane default. A non-positive /
|
||||
* unparseable value uses the default.
|
||||
*/
|
||||
export function resolveShareAiWorkspaceTokenBudget(): number {
|
||||
const raw = Number(process.env.SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY);
|
||||
return Number.isFinite(raw) && raw > 0
|
||||
? Math.floor(raw)
|
||||
: SHARE_AI_WORKSPACE_TOKEN_BUDGET_DEFAULT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the per-workspace token budget from the injected RedisService (the same
|
||||
* global ioredis client used by the request-count limiter). Tiny factory so the
|
||||
* service constructor stays declarative and the budget stays unit-testable with
|
||||
* a hand-rolled fake redis.
|
||||
*/
|
||||
export function createPublicShareWorkspaceTokenBudget(
|
||||
redisService: RedisService,
|
||||
): PublicShareWorkspaceTokenBudget {
|
||||
return new PublicShareWorkspaceTokenBudget(
|
||||
redisService.getOrThrow(),
|
||||
resolveShareAiWorkspaceTokenBudget(),
|
||||
SHARE_AI_WORKSPACE_TOKEN_WINDOW_MS,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,18 +120,25 @@ describe('AiChatToolsService deletePage guardrail (H4)', () => {
|
||||
const tools = await buildTools();
|
||||
const deletePage = tools.deletePage;
|
||||
|
||||
// The Zod input schema only allows `pageId`; parsing strips/ignores extra
|
||||
// keys, so a permanent/force flag is never part of the validated input.
|
||||
// The wrapped input schema (modelFriendlyInput) only allows `pageId`;
|
||||
// validation strips/ignores extra keys, so a permanent/force flag is never
|
||||
// part of the validated input handed to execute.
|
||||
const schema = (deletePage as unknown as { inputSchema: unknown })
|
||||
.inputSchema as {
|
||||
parse: (v: unknown) => Record<string, unknown>;
|
||||
validate: (
|
||||
v: unknown,
|
||||
) =>
|
||||
| { success: boolean; value?: Record<string, unknown> }
|
||||
| Promise<{ success: boolean; value?: Record<string, unknown> }>;
|
||||
};
|
||||
const parsed = schema.parse({
|
||||
const result = await schema.validate({
|
||||
pageId: 'page-789',
|
||||
permanentlyDelete: true,
|
||||
forceDelete: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const parsed = result.value as Record<string, unknown>;
|
||||
expect(parsed).toHaveProperty('pageId', 'page-789');
|
||||
expect(parsed).not.toHaveProperty('permanentlyDelete');
|
||||
expect(parsed).not.toHaveProperty('forceDelete');
|
||||
@@ -207,19 +214,26 @@ describe('AiChatToolsService expanded toolset guardrails', () => {
|
||||
const tools = await buildTools();
|
||||
const transformPage = tools.transformPage;
|
||||
|
||||
// The Zod input schema only allows pageId/transformJs/dryRun; parsing
|
||||
// strips unknown keys, so deleteComments can never reach the client.
|
||||
// The wrapped input schema only allows pageId/transformJs/dryRun;
|
||||
// validation strips unknown keys, so deleteComments can never reach the
|
||||
// client.
|
||||
const schema = (transformPage as unknown as { inputSchema: unknown })
|
||||
.inputSchema as {
|
||||
parse: (v: unknown) => Record<string, unknown>;
|
||||
validate: (
|
||||
v: unknown,
|
||||
) =>
|
||||
| { success: boolean; value?: Record<string, unknown> }
|
||||
| Promise<{ success: boolean; value?: Record<string, unknown> }>;
|
||||
};
|
||||
const parsed = schema.parse({
|
||||
const result = await schema.validate({
|
||||
pageId: 'p',
|
||||
transformJs: '(d)=>d',
|
||||
dryRun: true,
|
||||
deleteComments: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const parsed = result.value as Record<string, unknown>;
|
||||
expect(parsed).toHaveProperty('pageId', 'p');
|
||||
expect(parsed).not.toHaveProperty('deleteComments');
|
||||
});
|
||||
@@ -395,3 +409,95 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
|
||||
expect(updatePageJsonCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Model-friendly tool-call validation (#190): when the model drops a required
|
||||
* `pageId` in a parallel/batch tool call, the built-in input schema must return
|
||||
* a CLEAR, actionable message (naming the parameter, reminding it not to drop
|
||||
* ids in batches) instead of zod's raw "expected string, received undefined" —
|
||||
* while a valid call still validates. This is wired centrally via
|
||||
* modelFriendlyInput, so it applies to every in-app tool; createComment (the
|
||||
* tool from the bug report) and a sharedTool-built tool (getPage's sibling
|
||||
* getOutline) are exercised here end-to-end through forUser().
|
||||
*/
|
||||
describe('AiChatToolsService model-friendly input validation (#190)', () => {
|
||||
const fakeClient: Partial<DocmostClientLike> = {};
|
||||
const tokenServiceStub = {
|
||||
generateAccessToken: jest.fn().mockResolvedValue('access-token'),
|
||||
generateCollabToken: jest.fn().mockResolvedValue('collab-token'),
|
||||
};
|
||||
let service: AiChatToolsService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
|
||||
mockLoaded(function () {
|
||||
return fakeClient as DocmostClientLike;
|
||||
} as unknown as loader.DocmostClientCtor),
|
||||
);
|
||||
service = new AiChatToolsService(
|
||||
tokenServiceStub as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
function buildTools() {
|
||||
return service.forUser(
|
||||
{ id: 'user-1', email: 'u@example.com', workspaceId: 'ws-1' } as never,
|
||||
'session-1',
|
||||
'ws-1',
|
||||
'chat-1',
|
||||
);
|
||||
}
|
||||
|
||||
// The AI SDK Schema produced by modelFriendlyInput exposes `validate`.
|
||||
type ValidatableSchema = {
|
||||
validate: (
|
||||
v: unknown,
|
||||
) =>
|
||||
| { success: boolean; value?: unknown; error?: Error }
|
||||
| Promise<{ success: boolean; value?: unknown; error?: Error }>;
|
||||
};
|
||||
const inputSchemaOf = (t: unknown) =>
|
||||
(t as { inputSchema: unknown }).inputSchema as ValidatableSchema;
|
||||
|
||||
it('createComment: a dropped pageId yields a clear, model-actionable message', async () => {
|
||||
const tools = await buildTools();
|
||||
// The exact failing shape from the bug report's second parallel batch:
|
||||
// content + selection, but pageId silently dropped.
|
||||
const result = await inputSchemaOf(tools.createComment).validate({
|
||||
content: 'A remark',
|
||||
selection: 'титановый проводник',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.message).toContain('parameter "pageId": missing (required)');
|
||||
expect(result.error?.message).toContain('parallel/batch tool calls');
|
||||
// Not the raw zod text the model previously received.
|
||||
expect(result.error?.message).not.toContain('received undefined');
|
||||
});
|
||||
|
||||
it('createComment: a valid call with pageId validates successfully', async () => {
|
||||
const tools = await buildTools();
|
||||
const result = await inputSchemaOf(tools.createComment).validate({
|
||||
pageId: '019efe44-0000-0000-0000-000000000000',
|
||||
content: 'A remark',
|
||||
selection: 'титановый проводник',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.value).toMatchObject({
|
||||
pageId: '019efe44-0000-0000-0000-000000000000',
|
||||
content: 'A remark',
|
||||
});
|
||||
});
|
||||
|
||||
it('sharedTool-built tools (getOutline) also get the friendly message on a dropped pageId', async () => {
|
||||
const tools = await buildTools();
|
||||
const result = await inputSchemaOf(tools.getOutline).validate({});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.message).toContain('parameter "pageId": missing (required)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from './docmost-client.loader';
|
||||
import { resolveCurrentPageResult } from './current-page.util';
|
||||
import { parseNodeArg } from './parse-node-arg';
|
||||
import { modelFriendlyInput } from './model-friendly-input';
|
||||
|
||||
/**
|
||||
* Per-user, per-request adapter that exposes Docmost READ operations to the
|
||||
@@ -102,9 +103,13 @@ export class AiChatToolsService {
|
||||
): Tool =>
|
||||
tool({
|
||||
description: spec.description,
|
||||
inputSchema: spec.buildShape
|
||||
? z.object(spec.buildShape(z) as z.ZodRawShape)
|
||||
: z.object({}),
|
||||
// Wrap via modelFriendlyInput so a dropped/invalid parameter (e.g. a
|
||||
// pageId omitted in a parallel batch, #190) yields a clear, actionable
|
||||
// tool error instead of zod's raw text. No-arg specs still get an empty
|
||||
// object schema.
|
||||
inputSchema: modelFriendlyInput(
|
||||
spec.buildShape ? (spec.buildShape(z) as z.ZodRawShape) : {},
|
||||
),
|
||||
execute,
|
||||
});
|
||||
|
||||
@@ -118,7 +123,7 @@ export class AiChatToolsService {
|
||||
'and entities), not a full sentence. If the first results look weak ' +
|
||||
'or incomplete, search again with different wording or synonyms ' +
|
||||
'before answering.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: modelFriendlyInput({
|
||||
query: z.string().describe('The search query.'),
|
||||
limit: z
|
||||
.number()
|
||||
@@ -227,7 +232,7 @@ export class AiChatToolsService {
|
||||
'"the current page", or "here" refers to. Returns the page id and title, ' +
|
||||
'or null if the user is not currently on a page. Call this first whenever ' +
|
||||
'the user refers to the current page without giving an explicit id.',
|
||||
inputSchema: z.object({}),
|
||||
inputSchema: modelFriendlyInput({}),
|
||||
execute: async () => resolveCurrentPageResult(openedPage),
|
||||
}),
|
||||
|
||||
@@ -235,7 +240,7 @@ export class AiChatToolsService {
|
||||
description:
|
||||
'Fetch a single page as Markdown by its page id. Returns the page ' +
|
||||
'title and its Markdown content.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id (or slugId) of the page.'),
|
||||
}),
|
||||
execute: async ({ pageId }) => {
|
||||
@@ -259,7 +264,7 @@ export class AiChatToolsService {
|
||||
'Create a new page with a Markdown body in a space, optionally under ' +
|
||||
'a parent page. Returns the new page id and title. Reversible: a page ' +
|
||||
'can be moved to trash later.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: modelFriendlyInput({
|
||||
title: z.string().describe('The title of the new page.'),
|
||||
content: z
|
||||
.string()
|
||||
@@ -294,7 +299,7 @@ export class AiChatToolsService {
|
||||
description:
|
||||
"Replace a page's body with new Markdown content (and optionally its " +
|
||||
'title). Reversible: the previous version is kept in page history.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page to update.'),
|
||||
content: z.string().describe('The new page body as Markdown.'),
|
||||
title: z
|
||||
@@ -316,7 +321,7 @@ export class AiChatToolsService {
|
||||
description:
|
||||
"Rename a page (change its title only; the body is untouched). " +
|
||||
'Reversible: rename back at any time.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page to rename.'),
|
||||
title: z.string().describe('The new title.'),
|
||||
}),
|
||||
@@ -331,7 +336,7 @@ export class AiChatToolsService {
|
||||
description:
|
||||
'Move a page under a new parent page, or to the space root when no ' +
|
||||
'parent is given. Reversible: move it back at any time.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page to move.'),
|
||||
parentPageId: z
|
||||
.string()
|
||||
@@ -353,7 +358,7 @@ export class AiChatToolsService {
|
||||
description:
|
||||
'Move a page to the trash (SOFT delete only — fully reversible; the ' +
|
||||
'page can be restored from trash). This NEVER permanently deletes.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page to move to trash.'),
|
||||
}),
|
||||
// GUARDRAIL (§14 H4): the only field ever passed to the client is
|
||||
@@ -379,7 +384,7 @@ export class AiChatToolsService {
|
||||
'"selection not found" error, retry with a corrected EXACT selection ' +
|
||||
'copied verbatim from a single paragraph/block. Reversible via the ' +
|
||||
'comment UI.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page to comment on.'),
|
||||
content: z.string().describe('The comment body as Markdown.'),
|
||||
selection: z
|
||||
@@ -428,7 +433,7 @@ export class AiChatToolsService {
|
||||
description:
|
||||
'Resolve or reopen a top-level comment thread (reversible — toggle ' +
|
||||
'the resolved flag). Only top-level comments can be resolved.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: modelFriendlyInput({
|
||||
commentId: z
|
||||
.string()
|
||||
.describe('The id of the top-level comment to resolve/reopen.'),
|
||||
@@ -460,7 +465,7 @@ export class AiChatToolsService {
|
||||
'List the most recent pages, optionally scoped to a single space. ' +
|
||||
'Returns a bounded list (default 50, max 100). Pass tree:true (with ' +
|
||||
"spaceId) to instead get the space's full page hierarchy as a nested tree.",
|
||||
inputSchema: z.object({
|
||||
inputSchema: modelFriendlyInput({
|
||||
spaceId: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -488,7 +493,7 @@ export class AiChatToolsService {
|
||||
'List sidebar pages for a space. With no pageId, returns the ' +
|
||||
"space's ROOT pages; with a pageId, returns that page's direct " +
|
||||
'CHILDREN.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: modelFriendlyInput({
|
||||
spaceId: z.string().describe('The id of the space.'),
|
||||
pageId: z
|
||||
.string()
|
||||
@@ -520,7 +525,7 @@ export class AiChatToolsService {
|
||||
description:
|
||||
'Read a table as a matrix of cell texts (plus a parallel cellIds ' +
|
||||
'matrix so cells can be addressed for rich edits).',
|
||||
inputSchema: z.object({
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
tableRef: z
|
||||
.string()
|
||||
@@ -536,7 +541,7 @@ export class AiChatToolsService {
|
||||
listComments: tool({
|
||||
description:
|
||||
'List all comments on a page (content as Markdown).',
|
||||
inputSchema: z.object({
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
}),
|
||||
execute: async ({ pageId }) => await client.listComments(pageId),
|
||||
@@ -544,7 +549,7 @@ export class AiChatToolsService {
|
||||
|
||||
getComment: tool({
|
||||
description: 'Fetch a single comment by id (content as Markdown).',
|
||||
inputSchema: z.object({
|
||||
inputSchema: modelFriendlyInput({
|
||||
commentId: z.string().describe('The id of the comment.'),
|
||||
}),
|
||||
execute: async ({ commentId }) => await client.getComment(commentId),
|
||||
@@ -554,7 +559,7 @@ export class AiChatToolsService {
|
||||
description:
|
||||
'Find new comments across a space (optionally scoped to a subtree) ' +
|
||||
'created after a given timestamp.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: modelFriendlyInput({
|
||||
spaceId: z.string().describe('The id of the space to scan.'),
|
||||
since: z
|
||||
.string()
|
||||
@@ -586,7 +591,7 @@ export class AiChatToolsService {
|
||||
description:
|
||||
'Fetch a single page-history version including its lossless ' +
|
||||
'ProseMirror content.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: modelFriendlyInput({
|
||||
historyId: z.string().describe('The id of the history version.'),
|
||||
}),
|
||||
execute: async ({ historyId }) =>
|
||||
@@ -604,7 +609,7 @@ export class AiChatToolsService {
|
||||
'Export a page to a single self-contained Docmost-flavoured ' +
|
||||
'Markdown file (meta + body + comment threads). Lossless round-trip ' +
|
||||
'with importPageMarkdown.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page to export.'),
|
||||
}),
|
||||
execute: async ({ pageId }) => {
|
||||
@@ -630,7 +635,7 @@ export class AiChatToolsService {
|
||||
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node arg ' +
|
||||
'may be a JSON object or a JSON string (both accepted). Reversible: ' +
|
||||
'the previous version is kept in page history.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
nodeId: z
|
||||
.string()
|
||||
@@ -663,7 +668,7 @@ export class AiChatToolsService {
|
||||
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node arg ' +
|
||||
'may be a JSON object or a JSON string (both accepted). Reversible ' +
|
||||
'via page history.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
node: z
|
||||
.any()
|
||||
@@ -722,7 +727,7 @@ export class AiChatToolsService {
|
||||
'object or a JSON string (both accepted). Omit content for a ' +
|
||||
'title-only update. Reversible: the previous version is kept in page ' +
|
||||
'history.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page to update.'),
|
||||
content: z
|
||||
.any()
|
||||
@@ -753,7 +758,7 @@ export class AiChatToolsService {
|
||||
description:
|
||||
'Insert a row of plain-text cells into a table. Reversible via ' +
|
||||
'page history.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
tableRef: z
|
||||
.string()
|
||||
@@ -772,7 +777,7 @@ export class AiChatToolsService {
|
||||
tableDeleteRow: tool({
|
||||
description:
|
||||
'Delete a table row at a 0-based index. Reversible via page history.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
tableRef: z
|
||||
.string()
|
||||
@@ -787,7 +792,7 @@ export class AiChatToolsService {
|
||||
description:
|
||||
'Set the plain-text content of a table cell at [row, col] (0-based). ' +
|
||||
'Reversible via page history.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
tableRef: z
|
||||
.string()
|
||||
@@ -817,7 +822,7 @@ export class AiChatToolsService {
|
||||
'Make a page PUBLICLY accessible and return its public URL. ' +
|
||||
'Reversible via unsharePage. Only share when the user explicitly ' +
|
||||
'asked, since this exposes the page to anyone with the link.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page to share.'),
|
||||
searchIndexing: z
|
||||
.boolean()
|
||||
@@ -844,7 +849,7 @@ export class AiChatToolsService {
|
||||
"page's ProseMirror document for complex/scripted rewrites. dryRun " +
|
||||
'(default true) previews a diff WITHOUT writing; set dryRun:false to ' +
|
||||
'apply. Reversible: applying creates a new page-history snapshot.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page to transform.'),
|
||||
transformJs: z
|
||||
.string()
|
||||
|
||||
101
apps/server/src/core/ai-chat/tools/model-friendly-input.spec.ts
Normal file
101
apps/server/src/core/ai-chat/tools/model-friendly-input.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
modelFriendlyInput,
|
||||
buildModelFriendlyMessage,
|
||||
} from './model-friendly-input';
|
||||
|
||||
/**
|
||||
* Unit tests for the centralized in-app tool input wrapper (#190). A dropped or
|
||||
* invalid parameter must surface a clear, model-actionable message (naming the
|
||||
* parameter and reminding the model not to drop ids in parallel batches), while
|
||||
* a valid call validates cleanly and strips unknown keys — and the advertised
|
||||
* JSON Schema keeps the unchanged required/description contract.
|
||||
*/
|
||||
describe('modelFriendlyInput', () => {
|
||||
// Mirrors createComment's shape: pageId is the required id the model drops in
|
||||
// parallel batches; selection is optional with a min length.
|
||||
const shape = {
|
||||
pageId: z.string().describe('The id of the page to comment on.'),
|
||||
content: z.string().describe('The comment body as Markdown.'),
|
||||
selection: z.string().min(1).max(250).optional(),
|
||||
};
|
||||
|
||||
// Loose return type: the AI SDK ValidationResult is a discriminated union, but
|
||||
// these tests assert on both branches, so a flat optional shape is simpler.
|
||||
async function validate(
|
||||
value: unknown,
|
||||
): Promise<{ success: boolean; value?: unknown; error?: Error }> {
|
||||
const schema = modelFriendlyInput(shape);
|
||||
return await schema.validate!(value);
|
||||
}
|
||||
|
||||
it('rejects a dropped required pageId with a clear, actionable message', async () => {
|
||||
const result = await validate({
|
||||
content: 'Looks off here',
|
||||
selection: 'титановый проводник',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
const msg = result.error?.message ?? '';
|
||||
// Names the dropped parameter...
|
||||
expect(msg).toContain('parameter "pageId": missing (required)');
|
||||
// ...and gives an explicit, non-raw instruction (not zod's raw text).
|
||||
expect(msg).toContain('parallel/batch tool calls');
|
||||
expect(msg).not.toContain('expected string, received undefined');
|
||||
});
|
||||
|
||||
it('distinguishes a present-but-invalid parameter from a missing one', async () => {
|
||||
// selection is present but too short (invalid), pageId is missing.
|
||||
const result = await validate({ content: 'x', selection: '' });
|
||||
expect(result.success).toBe(false);
|
||||
const msg = result.error?.message ?? '';
|
||||
expect(msg).toContain('parameter "pageId": missing (required)');
|
||||
expect(msg).toContain('parameter "selection": invalid');
|
||||
});
|
||||
|
||||
it('accepts a valid call and strips unknown keys from the validated value', async () => {
|
||||
const result = await validate({
|
||||
pageId: 'page-1',
|
||||
content: 'A comment',
|
||||
selection: 'anchor text',
|
||||
bogus: true,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) throw new Error('expected success');
|
||||
expect(result.value).toEqual({
|
||||
pageId: 'page-1',
|
||||
content: 'A comment',
|
||||
selection: 'anchor text',
|
||||
});
|
||||
expect(result.value).not.toHaveProperty('bogus');
|
||||
});
|
||||
|
||||
it('preserves the required/description contract in the advertised JSON Schema', async () => {
|
||||
const schema = modelFriendlyInput(shape);
|
||||
const json = (await schema.jsonSchema) as {
|
||||
required?: string[];
|
||||
properties?: Record<string, { description?: string }>;
|
||||
};
|
||||
// pageId + content stay required; selection stays optional.
|
||||
expect(json.required).toEqual(expect.arrayContaining(['pageId', 'content']));
|
||||
expect(json.required).not.toContain('selection');
|
||||
expect(json.properties?.pageId.description).toBe(
|
||||
'The id of the page to comment on.',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles a no-arg tool (empty shape) without error', async () => {
|
||||
const schema = modelFriendlyInput({});
|
||||
const result = await schema.validate!({});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildModelFriendlyMessage', () => {
|
||||
it('falls back to a generic message when issues carry an empty path', () => {
|
||||
// safeParse on a non-object yields a root-level issue (empty path).
|
||||
const error = z.object({ a: z.string() }).safeParse('not-an-object');
|
||||
if (error.success) throw new Error('expected failure');
|
||||
const msg = buildModelFriendlyMessage(error.error, 'not-an-object');
|
||||
expect(msg).toContain('parameter "input"');
|
||||
});
|
||||
});
|
||||
93
apps/server/src/core/ai-chat/tools/model-friendly-input.ts
Normal file
93
apps/server/src/core/ai-chat/tools/model-friendly-input.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { jsonSchema, type Schema } from 'ai';
|
||||
import type { JSONSchema7 } from '@ai-sdk/provider';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Centralized input-schema wrapper for every in-app AI-chat tool.
|
||||
*
|
||||
* THE PROBLEM (#190): when the model issues PARALLEL / batch tool calls it
|
||||
* sometimes drops an "obvious" repeated required argument (typically `pageId`)
|
||||
* from some of the calls. zod v4 correctly rejects the missing value, but the
|
||||
* AI SDK forwards zod's RAW message ("Invalid input: expected string, received
|
||||
* undefined") straight back to the model, which is not actionable — the model
|
||||
* cannot tell WHICH parameter it dropped or that it must re-send it.
|
||||
*
|
||||
* THE FIX: keep the exact same validation, but replace the raw zod text with a
|
||||
* model-friendly message that names every problematic parameter and tells the
|
||||
* model to re-issue the call with all required parameters present. We do NOT
|
||||
* guess/backfill the value (a silently-assumed "current page" could comment on
|
||||
* the wrong page — cf. #159); the model is simply told to retry correctly.
|
||||
*
|
||||
* HOW IT WORKS: we build the tool's JSON Schema from the zod shape via
|
||||
* `z.toJSONSchema(..., { target: 'draft-7' })` (so the advertised contract —
|
||||
* `required` / `description` / field constraints — is unchanged) and hand the
|
||||
* AI SDK a custom `validate` that runs `z.object(shape).safeParse(value)`. On
|
||||
* failure the AI SDK wraps our returned `Error` in `InvalidToolInputError`, so
|
||||
* our clear text is what reaches the model as the tool error.
|
||||
*/
|
||||
export function modelFriendlyInput<T extends z.ZodRawShape>(
|
||||
shape: T,
|
||||
): Schema<z.output<z.ZodObject<T>>> {
|
||||
const objectSchema = z.object(shape);
|
||||
// draft-07 keeps required/description/constraints intact, matching what the
|
||||
// model already saw — the tool contract does not change.
|
||||
const json = z.toJSONSchema(objectSchema, {
|
||||
target: 'draft-7',
|
||||
}) as JSONSchema7;
|
||||
|
||||
return jsonSchema<z.output<z.ZodObject<T>>>(json, {
|
||||
validate: (value) => {
|
||||
const result = objectSchema.safeParse(value);
|
||||
if (result.success) {
|
||||
return { success: true, value: result.data };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: new Error(buildModelFriendlyMessage(result.error, value)),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a zod validation failure into a clear, model-actionable message naming
|
||||
* each problematic parameter (and whether it is missing vs. invalid), plus an
|
||||
* explicit reminder not to drop required ids in parallel/batch tool calls.
|
||||
*/
|
||||
export function buildModelFriendlyMessage(
|
||||
error: z.ZodError,
|
||||
value: unknown,
|
||||
): string {
|
||||
const seen = new Set<string>();
|
||||
const parts: string[] = [];
|
||||
for (const issue of error.issues) {
|
||||
const name = issue.path.length ? issue.path.map(String).join('.') : 'input';
|
||||
// A parameter the model omitted entirely reads as `undefined` at its path;
|
||||
// anything else is present-but-invalid (wrong type, too short, etc.).
|
||||
const missing = valueAtPath(value, issue.path) === undefined;
|
||||
const part = `parameter "${name}": ${missing ? 'missing (required)' : 'invalid'}`;
|
||||
if (seen.has(part)) continue;
|
||||
seen.add(part);
|
||||
parts.push(part);
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
// Defensive: a ZodError always has issues, but never emit an empty list.
|
||||
parts.push('input: invalid');
|
||||
}
|
||||
return (
|
||||
`Invalid input for this tool — ${parts.join('; ')}. ` +
|
||||
'Re-issue the call with EVERY required parameter present and valid. ' +
|
||||
"Do not drop ids like pageId, even when making parallel/batch tool calls — " +
|
||||
'each tool call must carry its own pageId.'
|
||||
);
|
||||
}
|
||||
|
||||
/** Read the value at a zod issue path; returns undefined if any hop is absent. */
|
||||
function valueAtPath(value: unknown, path: ReadonlyArray<PropertyKey>): unknown {
|
||||
let current: unknown = value;
|
||||
for (const key of path) {
|
||||
if (current === null || typeof current !== 'object') return undefined;
|
||||
current = (current as Record<PropertyKey, unknown>)[key];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { ShareService } from '../../share/share.service';
|
||||
import { SearchService } from '../../search/search.service';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { jsonToMarkdown } from '../../../collaboration/collaboration.util';
|
||||
import { modelFriendlyInput } from './model-friendly-input';
|
||||
|
||||
/**
|
||||
* Isolated, READ-ONLY toolset for the ANONYMOUS public-share assistant.
|
||||
@@ -52,7 +53,7 @@ export class PublicShareChatToolsService {
|
||||
'(key terms and entities), not a full sentence. If the first ' +
|
||||
'results look weak, search again with different wording before ' +
|
||||
'answering. Only pages inside this share are ever returned.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: modelFriendlyInput({
|
||||
query: z.string().describe('The search query.'),
|
||||
limit: z
|
||||
.number()
|
||||
@@ -87,7 +88,7 @@ export class PublicShareChatToolsService {
|
||||
'Markdown, by its page id. Returns the page title and its Markdown ' +
|
||||
'content. Only pages inside this share can be read; reading any ' +
|
||||
'other page fails.',
|
||||
inputSchema: z.object({
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z
|
||||
.string()
|
||||
.describe('The id (or slugId) of a page within this share.'),
|
||||
@@ -142,7 +143,7 @@ export class PublicShareChatToolsService {
|
||||
'List the pages (titles + ids) that make up THIS published ' +
|
||||
'documentation share, so you can orient yourself before reading or ' +
|
||||
'searching. Only pages inside this share are listed.',
|
||||
inputSchema: z.object({}),
|
||||
inputSchema: modelFriendlyInput({}),
|
||||
execute: async () => {
|
||||
// Reuse the same share-tree logic the public /shares/tree route uses:
|
||||
// it validates the share + workspace, excludes restricted subtrees,
|
||||
|
||||
@@ -3,12 +3,8 @@
|
||||
* from the SIGNED token claim (never a request body), so 'agent' is unspoofable.
|
||||
* Single source of truth so a typo like 'agnet' can't slip through as a bare
|
||||
* string (#143 review). Distinct from `ActorType` (auth principal kind).
|
||||
*
|
||||
* 'git-sync' marks writes made by the git-sync data plane (issue #194 §8.1). It NEVER
|
||||
* travels in a user-facing token; it is set in-process on the collab connection
|
||||
* context by the native datasource, so it cannot be spoofed from a request.
|
||||
*/
|
||||
export type ProvenanceSource = 'user' | 'agent' | 'git-sync';
|
||||
export type ProvenanceSource = 'user' | 'agent';
|
||||
|
||||
export enum JwtType {
|
||||
ACCESS = 'access',
|
||||
@@ -30,8 +26,7 @@ export type JwtPayload = {
|
||||
// normal user token (treated as 'user'); set only when the internal agent
|
||||
// mints a provenance access token so REST writes (create/rename/move page,
|
||||
// comment create/resolve) record a non-spoofable 'agent' marker (§6.5 / §15
|
||||
// C3 / §14 N2). (git-sync writes use the in-process actor, not a token — see
|
||||
// the ProvenanceSource note.)
|
||||
// C3 / §14 N2).
|
||||
actor?: ProvenanceSource;
|
||||
// Nullable: an external MCP agent has no internal ai_chats row, so it carries
|
||||
// an 'agent' actor with a null aiChatId.
|
||||
@@ -44,8 +39,7 @@ export type JwtCollabPayload = {
|
||||
type: 'collab';
|
||||
// Optional agent-edit provenance, signed into the collab token. Absent for
|
||||
// the human collab path (treated as 'user'); set only when the internal agent
|
||||
// mints a provenance collab token (§6.6 / §15 C2). 'git-sync' (in ProvenanceSource)
|
||||
// is accepted for type-compatibility with the in-process git-sync write path.
|
||||
// mints a provenance collab token (§6.6 / §15 C2).
|
||||
actor?: ProvenanceSource;
|
||||
// Nullable: an external MCP agent has no internal ai_chats row, so it carries
|
||||
// an 'agent' actor with a null aiChatId.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// Default lifetime for a temporary note, in HOURS, used when the workspace has
|
||||
// no `temporaryNoteHours` configured (NULL). Mirrors the trash-cleanup
|
||||
// DEFAULT_RETENTION_DAYS fallback. After this many hours a temporary note is
|
||||
// auto-moved to trash unless it was made permanent first.
|
||||
export const DEFAULT_TEMPORARY_NOTE_HOURS = 24;
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -32,4 +33,10 @@ export class CreatePageDto {
|
||||
@Transform(({ value }) => value?.toLowerCase() ?? 'json')
|
||||
@IsIn(['json', 'markdown', 'html'])
|
||||
format?: ContentFormat;
|
||||
|
||||
// When true, create the page as a temporary note: arm its death timer
|
||||
// (now + workspace temporaryNoteHours) at creation.
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
temporary?: boolean;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PageService } from './services/page.service';
|
||||
import { PageController } from './page.controller';
|
||||
import { PageHistoryService } from './services/page-history.service';
|
||||
import { TrashCleanupService } from './services/trash-cleanup.service';
|
||||
import { TemporaryNoteCleanupService } from './services/temporary-note-cleanup.service';
|
||||
import { BacklinkService } from './services/backlink.service';
|
||||
import { StorageModule } from '../../integrations/storage/storage.module';
|
||||
import { CollaborationModule } from '../../collaboration/collaboration.module';
|
||||
@@ -16,6 +17,7 @@ import { LabelModule } from '../label/label.module';
|
||||
PageService,
|
||||
PageHistoryService,
|
||||
TrashCleanupService,
|
||||
TemporaryNoteCleanupService,
|
||||
BacklinkService,
|
||||
],
|
||||
exports: [PageService, PageHistoryService],
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { PageService } from './page.service';
|
||||
import { MovePageDto } from '../dto/move-page.dto';
|
||||
import { CreatePageDto } from '../dto/create-page.dto';
|
||||
import { UpdatePageDto } from '../dto/update-page.dto';
|
||||
import { Page, User } from '@docmost/db/types/entity.types';
|
||||
import { AuthProvenanceData } from '../../../common/decorators/auth-provenance.decorator';
|
||||
import { Page } from '@docmost/db/types/entity.types';
|
||||
import { DEFAULT_TEMPORARY_NOTE_HOURS } from '../constants/temporary-note.constants';
|
||||
|
||||
// Direct instantiation with stub deps. The Test.createTestingModule form failed
|
||||
// to resolve the @InjectKysely()/@InjectQueue() tokens at compile(), and this
|
||||
@@ -60,11 +58,28 @@ describe('PageService', () => {
|
||||
|
||||
const eventEmitter = { emit: jest.fn() };
|
||||
|
||||
// movePage now runs the cycle-check + UPDATE inside executeTx(this.db),
|
||||
// i.e. this.db.transaction().execute(fn => fn(trx)). A permissive chainable
|
||||
// Proxy stands in for the Kysely trx so the per-space advisory-lock
|
||||
// `sql``.execute(trx)` resolves; a thrown BadRequestException still
|
||||
// propagates out of the transaction unchanged.
|
||||
const trxStub: any = new Proxy(function () {}, {
|
||||
get: (_t, p) =>
|
||||
p === 'then'
|
||||
? undefined
|
||||
: p === 'execute' || p === 'executeTakeFirst'
|
||||
? () => Promise.resolve([])
|
||||
: () => trxStub,
|
||||
});
|
||||
const db = {
|
||||
transaction: () => ({ execute: (fn: any) => fn(trxStub) }),
|
||||
};
|
||||
|
||||
const svc = new PageService(
|
||||
pageRepo as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // db
|
||||
db as any, // db
|
||||
{} as any, // storageService
|
||||
{} as any, // attachmentQueue
|
||||
{} as any, // aiQueue
|
||||
@@ -271,9 +286,23 @@ describe('PageService', () => {
|
||||
}),
|
||||
updatePage: jest.fn().mockResolvedValue({ numUpdatedRows: 1n }),
|
||||
};
|
||||
// movePage now runs the cycle-check + UPDATE inside executeTx(this.db),
|
||||
// which calls this.db.transaction().execute(fn => fn(trx)). A permissive
|
||||
// chainable Proxy stands in for the Kysely trx so the per-space
|
||||
// advisory-lock `sql``.execute(trx)` resolves and updatePage receives it.
|
||||
const trxStub: any = new Proxy(function () {}, {
|
||||
get: (_t, p) =>
|
||||
p === 'then'
|
||||
? undefined
|
||||
: p === 'execute' || p === 'executeTakeFirst'
|
||||
? () => Promise.resolve([])
|
||||
: () => trxStub,
|
||||
});
|
||||
const svc = makeSvc({
|
||||
pageRepo,
|
||||
db: {} as any,
|
||||
db: {
|
||||
transaction: () => ({ execute: (fn: any) => fn(trxStub) }),
|
||||
} as any,
|
||||
});
|
||||
// Legitimate move: destination ancestors do NOT include the moved page.
|
||||
jest
|
||||
@@ -393,278 +422,78 @@ describe('PageService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('git-sync provenance stamping (#1)', () => {
|
||||
const GIT_SYNC: AuthProvenanceData = { actor: 'git-sync', aiChatId: null };
|
||||
const USER_PROVENANCE: AuthProvenanceData = { actor: 'user', aiChatId: null };
|
||||
|
||||
describe('create()', () => {
|
||||
// Build a service whose insertPage/generalQueue are observable and whose
|
||||
// nextPagePosition (a DB query) is stubbed, so create() reaches insertPage
|
||||
// without a real database.
|
||||
const makeService = () => {
|
||||
const insertedPage = { id: 'page-1', slugId: 'slug-1' };
|
||||
const pageRepo = {
|
||||
insertPage: jest.fn().mockResolvedValue(insertedPage),
|
||||
};
|
||||
// add() is fire-and-forget (the service .catch()es it); resolve so no
|
||||
// unhandled rejection leaks.
|
||||
const generalQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
const svc = new PageService(
|
||||
pageRepo as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // db
|
||||
{} as any, // storageService
|
||||
{} as any, // attachmentQueue
|
||||
{} as any, // aiQueue
|
||||
generalQueue as any, // generalQueue
|
||||
{} as any, // eventEmitter
|
||||
{} as any, // collaborationGateway
|
||||
{} as any, // watcherService
|
||||
{} as any, // transclusionService
|
||||
);
|
||||
|
||||
// nextPagePosition runs a kysely query; stub it so create() never hits
|
||||
// the db. No DTO content is provided, so parseProsemirrorContent is
|
||||
// skipped entirely (content/textContent/ydoc stay undefined).
|
||||
jest.spyOn(svc, 'nextPagePosition').mockResolvedValue('a0');
|
||||
|
||||
return { svc, pageRepo };
|
||||
describe('create() temporary deadline (#201)', () => {
|
||||
// db stub for the workspaces.temporaryNoteHours lookup:
|
||||
// selectFrom('workspaces').select(['temporaryNoteHours']).where(...).executeTakeFirst()
|
||||
const makeDb = (workspaceRow: any) => {
|
||||
const builder: any = {
|
||||
selectFrom: jest.fn(() => builder),
|
||||
select: jest.fn(() => builder),
|
||||
where: jest.fn(() => builder),
|
||||
executeTakeFirst: jest.fn().mockResolvedValue(workspaceRow),
|
||||
};
|
||||
return builder;
|
||||
};
|
||||
|
||||
const createDto: CreatePageDto = {
|
||||
title: 'New page',
|
||||
spaceId: 'space-1',
|
||||
} as any;
|
||||
const makeGeneralQueue = () =>
|
||||
({ add: jest.fn().mockReturnValue({ catch: jest.fn() }) }) as any;
|
||||
|
||||
it("stamps lastUpdatedSource:'git-sync' on the insertPage payload", async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
const run = async (dto: any, workspaceRow: any) => {
|
||||
const pageRepo = {
|
||||
insertPage: jest.fn().mockResolvedValue({ id: 'p1' }),
|
||||
};
|
||||
const db = makeDb(workspaceRow);
|
||||
const svc = new PageService(
|
||||
pageRepo as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // attachmentRepo
|
||||
db as any, // db
|
||||
{} as any, // storageService
|
||||
{} as any, // attachmentQueue
|
||||
{} as any, // aiQueue
|
||||
makeGeneralQueue(), // generalQueue
|
||||
{} as any, // eventEmitter
|
||||
{} as any, // collaborationGateway
|
||||
{} as any, // watcherService
|
||||
{} as any, // transclusionService
|
||||
);
|
||||
// nextPagePosition runs a real db query; stub it out.
|
||||
jest.spyOn(svc, 'nextPagePosition').mockResolvedValue('a0' as any);
|
||||
await svc.create('u1', 'w1', dto, undefined);
|
||||
return { payload: pageRepo.insertPage.mock.calls[0][0], db };
|
||||
};
|
||||
|
||||
await svc.create('user-1', 'ws-1', createDto, GIT_SYNC);
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
expect(pageRepo.insertPage).toHaveBeenCalledTimes(1);
|
||||
expect(pageRepo.insertPage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ lastUpdatedSource: 'git-sync' }),
|
||||
);
|
||||
// git-sync carries no aiChatId (unlike the agent branch).
|
||||
const payload = pageRepo.insertPage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedAiChatId).toBeUndefined();
|
||||
// The human stays the responsible author.
|
||||
expect(payload.creatorId).toBe('user-1');
|
||||
expect(payload.lastUpdatedById).toBe('user-1');
|
||||
});
|
||||
|
||||
it('leaves the source column unset for a plain user create', async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.create('user-1', 'ws-1', createDto, USER_PROVENANCE);
|
||||
|
||||
const payload = pageRepo.insertPage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedSource).toBeUndefined();
|
||||
});
|
||||
it('freezes temporaryExpiresAt at now + workspace hours when temporary', async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-06-26T00:00:00.000Z'));
|
||||
const { payload } = await run(
|
||||
{ title: 't', spaceId: 's1', temporary: true },
|
||||
{ temporaryNoteHours: 5 },
|
||||
);
|
||||
expect(payload.temporaryExpiresAt).toEqual(
|
||||
new Date(Date.now() + 5 * 60 * 60 * 1000),
|
||||
);
|
||||
});
|
||||
|
||||
describe('update() (rename)', () => {
|
||||
const makeService = () => {
|
||||
const pageRepo = {
|
||||
updatePage: jest.fn().mockResolvedValue({ numUpdatedRows: 1n }),
|
||||
// update() re-reads the row at the end to return the refreshed page.
|
||||
findById: jest.fn().mockResolvedValue({ id: 'page-1' }),
|
||||
};
|
||||
const generalQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
const aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
const svc = new PageService(
|
||||
pageRepo as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // db
|
||||
{} as any, // storageService
|
||||
{} as any, // attachmentQueue
|
||||
aiQueue as any, // aiQueue
|
||||
generalQueue as any, // generalQueue
|
||||
{} as any, // eventEmitter
|
||||
{} as any, // collaborationGateway
|
||||
{} as any, // watcherService
|
||||
{} as any, // transclusionService
|
||||
);
|
||||
|
||||
return { svc, pageRepo };
|
||||
};
|
||||
|
||||
const page: Page = {
|
||||
id: 'page-1',
|
||||
slugId: 'slug-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
title: 'Old title',
|
||||
icon: null,
|
||||
parentPageId: null,
|
||||
contributorIds: [],
|
||||
} as any;
|
||||
|
||||
const user: User = { id: 'user-1' } as any;
|
||||
|
||||
it("stamps lastUpdatedSource:'git-sync' on the updatePage payload", async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
const dto: UpdatePageDto = { title: 'New title' } as any;
|
||||
|
||||
await svc.update(page, dto, user, GIT_SYNC);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const payload = pageRepo.updatePage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedSource).toBe('git-sync');
|
||||
expect(payload.lastUpdatedAiChatId).toBeUndefined();
|
||||
// The acting user stays the responsible author.
|
||||
expect(payload.lastUpdatedById).toBe('user-1');
|
||||
});
|
||||
|
||||
it('leaves the source column unset for a plain user rename', async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
const dto: UpdatePageDto = { title: 'New title' } as any;
|
||||
|
||||
await svc.update(page, dto, user, USER_PROVENANCE);
|
||||
|
||||
const payload = pageRepo.updatePage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedSource).toBeUndefined();
|
||||
});
|
||||
it('falls back to DEFAULT_TEMPORARY_NOTE_HOURS when the workspace hours are null', async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-06-26T00:00:00.000Z'));
|
||||
const { payload } = await run(
|
||||
{ title: 't', spaceId: 's1', temporary: true },
|
||||
{ temporaryNoteHours: null },
|
||||
);
|
||||
expect(payload.temporaryExpiresAt).toEqual(
|
||||
new Date(Date.now() + DEFAULT_TEMPORARY_NOTE_HOURS * 60 * 60 * 1000),
|
||||
);
|
||||
});
|
||||
|
||||
describe('movePage()', () => {
|
||||
const SPACE_ID = 'space-1';
|
||||
const VALID_POSITION = 'a0';
|
||||
|
||||
const makeService = () => {
|
||||
const pageRepo = {
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
id: 'dest-parent',
|
||||
deletedAt: null,
|
||||
spaceId: SPACE_ID,
|
||||
}),
|
||||
updatePage: jest.fn().mockResolvedValue({ numUpdatedRows: 1n }),
|
||||
};
|
||||
const eventEmitter = { emit: jest.fn() };
|
||||
|
||||
const svc = new PageService(
|
||||
pageRepo as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // db
|
||||
{} as any, // storageService
|
||||
{} as any, // attachmentQueue
|
||||
{} as any, // aiQueue
|
||||
{} as any, // generalQueue
|
||||
eventEmitter as any, // eventEmitter
|
||||
{} as any, // collaborationGateway
|
||||
{} as any, // watcherService
|
||||
{} as any, // transclusionService
|
||||
);
|
||||
|
||||
// No cycle: the destination's ancestor chain does not contain the moved
|
||||
// page, so movePage reaches updatePage.
|
||||
jest
|
||||
.spyOn(svc, 'getPageBreadCrumbs')
|
||||
.mockResolvedValue([{ id: 'dest-parent' }, { id: 'root' }] as any);
|
||||
|
||||
return { svc, pageRepo };
|
||||
};
|
||||
|
||||
const movedPage: Page = {
|
||||
id: 'page-1',
|
||||
parentPageId: 'old-parent',
|
||||
spaceId: SPACE_ID,
|
||||
workspaceId: 'ws-1',
|
||||
slugId: 'slug-1',
|
||||
title: 'Page 1',
|
||||
icon: null,
|
||||
} as any;
|
||||
|
||||
const dto: MovePageDto = {
|
||||
pageId: 'page-1',
|
||||
position: VALID_POSITION,
|
||||
parentPageId: 'dest-parent',
|
||||
};
|
||||
|
||||
it("stamps lastUpdatedSource:'git-sync' on the updatePage payload", async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.movePage(dto, movedPage, GIT_SYNC);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const payload = pageRepo.updatePage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedSource).toBe('git-sync');
|
||||
expect(payload.lastUpdatedAiChatId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves the source column unset for a plain user move', async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.movePage(dto, movedPage, USER_PROVENANCE);
|
||||
|
||||
const payload = pageRepo.updatePage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedSource).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removePage()', () => {
|
||||
// removePage forwards a `source` 4th arg to pageRepo.removePage: 'git-sync'
|
||||
// for a git-sync-driven soft-delete (so the change-listener loop-guard skips
|
||||
// its own write), undefined otherwise.
|
||||
const makeService = () => {
|
||||
const pageRepo = {
|
||||
removePage: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const svc = new PageService(
|
||||
pageRepo as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // db
|
||||
{} as any, // storageService
|
||||
{} as any, // attachmentQueue
|
||||
{} as any, // aiQueue
|
||||
{} as any, // generalQueue
|
||||
{} as any, // eventEmitter
|
||||
{} as any, // collaborationGateway
|
||||
{} as any, // watcherService
|
||||
{} as any, // transclusionService
|
||||
);
|
||||
|
||||
return { svc, pageRepo };
|
||||
};
|
||||
|
||||
it("forwards 'git-sync' as the source for a git-sync soft-delete", async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.removePage('page-1', 'user-1', 'ws-1', GIT_SYNC);
|
||||
|
||||
expect(pageRepo.removePage).toHaveBeenCalledTimes(1);
|
||||
const [pageId, userId, workspaceId, source] =
|
||||
pageRepo.removePage.mock.calls[0];
|
||||
expect(pageId).toBe('page-1');
|
||||
expect(userId).toBe('user-1');
|
||||
expect(workspaceId).toBe('ws-1');
|
||||
expect(source).toBe('git-sync');
|
||||
});
|
||||
|
||||
it('forwards undefined as the source for a plain user delete', async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.removePage('page-1', 'user-1', 'ws-1', USER_PROVENANCE);
|
||||
|
||||
const [, , , source] = pageRepo.removePage.mock.calls[0];
|
||||
expect(source).toBeUndefined();
|
||||
});
|
||||
|
||||
it('forwards undefined as the source when no provenance is given', async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.removePage('page-1', 'user-1', 'ws-1');
|
||||
|
||||
const [, , , source] = pageRepo.removePage.mock.calls[0];
|
||||
expect(source).toBeUndefined();
|
||||
});
|
||||
it('leaves temporaryExpiresAt undefined and skips the workspace lookup for a non-temporary page', async () => {
|
||||
const { payload, db } = await run(
|
||||
{ title: 't', spaceId: 's1' },
|
||||
{ temporaryNoteHours: 5 },
|
||||
);
|
||||
expect(payload.temporaryExpiresAt).toBeUndefined();
|
||||
expect(db.selectFrom).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,13 +15,13 @@ import {
|
||||
executeWithCursorPagination,
|
||||
} from '@docmost/db/pagination/cursor-pagination';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||
import { MovePageDto } from '../dto/move-page.dto';
|
||||
import { shapeSidebarPagesTree } from './sidebar-pages-tree.util';
|
||||
import { generateSlugId } from '../../../common/helpers';
|
||||
import { getPageTitle } from '../../../common/helpers';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { dbOrTx, executeTx } from '@docmost/db/utils';
|
||||
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
||||
import { v7 as uuid7 } from 'uuid';
|
||||
import {
|
||||
@@ -61,6 +61,24 @@ import {
|
||||
AuthProvenanceData,
|
||||
agentSourceFields,
|
||||
} from '../../../common/decorators/auth-provenance.decorator';
|
||||
import { DEFAULT_TEMPORARY_NOTE_HOURS } from '../constants/temporary-note.constants';
|
||||
|
||||
// Hard upper bound on how deep the recursive page-tree CTEs (ancestor /
|
||||
// descendant traversals) may walk. Real page trees are only a handful of levels
|
||||
// deep, so this cap never truncates a legitimate result; it purely defends the
|
||||
// recursive CTEs against runaway iteration if a parent/child cycle ever exists
|
||||
// in the data (e.g. one slipped in before the move guard, #207 #8). Without it a
|
||||
// cycle makes `withRecursive` loop forever (hang / statement timeout), and the
|
||||
// move guard itself calls one of these CTEs — so a cycle would disable the very
|
||||
// guard meant to prevent it. Each CTE carries a depth counter and stops here.
|
||||
const MAX_PAGE_TREE_DEPTH = 10_000;
|
||||
|
||||
// Advisory-lock namespace (the first key of pg_advisory_xact_lock) used to
|
||||
// serialize concurrent page moves within a single space so the cycle check and
|
||||
// the move UPDATE stay atomic (see movePage, #207 #7). A dedicated namespace
|
||||
// constant keeps these locks from colliding with any other advisory lock; the
|
||||
// second key is hashtext(spaceId). Fits a signed int4 ('page' in ASCII).
|
||||
const PAGE_MOVE_LOCK_NAMESPACE = 0x70616765;
|
||||
|
||||
@Injectable()
|
||||
export class PageService {
|
||||
@@ -123,6 +141,20 @@ export class PageService {
|
||||
parentPageId = parentPage.id;
|
||||
}
|
||||
|
||||
// Freeze the death timer here so later changes to the workspace setting
|
||||
// never reschedule existing temporary notes. NULL => permanent page.
|
||||
let temporaryExpiresAt: Date | undefined;
|
||||
if (createPageDto.temporary) {
|
||||
const workspace = await this.db
|
||||
.selectFrom('workspaces')
|
||||
.select(['temporaryNoteHours'])
|
||||
.where('id', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
const hours =
|
||||
workspace?.temporaryNoteHours ?? DEFAULT_TEMPORARY_NOTE_HOURS;
|
||||
temporaryExpiresAt = new Date(Date.now() + hours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
let content = undefined;
|
||||
let textContent = undefined;
|
||||
let ydoc = undefined;
|
||||
@@ -155,6 +187,7 @@ export class PageService {
|
||||
// (creatorId/lastUpdatedById); these only annotate the source. A normal
|
||||
// user request leaves the column default ('user').
|
||||
...agentSourceFields(provenance, 'lastUpdatedSource', 'lastUpdatedAiChatId'),
|
||||
temporaryExpiresAt,
|
||||
content,
|
||||
textContent,
|
||||
ydoc,
|
||||
@@ -339,6 +372,7 @@ export class PageService {
|
||||
'spaceId',
|
||||
'creatorId',
|
||||
'isTemplate',
|
||||
'temporaryExpiresAt',
|
||||
'deletedAt',
|
||||
])
|
||||
.select((eb) => this.pageRepo.withHasChildren(eb))
|
||||
@@ -601,7 +635,13 @@ export class PageService {
|
||||
slugIdMap.set(entry.oldSlugId, entry);
|
||||
}
|
||||
|
||||
const attachmentMap = new Map<string, ICopyPageAttachment>();
|
||||
// Keyed by old attachmentId. A single attachment can be referenced by more
|
||||
// than one page in the copied subtree (e.g. a block copy-pasted into a child
|
||||
// page keeps the same attachmentId). Each referencing page needs its own
|
||||
// fresh attachment id / row / blob copy, so the value is a LIST of copy
|
||||
// entries rather than a single one — otherwise the last page's entry would
|
||||
// clobber the others and their images would 404 in the copies (#206 attach-1).
|
||||
const attachmentMap = new Map<string, ICopyPageAttachment[]>();
|
||||
|
||||
const insertablePages: InsertablePage[] = await Promise.all(
|
||||
pages.map(async (page) => {
|
||||
@@ -617,12 +657,14 @@ export class PageService {
|
||||
attachmentIds.forEach((attachmentId: string) => {
|
||||
const newPageId = pageFromMap.newPageId;
|
||||
const newAttachmentId = uuid7();
|
||||
attachmentMap.set(attachmentId, {
|
||||
const existingEntries = attachmentMap.get(attachmentId) ?? [];
|
||||
existingEntries.push({
|
||||
newPageId: newPageId,
|
||||
oldPageId: page.id,
|
||||
oldAttachmentId: attachmentId,
|
||||
newAttachmentId: newAttachmentId,
|
||||
});
|
||||
attachmentMap.set(attachmentId, existingEntries);
|
||||
|
||||
prosemirrorDoc.descendants((node: PMNode) => {
|
||||
if (isAttachmentNode(node.type.name)) {
|
||||
@@ -819,51 +861,53 @@ export class PageService {
|
||||
.execute();
|
||||
|
||||
for (const attachment of attachments) {
|
||||
try {
|
||||
const pageAttachment = attachmentMap.get(attachment.id);
|
||||
|
||||
// make sure the copied attachment belongs to the page it was copied from
|
||||
if (attachment.pageId !== pageAttachment.oldPageId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newAttachmentId = pageAttachment.newAttachmentId;
|
||||
|
||||
const newPageId = pageAttachment.newPageId;
|
||||
|
||||
const newPathFile = attachment.filePath.replace(
|
||||
attachment.id,
|
||||
newAttachmentId,
|
||||
);
|
||||
|
||||
// One source attachment may need to be copied for several destination
|
||||
// pages (it is referenced by more than one page in the subtree). Copy a
|
||||
// distinct blob + row for every referencing page so each copy resolves
|
||||
// (#206 attach-1). The old per-page ownership guard is gone: when the
|
||||
// same attachmentId is shared, only one page would ever match the row's
|
||||
// pageId, silently dropping the other copies.
|
||||
const pageAttachments = attachmentMap.get(attachment.id) ?? [];
|
||||
for (const pageAttachment of pageAttachments) {
|
||||
try {
|
||||
await this.storageService.copy(attachment.filePath, newPathFile);
|
||||
const newAttachmentId = pageAttachment.newAttachmentId;
|
||||
|
||||
await this.db
|
||||
.insertInto('attachments')
|
||||
.values({
|
||||
id: newAttachmentId,
|
||||
type: attachment.type,
|
||||
filePath: newPathFile,
|
||||
fileName: attachment.fileName,
|
||||
fileSize: attachment.fileSize,
|
||||
mimeType: attachment.mimeType,
|
||||
fileExt: attachment.fileExt,
|
||||
creatorId: attachment.creatorId,
|
||||
workspaceId: attachment.workspaceId,
|
||||
pageId: newPageId,
|
||||
spaceId: spaceId,
|
||||
})
|
||||
.execute();
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Duplicate page: failed to copy attachment ${attachment.id}`,
|
||||
err,
|
||||
const newPageId = pageAttachment.newPageId;
|
||||
|
||||
const newPathFile = attachment.filePath.replace(
|
||||
attachment.id,
|
||||
newAttachmentId,
|
||||
);
|
||||
// Continue with other attachments even if one fails
|
||||
|
||||
try {
|
||||
await this.storageService.copy(attachment.filePath, newPathFile);
|
||||
|
||||
await this.db
|
||||
.insertInto('attachments')
|
||||
.values({
|
||||
id: newAttachmentId,
|
||||
type: attachment.type,
|
||||
filePath: newPathFile,
|
||||
fileName: attachment.fileName,
|
||||
fileSize: attachment.fileSize,
|
||||
mimeType: attachment.mimeType,
|
||||
fileExt: attachment.fileExt,
|
||||
creatorId: attachment.creatorId,
|
||||
workspaceId: attachment.workspaceId,
|
||||
pageId: newPageId,
|
||||
spaceId: spaceId,
|
||||
})
|
||||
.execute();
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Duplicate page: failed to copy attachment ${attachment.id}`,
|
||||
err,
|
||||
);
|
||||
// Continue with other attachments even if one fails
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -915,34 +959,61 @@ export class PageService {
|
||||
}
|
||||
}
|
||||
|
||||
// Server-side cycle guard: a page may not be moved into itself or into any
|
||||
// page within its own subtree. Without this, an MCP/REST/agent caller (or a
|
||||
// fast drag racing the client check) could persist a cycle and broadcast it.
|
||||
// Only relevant when re-parenting under a concrete parent; moving to root
|
||||
// (parentPageId null/undefined) can never create a cycle.
|
||||
if (dto.parentPageId) {
|
||||
if (dto.parentPageId === dto.pageId) {
|
||||
throw new BadRequestException('Cannot move a page into its own subtree');
|
||||
}
|
||||
// Walk the destination parent's ancestor chain (reusing the breadcrumb
|
||||
// ancestor CTE). If the page being moved appears among those ancestors,
|
||||
// the destination lives inside the moved page's subtree -> cycle.
|
||||
const destAncestors = await this.getPageBreadCrumbs(dto.parentPageId);
|
||||
if (destAncestors.some((ancestor) => ancestor.id === dto.pageId)) {
|
||||
throw new BadRequestException('Cannot move a page into its own subtree');
|
||||
}
|
||||
}
|
||||
// Server-side cycle guard + the move UPDATE run in ONE transaction. A page
|
||||
// may not be moved into itself or into any page within its own subtree;
|
||||
// without this an MCP/REST/agent caller (or a fast drag racing the client
|
||||
// check) could persist a cycle and broadcast it. Crucially, doing the guard
|
||||
// and the write as two separate, unlocked statements is a TOCTOU race: two
|
||||
// concurrent moves ("A under B" and "B under A") can each read the same
|
||||
// pre-write acyclic snapshot, both pass the guard, then persist
|
||||
// A.parentPageId=B AND B.parentPageId=A — a parent/child cycle (#207 #7). A
|
||||
// per-space advisory lock (held until COMMIT) serializes all moves within a
|
||||
// space: the second mover blocks until the first commits and then sees the
|
||||
// freshly written parent, so its guard rejects the cycle.
|
||||
const updateResult = await executeTx(this.db, async (trx) => {
|
||||
await sql`select pg_advisory_xact_lock(${sql.lit(
|
||||
PAGE_MOVE_LOCK_NAMESPACE,
|
||||
)}, hashtext(${movedPage.spaceId}))`.execute(trx);
|
||||
|
||||
const updateResult = await this.pageRepo.updatePage(
|
||||
{
|
||||
position: dto.position,
|
||||
parentPageId: parentPageId,
|
||||
// Agent-edit provenance: annotate the source on an agent move. A normal
|
||||
// user request leaves the existing source value unchanged.
|
||||
...agentSourceFields(provenance, 'lastUpdatedSource', 'lastUpdatedAiChatId'),
|
||||
},
|
||||
dto.pageId,
|
||||
);
|
||||
// Only relevant when re-parenting under a concrete parent; moving to root
|
||||
// (parentPageId null/undefined) can never create a cycle.
|
||||
if (dto.parentPageId) {
|
||||
if (dto.parentPageId === dto.pageId) {
|
||||
throw new BadRequestException(
|
||||
'Cannot move a page into its own subtree',
|
||||
);
|
||||
}
|
||||
// Walk the destination parent's ancestor chain (reusing the breadcrumb
|
||||
// ancestor CTE) inside the lock. If the page being moved appears among
|
||||
// those ancestors, the destination lives inside the moved page's
|
||||
// subtree -> cycle.
|
||||
const destAncestors = await this.getPageBreadCrumbs(
|
||||
dto.parentPageId,
|
||||
trx,
|
||||
);
|
||||
if (destAncestors.some((ancestor) => ancestor.id === dto.pageId)) {
|
||||
throw new BadRequestException(
|
||||
'Cannot move a page into its own subtree',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.pageRepo.updatePage(
|
||||
{
|
||||
position: dto.position,
|
||||
parentPageId: parentPageId,
|
||||
// Agent-edit provenance: annotate the source on an agent move. A
|
||||
// normal user request leaves the existing source value unchanged.
|
||||
...agentSourceFields(
|
||||
provenance,
|
||||
'lastUpdatedSource',
|
||||
'lastUpdatedAiChatId',
|
||||
),
|
||||
},
|
||||
dto.pageId,
|
||||
trx,
|
||||
);
|
||||
});
|
||||
|
||||
// Guard against a phantom broadcast: if the row was concurrently deleted or
|
||||
// otherwise not updated, skip the PAGE_MOVED event so we don't replay a move
|
||||
@@ -981,8 +1052,8 @@ export class PageService {
|
||||
});
|
||||
}
|
||||
|
||||
async getPageBreadCrumbs(childPageId: string) {
|
||||
const ancestors = await this.db
|
||||
async getPageBreadCrumbs(childPageId: string, trx?: KyselyTransaction) {
|
||||
const ancestors = await dbOrTx(this.db, trx)
|
||||
.withRecursive('page_ancestors', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
@@ -996,6 +1067,9 @@ export class PageService {
|
||||
'spaceId',
|
||||
'deletedAt',
|
||||
])
|
||||
// Depth counter: bounds the walk so a parent/child cycle in the data
|
||||
// can't make this recursive CTE loop forever (#207 #8).
|
||||
.select(sql<number>`0`.as('depth'))
|
||||
.where('id', '=', childPageId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.unionAll((exp) =>
|
||||
@@ -1011,12 +1085,25 @@ export class PageService {
|
||||
'p.spaceId',
|
||||
'p.deletedAt',
|
||||
])
|
||||
.select(sql<number>`pa.depth + 1`.as('depth'))
|
||||
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
|
||||
.where('p.deletedAt', 'is', null),
|
||||
.where('p.deletedAt', 'is', null)
|
||||
.where(sql<number>`pa.depth`, '<', MAX_PAGE_TREE_DEPTH),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_ancestors')
|
||||
.selectAll('page_ancestors')
|
||||
// Explicit column list (not selectAll) so the internal `depth` counter
|
||||
// never leaks into the breadcrumb result shape.
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'icon',
|
||||
'position',
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'deletedAt',
|
||||
])
|
||||
.select((eb) =>
|
||||
eb
|
||||
.exists(
|
||||
@@ -1137,16 +1224,21 @@ export class PageService {
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select(['id'])
|
||||
// Depth counter: bounds the walk so a parent/child cycle in the data
|
||||
// can't make this recursive CTE loop forever (#207 #8).
|
||||
.select(sql<number>`0`.as('depth'))
|
||||
.where('id', '=', pageId)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.select(['p.id'])
|
||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
|
||||
.select(sql<number>`pd.depth + 1`.as('depth'))
|
||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
|
||||
.where(sql<number>`pd.depth`, '<', MAX_PAGE_TREE_DEPTH),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_descendants')
|
||||
.selectAll()
|
||||
.select(['id'])
|
||||
.execute();
|
||||
|
||||
const pageIds = descendants.map((d) => d.id);
|
||||
@@ -1182,18 +1274,8 @@ export class PageService {
|
||||
pageId: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
// Optional provenance. A git-sync-driven soft-delete stamps
|
||||
// `lastUpdatedSource = 'git-sync'` so the change-listener loop-guard skips
|
||||
// its own write (mirrors the create/update/move provenance branches above).
|
||||
provenance?: AuthProvenanceData,
|
||||
): Promise<void> {
|
||||
const isGitSync = provenance?.actor === 'git-sync';
|
||||
await this.pageRepo.removePage(
|
||||
pageId,
|
||||
userId,
|
||||
workspaceId,
|
||||
isGitSync ? 'git-sync' : undefined,
|
||||
);
|
||||
await this.pageRepo.removePage(pageId, userId, workspaceId);
|
||||
}
|
||||
|
||||
private async parseProsemirrorContent(
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { TemporaryNoteCleanupService } from '../temporary-note-cleanup.service';
|
||||
|
||||
/**
|
||||
* Chainable Kysely stub that records every `.where(...)` call so the test can
|
||||
* assert the sweep only selects armed, expired, not-yet-trashed notes. The
|
||||
* terminal `.execute()` resolves the configured expired rows (the batch SELECT);
|
||||
* `.executeTakeFirst()` resolves the per-row deadline re-read done just before
|
||||
* each `removePage`. By default the re-read reports the note as still armed and
|
||||
* still expired (epoch deadline < now), so the sweep proceeds to delete it;
|
||||
* tests override `reReadFirst` to simulate a concurrent "Make permanent".
|
||||
*/
|
||||
function makeDbStub(expiredRows: any[]) {
|
||||
const whereCalls: any[][] = [];
|
||||
const reReadFirst = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ temporaryExpiresAt: new Date(0), deletedAt: null });
|
||||
const builder: any = {
|
||||
selectFrom: jest.fn(() => builder),
|
||||
select: jest.fn(() => builder),
|
||||
where: jest.fn((...args: any[]) => {
|
||||
whereCalls.push(args);
|
||||
return builder;
|
||||
}),
|
||||
limit: jest.fn(() => builder),
|
||||
execute: jest.fn().mockResolvedValue(expiredRows),
|
||||
executeTakeFirst: reReadFirst,
|
||||
};
|
||||
return { builder, whereCalls, reReadFirst };
|
||||
}
|
||||
|
||||
describe('TemporaryNoteCleanupService.sweepExpiredTemporaryNotes', () => {
|
||||
it('selects only armed, expired, not-yet-trashed notes', async () => {
|
||||
const { builder, whereCalls } = makeDbStub([]);
|
||||
const pageRepo = { removePage: jest.fn() } as any;
|
||||
const service = new TemporaryNoteCleanupService(builder, pageRepo);
|
||||
|
||||
await service.sweepExpiredTemporaryNotes();
|
||||
|
||||
// temporaryExpiresAt IS NOT NULL, temporaryExpiresAt < now, deletedAt IS NULL
|
||||
const cols = whereCalls.map((c) => c[0]);
|
||||
const ops = whereCalls.map((c) => c[1]);
|
||||
expect(cols).toEqual([
|
||||
'temporaryExpiresAt',
|
||||
'temporaryExpiresAt',
|
||||
'deletedAt',
|
||||
]);
|
||||
expect(ops).toEqual(['is not', '<', 'is']);
|
||||
// last operand is the trash filter -> null
|
||||
expect(whereCalls[2][2]).toBeNull();
|
||||
// The batch SELECT is capped so a large backlog is not pulled at once.
|
||||
expect(builder.limit).toHaveBeenCalledTimes(1);
|
||||
expect(builder.limit.mock.calls[0][0]).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('soft-deletes each expired note via removePage, attributed to its creator', async () => {
|
||||
const expired = [
|
||||
{ id: 'p1', creatorId: 'u1', workspaceId: 'w1' },
|
||||
{ id: 'p2', creatorId: 'u2', workspaceId: 'w1' },
|
||||
];
|
||||
const { builder } = makeDbStub(expired);
|
||||
const pageRepo = { removePage: jest.fn().mockResolvedValue(undefined) } as any;
|
||||
const service = new TemporaryNoteCleanupService(builder, pageRepo);
|
||||
|
||||
await service.sweepExpiredTemporaryNotes();
|
||||
|
||||
expect(pageRepo.removePage).toHaveBeenCalledTimes(2);
|
||||
expect(pageRepo.removePage).toHaveBeenNthCalledWith(1, 'p1', 'u1', 'w1');
|
||||
expect(pageRepo.removePage).toHaveBeenNthCalledWith(2, 'p2', 'u2', 'w1');
|
||||
});
|
||||
|
||||
it('continues past a failing note (one bad removePage does not abort the sweep)', async () => {
|
||||
const expired = [
|
||||
{ id: 'bad', creatorId: 'u1', workspaceId: 'w1' },
|
||||
{ id: 'good', creatorId: 'u2', workspaceId: 'w1' },
|
||||
];
|
||||
const { builder } = makeDbStub(expired);
|
||||
const pageRepo = {
|
||||
removePage: jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockResolvedValueOnce(undefined),
|
||||
} as any;
|
||||
const service = new TemporaryNoteCleanupService(builder, pageRepo);
|
||||
|
||||
await expect(
|
||||
service.sweepExpiredTemporaryNotes(),
|
||||
).resolves.toBeUndefined();
|
||||
expect(pageRepo.removePage).toHaveBeenCalledTimes(2);
|
||||
expect(pageRepo.removePage).toHaveBeenNthCalledWith(2, 'good', 'u2', 'w1');
|
||||
});
|
||||
|
||||
it('does NOT trash a note made permanent in the race window', async () => {
|
||||
// The batch SELECT saw the note as expired, but before its turn in the loop
|
||||
// the user clicked "Make permanent" (temporary_expires_at -> null). The
|
||||
// deadline re-read must catch this and skip the delete so the keep wins.
|
||||
const expired = [{ id: 'p1', creatorId: 'u1', workspaceId: 'w1' }];
|
||||
const { builder, reReadFirst } = makeDbStub(expired);
|
||||
reReadFirst.mockResolvedValueOnce({
|
||||
temporaryExpiresAt: null,
|
||||
deletedAt: null,
|
||||
});
|
||||
const pageRepo = { removePage: jest.fn() } as any;
|
||||
const service = new TemporaryNoteCleanupService(builder, pageRepo);
|
||||
|
||||
await service.sweepExpiredTemporaryNotes();
|
||||
|
||||
expect(reReadFirst).toHaveBeenCalledTimes(1);
|
||||
expect(pageRepo.removePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips a note already trashed since the batch SELECT', async () => {
|
||||
const expired = [{ id: 'p1', creatorId: 'u1', workspaceId: 'w1' }];
|
||||
const { builder, reReadFirst } = makeDbStub(expired);
|
||||
reReadFirst.mockResolvedValueOnce({
|
||||
temporaryExpiresAt: new Date(0),
|
||||
deletedAt: new Date(),
|
||||
});
|
||||
const pageRepo = { removePage: jest.fn() } as any;
|
||||
const service = new TemporaryNoteCleanupService(builder, pageRepo);
|
||||
|
||||
await service.sweepExpiredTemporaryNotes();
|
||||
|
||||
expect(pageRepo.removePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT trash a note re-armed to a future deadline in the race window', async () => {
|
||||
// The batch SELECT saw the note as expired, but before its turn in the loop
|
||||
// the user disarmed it and re-armed it to a fresh, still-future deadline
|
||||
// (temporary_expires_at -> now + 1h). The deadline re-read must catch that
|
||||
// the note is no longer expired and skip the delete so the keep wins.
|
||||
const expired = [{ id: 'p1', creatorId: 'u1', workspaceId: 'w1' }];
|
||||
const { builder, reReadFirst } = makeDbStub(expired);
|
||||
reReadFirst.mockResolvedValueOnce({
|
||||
temporaryExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
deletedAt: null,
|
||||
});
|
||||
const pageRepo = { removePage: jest.fn() } as any;
|
||||
const service = new TemporaryNoteCleanupService(builder, pageRepo);
|
||||
|
||||
await service.sweepExpiredTemporaryNotes();
|
||||
|
||||
expect(reReadFirst).toHaveBeenCalledTimes(1);
|
||||
expect(pageRepo.removePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when no notes are expired', async () => {
|
||||
const { builder } = makeDbStub([]);
|
||||
const pageRepo = { removePage: jest.fn() } as any;
|
||||
const service = new TemporaryNoteCleanupService(builder, pageRepo);
|
||||
|
||||
await service.sweepExpiredTemporaryNotes();
|
||||
expect(pageRepo.removePage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user