Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f77a6b42de | |||
| 134b627806 | |||
| 3267512ed9 | |||
| 48bd27b83c | |||
| 265b81c93d | |||
| ed808876be | |||
| a72ddbbe86 | |||
| d8fc724d90 | |||
| e4bfbcabaa | |||
| 4c1ee50dc9 | |||
| b8cce4f814 | |||
| c5bff2d84a | |||
| a325ddbabd | |||
| 80fc30633b | |||
| e17d5bc060 | |||
| bfcee6dddc | |||
| 2c2d60a5dc | |||
| 1417209915 | |||
| f555fc87da | |||
| d6d1195abd | |||
| 36b940fdb8 | |||
| 0050ad7ebb | |||
| ce70fab1df | |||
| 7b4617db70 | |||
| b51dae16a6 | |||
| 39735afd73 | |||
| 9b4b38a611 | |||
| eebbe6717c | |||
| e348433a39 | |||
| 459d636ffb | |||
| e89ac627dd | |||
| f665f6fdd2 | |||
| 7af85b476e | |||
| 5d8364bb5f | |||
| d3209b5aab | |||
| 68899a2c2e | |||
| b9f3de80f5 |
@@ -209,6 +209,20 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# active" behavior.
|
# active" behavior.
|
||||||
# AI_CHAT_DEFERRED_TOOLS=true
|
# AI_CHAT_DEFERRED_TOOLS=true
|
||||||
|
|
||||||
|
# --- Autonomous / detached agent runs (settings.ai.autonomousRuns) ---
|
||||||
|
# Opt-in per workspace (AI settings; off by default). When on, a chat turn becomes
|
||||||
|
# a server-side RUN that survives a browser disconnect — only an explicit Stop ends
|
||||||
|
# it, and a client reconnects/live-follows the run.
|
||||||
|
#
|
||||||
|
# DEPLOY CONSTRAINT — SINGLE-INSTANCE ONLY in phase 1: Stop and the in-process
|
||||||
|
# AbortController that backs it are process-local, so a Stop only aborts a run
|
||||||
|
# executing on the SAME replica that owns it (cross-instance pub/sub stop is phase
|
||||||
|
# 2 and not yet reliable). Do NOT enable autonomousRuns on a horizontally-scaled
|
||||||
|
# deployment (multiple replicas behind a load balancer, or Docmost cloud
|
||||||
|
# CLOUD=true) — run a single instance instead. The server logs a startup WARNING
|
||||||
|
# when it detects a multi-instance deployment (CLOUD=true) so the constraint is
|
||||||
|
# visible, and a startup sweep settles any run left dangling by a restart.
|
||||||
|
|
||||||
# --- Anonymous public-share AI assistant ---
|
# --- Anonymous public-share AI assistant ---
|
||||||
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
||||||
# When enabled, anonymous visitors of a published share can ask an AI about that
|
# When enabled, anonymous visitors of a published share can ask an AI about that
|
||||||
@@ -242,3 +256,27 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# FAILS CLOSED if Redis is unavailable (default: 1,000,000 tokens per workspace
|
# FAILS CLOSED if Redis is unavailable (default: 1,000,000 tokens per workspace
|
||||||
# per rolling day).
|
# per rolling day).
|
||||||
# SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY=1000000
|
# SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY=1000000
|
||||||
|
|
||||||
|
# --- Observability / perf metrics (#355) ---
|
||||||
|
#
|
||||||
|
# Two INDEPENDENT toggles, both OFF by default:
|
||||||
|
#
|
||||||
|
# 1) METRICS_PORT — the server-side Prometheus scrape endpoint.
|
||||||
|
# UNSET (default) => the whole prom subsystem is OFF: no registry, no
|
||||||
|
# collectors, and NOTHING is exposed on the main app port. There is NO
|
||||||
|
# default port — leaving it blank disables it. When set to a port (e.g.
|
||||||
|
# 9464), a SEPARATE bare node:http listener serves GET /metrics on that port
|
||||||
|
# only (never on the main :3000 app listener), for a scraper such as
|
||||||
|
# VictoriaMetrics/Prometheus reaching it as <host>:<port>/metrics.
|
||||||
|
# METRICS_PORT=9464
|
||||||
|
#
|
||||||
|
# 2) CLIENT_TELEMETRY_ENABLED — the public client perf-telemetry sink.
|
||||||
|
# OFF by default. When true, the unauthenticated POST /api/telemetry/vitals
|
||||||
|
# endpoint is registered and browsers collect + send web-vitals / editor
|
||||||
|
# metrics into the `client_metrics` table (read directly by Grafana, separate
|
||||||
|
# from METRICS_PORT). Leave OFF unless you actually consume this data: the
|
||||||
|
# endpoint is public and the table has NO app-side retention, so enabling it
|
||||||
|
# requires an EXTERNAL pruner to bound `client_metrics` growth (the deployed
|
||||||
|
# infra prunes rows >90d via a maintenance container). When off, the endpoint
|
||||||
|
# does not exist and the client installs no observers.
|
||||||
|
# CLIENT_TELEMETRY_ENABLED=false
|
||||||
|
|||||||
@@ -13,6 +13,49 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# Guard against a long-lived branch adding a migration whose timestamped
|
||||||
|
# filename sorts BEFORE migrations already applied on the target branch (and
|
||||||
|
# thus in prod). The Kysely startup migrator rejects that as "corrupted
|
||||||
|
# migrations" and crash-loops the app on boot (incident #361). This gate fails
|
||||||
|
# the PR so the migration is renamed to a current timestamp before merge. Only
|
||||||
|
# runs for pull_request events (needs a base branch to diff against).
|
||||||
|
migration-order:
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- name: Checkout (full history for the base-branch diff)
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Added migrations must sort after the newest on the base branch
|
||||||
|
env:
|
||||||
|
TARGET_BRANCH: ${{ github.base_ref }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
MIG_DIR="apps/server/src/database/migrations"
|
||||||
|
# checkout above already did fetch-depth:0 (full history). Fetch the base
|
||||||
|
# WITHOUT --depth (a shallow graft would truncate the base history and
|
||||||
|
# break the merge-base when the base has moved ahead of the PR merge —
|
||||||
|
# exactly the long-branch-vs-moving-base case this gate guards, #361).
|
||||||
|
git fetch --no-tags origin "$TARGET_BRANCH"
|
||||||
|
newest_on_target=$(git ls-tree -r --name-only "origin/${TARGET_BRANCH}" "$MIG_DIR" | sort | tail -1)
|
||||||
|
# NO `|| true`: a diff failure (e.g. an unresolved merge-base) must fail
|
||||||
|
# the job CLOSED — a gate whose job is to BLOCK must never pass on error.
|
||||||
|
# `set -e` above already aborts on a non-zero diff exit.
|
||||||
|
added=$(git diff --diff-filter=A --name-only "origin/${TARGET_BRANCH}...HEAD" -- "$MIG_DIR")
|
||||||
|
bad=0
|
||||||
|
for f in $added; do
|
||||||
|
if [[ "$f" < "$newest_on_target" || "$f" == "$newest_on_target" ]]; then
|
||||||
|
echo "::error::Migration $f sorts at or before the newest on ${TARGET_BRANCH} ($newest_on_target) — rename it with a CURRENT timestamp before merge (do not change its contents). See incident #361."
|
||||||
|
bad=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$bad" -eq 0 ]; then
|
||||||
|
echo "Migration order OK (added migrations all sort after $newest_on_target)."
|
||||||
|
fi
|
||||||
|
exit $bad
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
|
|||||||
| `apps/client` | `client` | React 18 + Vite + Mantine 8 + TanStack Query + Jotai | SPA frontend |
|
| `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/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`. Consumes the shared converter/schema from `@docmost/prosemirror-markdown` (#293) — it no longer carries its own vendored converter/schema copy |
|
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Consumes the shared converter/schema from `@docmost/prosemirror-markdown` (#293) — it no longer carries its own vendored converter/schema copy |
|
||||||
| `packages/prosemirror-markdown` | `@docmost/prosemirror-markdown` | Tiptap, marked, jsdom | The single, canonical ProseMirror↔Markdown converter + Docmost schema mirror (#293). Consumed by `mcp` and `git-sync`; there is exactly ONE copy of the converter now |
|
| `packages/prosemirror-markdown` | `@docmost/prosemirror-markdown` | Tiptap, marked, jsdom | The single, canonical ProseMirror↔Markdown converter + Docmost schema mirror (#293). Consumed by `mcp`, `git-sync`, AND `apps/server` (server-side markdown import/export, #345); there is exactly ONE copy of the converter now |
|
||||||
|
|
||||||
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
||||||
|
|
||||||
@@ -214,6 +214,12 @@ Run from the repo root unless noted. The dev workflow needs **Postgres (with the
|
|||||||
> server, `APP_SECRET` mismatch between processes, a stale `editor-ext` white-
|
> server, `APP_SECRET` mismatch between processes, a stale `editor-ext` white-
|
||||||
> screening the client, LAN exposure. See **[docs/dev-stand.md](docs/dev-stand.md)**
|
> screening the client, LAN exposure. See **[docs/dev-stand.md](docs/dev-stand.md)**
|
||||||
> for the step-by-step and the traps.
|
> for the step-by-step and the traps.
|
||||||
|
>
|
||||||
|
> **Testing the app against a stand** (browser E2E + out-of-band verification) has
|
||||||
|
> its own non-obvious traps — the page has two ProseMirror editors (only the body is
|
||||||
|
> collab-bound), a ~10s store debounce, and API-seeding the thing under test is a
|
||||||
|
> silent no-test. See **[docs/how-to-test.md](docs/how-to-test.md)** before writing
|
||||||
|
> UI tests.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install # install all workspaces (uses pnpm patches; see package.json `pnpm.patchedDependencies`)
|
pnpm install # install all workspaces (uses pnpm patches; see package.json `pnpm.patchedDependencies`)
|
||||||
@@ -250,7 +256,10 @@ pnpm --filter server migration:codegen # regenerate src/databa
|
|||||||
```
|
```
|
||||||
Migration files live in `apps/server/src/database/migrations/` and are named `YYYYMMDDThhmmss-description.ts`. Fork-specific migrations only **add** tables (`page_embeddings`, `ai_chats`, `ai_chat_messages`, `ai_provider_credentials`, `ai_mcp_servers`, `page_template_references`) and columns (e.g. `pages.is_template`, a `NOT NULL DEFAULT false` boolean) — never drop/rewrite Docmost data.
|
Migration files live in `apps/server/src/database/migrations/` and are named `YYYYMMDDThhmmss-description.ts`. Fork-specific migrations only **add** tables (`page_embeddings`, `ai_chats`, `ai_chat_messages`, `ai_provider_credentials`, `ai_mcp_servers`, `page_template_references`) and columns (e.g. `pages.is_template`, a `NOT NULL DEFAULT false` boolean) — never drop/rewrite Docmost data.
|
||||||
|
|
||||||
**Migration ordering — always check when merging branches/features.** Kysely runs migrations in **alphabetical (= timestamp) order** and refuses to start if a *new* migration sorts **before** one already applied to the DB (`corrupted migrations: ... must always have a name that comes alphabetically after the last executed migration`). When you merge a branch or land a feature, verify your migration's timestamp still sorts **after every migration that may already be applied on the target** (`/bin/ls -1 apps/server/src/database/migrations | sort | tail`). Branches developed in parallel routinely break this: a feature branch adds `…T130000-…`, `main` meanwhile ships and deploys `…T150000-…`, and after the merge the older-timestamped file is rejected at boot. **Fix = rename your migration to a timestamp after the latest one already in the target** (content unchanged — the filename is the ordering key), then rebuild so the compiled `dist/database/migrations/` picks up the new name.
|
**Migration ordering — always check when merging branches/features.** Kysely runs migrations in **alphabetical (= timestamp) order**. A *new* migration that sorts **before** one already applied to the DB is a "back-dated" migration, which branches developed in parallel routinely produce: a feature branch adds `…T130000-…`, `develop` meanwhile ships and deploys `…T150000-…`, and after the merge the older-timestamped file has been skipped. Two layers guard this (both added for incident #361, where a back-dated migration crash-looped prod for ~11 min):
|
||||||
|
|
||||||
|
- **CI gate (primary):** the `migration-order` job in `.github/workflows/test.yml` fails a PR whose added migration sorts at/before the newest on the base branch. **So the fix is to rename your migration to a timestamp after the latest one already in the target** (`/bin/ls -1 apps/server/src/database/migrations | sort | tail`; content unchanged — the filename is the ordering key), then rebuild so the compiled `dist/database/migrations/` picks up the new name.
|
||||||
|
- **Runtime safety net:** both Migrators (`migration.service.ts` startup auto-migrate + `migrate.ts` CLI) set `allowUnorderedMigrations: true`, so the app does **not** refuse to start on an out-of-order migration — it applies the skipped older one instead of crash-looping. Kysely's `#ensureNoMissingMigrations` guard is still on (a *removed* applied migration is still an error). Because apply order can then differ from lexicographic across instances, migrations must stay **independent** (each creates its own objects) — the CI gate remains the primary line; this net only covers a gate bypass (manual push / hotfix branch).
|
||||||
|
|
||||||
## Architecture — the big picture
|
## Architecture — the big picture
|
||||||
|
|
||||||
@@ -279,11 +288,12 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
|
|||||||
- `core/ai-chat/tools/` — the agent's ~40 read+write tools. Every tool runs under the **calling user's** CASL permissions via a per-user loopback access token (`docmost-client.loader.ts`), so the agent can never exceed what the user could do. Only **reversible** operations are exposed (page history + trash; no permanent delete). Agent edits get an "AI agent" provenance badge in page history (`20260616T130000-agent-provenance` migration).
|
- `core/ai-chat/tools/` — the agent's ~40 read+write tools. Every tool runs under the **calling user's** CASL permissions via a per-user loopback access token (`docmost-client.loader.ts`), so the agent can never exceed what the user could do. Only **reversible** operations are exposed (page history + trash; no permanent delete). Agent edits get an "AI agent" provenance badge in page history (`20260616T130000-agent-provenance` migration).
|
||||||
- `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint.
|
- `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint.
|
||||||
- `core/ai-chat/external-mcp/` — admins can attach external MCP servers (e.g. Tavily) to give the agent web access. **`ssrf-guard.ts` validates outbound MCP URLs against SSRF** — keep that guard in the path when touching external-MCP connection logic.
|
- `core/ai-chat/external-mcp/` — admins can attach external MCP servers (e.g. Tavily) to give the agent web access. **`ssrf-guard.ts` validates outbound MCP URLs against SSRF** — keep that guard in the path when touching external-MCP connection logic.
|
||||||
|
- `core/ai-chat/ai-chat-run.service.ts` + `ai_chat_runs` — **detached/autonomous agent runs** (`#184`), behind the per-workspace `settings.ai.autonomousRuns` flag (off by default). When on, a turn becomes a server-side RUN that survives a browser disconnect; only an explicit `POST /ai-chat/stop` ends it, and a client reconnects/live-follows via `POST /ai-chat/run`. **DEPLOY CONSTRAINT — single-instance only in phase 1:** Stop and the AbortController that backs it are process-local, so a Stop only aborts a run executing on the **same** replica that owns it (cross-instance pub/sub stop is phase 2). Do **not** enable `autonomousRuns` on a horizontally-scaled deployment (multiple replicas behind a load balancer, or Docmost cloud `CLOUD=true`) — run a single instance instead. The server logs a startup WARNING when it detects a multi-instance deployment (`CLOUD=true`) so the constraint is visible. The startup sweep settles any run left dangling by a restart.
|
||||||
|
|
||||||
### Client structure
|
### 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:
|
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.
|
- **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. The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by both `mcp` and `git-sync` — do NOT reintroduce a per-package copy. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence.
|
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, and `apps/server` (#345) — do NOT reintroduce a per-package copy. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence.
|
||||||
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
||||||
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
||||||
|
|
||||||
@@ -293,7 +303,7 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
|||||||
- **Errors must never be swallowed or shown as generic messages.** Every caught error MUST (1) be logged in full to the console/logger — error name, message, stack, `cause`, and (for HTTP/provider failures) the status code and response body — and (2) be surfaced to the user with a *specific, human-readable explanation of what actually went wrong*, never a bare generic string like "Something went wrong" / "Could not start recording" / "Transcription failed". Include the real reason (the underlying error/provider message) in the user-facing text. On the server, wrap third-party/provider failures with `describeProviderError` (or equivalent) and rethrow as a meaningful HTTP status + message — never let them collapse into an opaque 500. On the client, `console.error(<context>, err)` the raw error AND show the extracted reason (e.g. `err.response?.data?.message`, or the error `name: message`) in the notification.
|
- **Errors must never be swallowed or shown as generic messages.** Every caught error MUST (1) be logged in full to the console/logger — error name, message, stack, `cause`, and (for HTTP/provider failures) the status code and response body — and (2) be surfaced to the user with a *specific, human-readable explanation of what actually went wrong*, never a bare generic string like "Something went wrong" / "Could not start recording" / "Transcription failed". Include the real reason (the underlying error/provider message) in the user-facing text. On the server, wrap third-party/provider failures with `describeProviderError` (or equivalent) and rethrow as a meaningful HTTP status + message — never let them collapse into an opaque 500. On the client, `console.error(<context>, err)` the raw error AND show the extracted reason (e.g. `err.response?.data?.message`, or the error `name: message`) in the notification.
|
||||||
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
|
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
|
||||||
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
|
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
|
||||||
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons.
|
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`, `ai`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons. The `ai@6.0.134` patch disables the SDK's O(n²) cumulative `partialOutput` accumulation when no output strategy is requested (server heap OOM on long agent runs, #184; tripwire test: `apps/server/src/integrations/ai/ai-sdk-partial-output.patch.spec.ts`) — it MUST be re-created via `pnpm patch` when bumping `ai`.
|
||||||
- **Adding/renaming/removing an MCP tool requires updating `SERVER_INSTRUCTIONS`** in `packages/mcp/src/index.ts` — the intent-routing guide MCP clients receive on initialize. This applies both to inline `server.registerTool(...)` calls in `index.ts` and to specs in `packages/mcp/src/tool-specs.ts`. Enforced by `packages/mcp/test/unit/server-instructions.test.mjs`, which fails when a registered tool is not mentioned in the guide (deliberate opt-outs go into its `EXCEPTIONS` list). `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
|
- **Adding/renaming/removing an MCP tool requires updating `SERVER_INSTRUCTIONS`** in `packages/mcp/src/index.ts` — the intent-routing guide MCP clients receive on initialize. This applies both to inline `server.registerTool(...)` calls in `index.ts` and to specs in `packages/mcp/src/tool-specs.ts`. Enforced by `packages/mcp/test/unit/server-instructions.test.mjs`, which fails when a registered tool is not mentioned in the guide (deliberate opt-outs go into its `EXCEPTIONS` list). `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
|
||||||
|
|
||||||
## CI / release
|
## CI / release
|
||||||
|
|||||||
@@ -72,6 +72,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
append/prepend fragments, nor to COMMENT bodies — a comment may legitimately
|
append/prepend fragments, nor to COMMENT bodies — a comment may legitimately
|
||||||
contain a standalone footnote definition, which canonicalization would drop.
|
contain a standalone footnote definition, which canonicalization would drop.
|
||||||
(#228)
|
(#228)
|
||||||
|
- **Detached, autonomous agent runs that survive a browser disconnect.** When the
|
||||||
|
new `settings.ai.autonomousRuns` workspace flag is on (off by default), an
|
||||||
|
AI-chat turn becomes a first-class, server-side RUN tracked in a new
|
||||||
|
`ai_chat_runs` table instead of a socket-bound stream: closing the tab or
|
||||||
|
losing the connection no longer aborts the turn — it keeps executing and
|
||||||
|
persisting server-side, and only an explicit Stop ends it. A client can
|
||||||
|
reconnect and live-follow (or stop) an in-flight run via `POST /ai-chat/run`
|
||||||
|
(resolve the latest run + its assistant message for a chat) and
|
||||||
|
`POST /ai-chat/stop` (stop by `runId` or `chatId`). A partial unique index
|
||||||
|
enforces one active run per chat, and a startup sweep settles any run left
|
||||||
|
dangling by a restart. Phase 1 is single-instance-only (cross-instance Stop is
|
||||||
|
not yet reliable); the server warns at startup on a horizontally-scaled
|
||||||
|
deployment. (#184)
|
||||||
- **Out-of-band page transfer via an in-RAM blob sandbox (`stash_page`).** A
|
- **Out-of-band page transfer via an in-RAM blob sandbox (`stash_page`).** A
|
||||||
new MCP tool serializes a whole page (its full ProseMirror JSON, with every
|
new MCP tool serializes a whole page (its full ProseMirror JSON, with every
|
||||||
internal image/file mirrored) into an ephemeral in-RAM blob and returns only
|
internal image/file mirrored) into an ephemeral in-RAM blob and returns only
|
||||||
@@ -156,6 +169,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **The server no longer runs out of heap during long autonomous agent runs.** A
|
||||||
|
new pnpm patch on `ai@6.0.134` stops the SDK from building a cumulative
|
||||||
|
snapshot of the ENTIRE turn text on every streamed text-delta when no output
|
||||||
|
strategy was requested (our server never requests one). Unpatched, those
|
||||||
|
O(n²) `partialOutput` snapshots piled up in a never-consumed internal
|
||||||
|
`tee()` branch of the stream result — a ~20-step, ~28k-chunk agent run
|
||||||
|
retained ~1.7 GB and OOM'd the 2 GB JS heap. Streaming granularity is
|
||||||
|
unchanged; the patch must be re-created if `ai` is ever bumped. (#184)
|
||||||
- **Internal links in exported Markdown no longer lose their visible text.** A
|
- **Internal links in exported Markdown no longer lose their visible text.** A
|
||||||
link whose target page name had no file extension (e.g. a bare title) was
|
link whose target page name had no file extension (e.g. a bare title) was
|
||||||
collapsed to empty text during export, producing an unclickable, label-less
|
collapsed to empty text during export, producing an unclickable, label-less
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
"react-clear-modal": "^2.0.18",
|
"react-clear-modal": "^2.0.18",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-drawio": "1.0.7",
|
"react-drawio": "1.0.7",
|
||||||
|
"web-vitals": "^5.1.0",
|
||||||
"react-error-boundary": "6.1.1",
|
"react-error-boundary": "6.1.1",
|
||||||
"react-helmet-async": "3.0.0",
|
"react-helmet-async": "3.0.0",
|
||||||
"react-i18next": "16.5.8",
|
"react-i18next": "16.5.8",
|
||||||
|
|||||||
@@ -1373,6 +1373,39 @@
|
|||||||
"The role catalog is unavailable": "The role catalog is unavailable",
|
"The role catalog is unavailable": "The role catalog is unavailable",
|
||||||
"Please try again later.": "Please try again later.",
|
"Please try again later.": "Please try again later.",
|
||||||
"No bundles available": "No bundles available",
|
"No bundles available": "No bundles available",
|
||||||
|
"Content": "Content",
|
||||||
|
"Content language of the roles": "Content language of the roles",
|
||||||
|
"{{count}} updates available in {{bundles}} bundles": "{{count}} updates available in {{bundles}} bundles",
|
||||||
|
"Update all ({{count}})": "Update all ({{count}})",
|
||||||
|
"Updating {{current}}/{{total}}…": "Updating {{current}}/{{total}}…",
|
||||||
|
"{{count}} roles are installed in another language. A different language installs separately and appears as new.": "{{count}} roles are installed in another language. A different language installs separately and appears as new.",
|
||||||
|
"{{count}} roles": "{{count}} roles",
|
||||||
|
"{{count}} new — none installed": "{{count}} new — none installed",
|
||||||
|
"All installed · up to date": "All installed · up to date",
|
||||||
|
"{{count}} updates · {{installed}} up to date": "{{count}} updates · {{installed}} up to date",
|
||||||
|
"{{count}} new": "{{count}} new",
|
||||||
|
"{{count}} installed": "{{count}} installed",
|
||||||
|
"{{count}} updates": "{{count}} updates",
|
||||||
|
"Install bundle": "Install bundle",
|
||||||
|
"Install {{count}} selected": "Install {{count}} selected",
|
||||||
|
"Install bundle ({{count}})": "Install bundle ({{count}})",
|
||||||
|
"{{selected}} of {{total}} selected": "{{selected}} of {{total}} selected",
|
||||||
|
"Select all": "Select all",
|
||||||
|
"Deselect all": "Deselect all",
|
||||||
|
"Skipped": "Skipped",
|
||||||
|
"v{{version}}": "v{{version}}",
|
||||||
|
"{{count}} roles installed": "{{count}} roles installed",
|
||||||
|
"{{count}} roles installed · {{renamed}} renamed": "{{count}} roles installed · {{renamed}} renamed",
|
||||||
|
"{{count}} roles updated": "{{count}} roles updated",
|
||||||
|
"Installed {{installed}} · {{skipped}} skipped": "Installed {{installed}} · {{skipped}} skipped",
|
||||||
|
"A role named \"{{name}}\" already exists in this workspace.": "A role named \"{{name}}\" already exists in this workspace.",
|
||||||
|
"\"{{name}}\" is already installed.": "\"{{name}}\" is already installed.",
|
||||||
|
"Rename & install": "Rename & install",
|
||||||
|
"Couldn’t load the catalog": "Couldn’t load the catalog",
|
||||||
|
"Check your connection and try again. Installed roles are not affected.": "Check your connection and try again. Installed roles are not affected.",
|
||||||
|
"Retry": "Retry",
|
||||||
|
"The catalog is empty": "The catalog is empty",
|
||||||
|
"No role bundles are published for this language yet. Try switching the content language.": "No role bundles are published for this language yet. Try switching the content language.",
|
||||||
"Already up to date": "Already up to date",
|
"Already up to date": "Already up to date",
|
||||||
"Updated to the latest version": "Updated to the latest version",
|
"Updated to the latest version": "Updated to the latest version",
|
||||||
"This role is no longer in the catalog": "This role is no longer in the catalog",
|
"This role is no longer in the catalog": "This role is no longer in the catalog",
|
||||||
|
|||||||
@@ -1235,6 +1235,39 @@
|
|||||||
"The role catalog is unavailable": "Каталог ролей недоступен",
|
"The role catalog is unavailable": "Каталог ролей недоступен",
|
||||||
"Please try again later.": "Попробуйте позже.",
|
"Please try again later.": "Попробуйте позже.",
|
||||||
"No bundles available": "Наборы недоступны",
|
"No bundles available": "Наборы недоступны",
|
||||||
|
"Content": "Язык контента",
|
||||||
|
"Content language of the roles": "Язык контента ролей",
|
||||||
|
"{{count}} updates available in {{bundles}} bundles": "Доступно обновлений: {{count}} в наборах: {{bundles}}",
|
||||||
|
"Update all ({{count}})": "Обновить все ({{count}})",
|
||||||
|
"Updating {{current}}/{{total}}…": "Обновление {{current}}/{{total}}…",
|
||||||
|
"{{count}} roles are installed in another language. A different language installs separately and appears as new.": "Ролей установлено на другом языке: {{count}}. Другой язык устанавливается отдельно и отображается как новый.",
|
||||||
|
"{{count}} roles": "ролей: {{count}}",
|
||||||
|
"{{count}} new — none installed": "новых: {{count}} — ничего не установлено",
|
||||||
|
"All installed · up to date": "Все установлены · актуальны",
|
||||||
|
"{{count}} updates · {{installed}} up to date": "обновлений: {{count}} · актуальны: {{installed}}",
|
||||||
|
"{{count}} new": "новых: {{count}}",
|
||||||
|
"{{count}} installed": "установлено: {{count}}",
|
||||||
|
"{{count}} updates": "обновлений: {{count}}",
|
||||||
|
"Install bundle": "Установить набор",
|
||||||
|
"Install {{count}} selected": "Установить выбранные ({{count}})",
|
||||||
|
"Install bundle ({{count}})": "Установить набор ({{count}})",
|
||||||
|
"{{selected}} of {{total}} selected": "выбрано {{selected}} из {{total}}",
|
||||||
|
"Select all": "Выбрать все",
|
||||||
|
"Deselect all": "Снять выбор",
|
||||||
|
"Skipped": "Пропущено",
|
||||||
|
"v{{version}}": "v{{version}}",
|
||||||
|
"{{count}} roles installed": "Установлено ролей: {{count}}",
|
||||||
|
"{{count}} roles installed · {{renamed}} renamed": "Установлено ролей: {{count}} · переименовано: {{renamed}}",
|
||||||
|
"{{count}} roles updated": "Обновлено ролей: {{count}}",
|
||||||
|
"Installed {{installed}} · {{skipped}} skipped": "Установлено: {{installed}} · пропущено: {{skipped}}",
|
||||||
|
"A role named \"{{name}}\" already exists in this workspace.": "Роль с именем «{{name}}» уже существует в этом рабочем пространстве.",
|
||||||
|
"\"{{name}}\" is already installed.": "«{{name}}» уже установлена.",
|
||||||
|
"Rename & install": "Переименовать и установить",
|
||||||
|
"Couldn’t load the catalog": "Не удалось загрузить каталог",
|
||||||
|
"Check your connection and try again. Installed roles are not affected.": "Проверьте подключение и попробуйте снова. Установленные роли не затронуты.",
|
||||||
|
"Retry": "Повторить",
|
||||||
|
"The catalog is empty": "Каталог пуст",
|
||||||
|
"No role bundles are published for this language yet. Try switching the content language.": "Для этого языка ещё не опубликовано ни одного набора ролей. Попробуйте сменить язык контента.",
|
||||||
"No roles configured": "Роли не настроены",
|
"No roles configured": "Роли не настроены",
|
||||||
"Already up to date": "Уже актуальна",
|
"Already up to date": "Уже актуальна",
|
||||||
"Updated to the latest version": "Обновлено до последней версии",
|
"Updated to the latest version": "Обновлено до последней версии",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
IconPlus,
|
IconPlus,
|
||||||
IconX,
|
IconX,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useAtom, useSetAtom } from "jotai";
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
import { useLocation, useMatch } from "react-router-dom";
|
import { useLocation, useMatch } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -41,13 +41,24 @@ import { extractPageSlugId } from "@/lib";
|
|||||||
import {
|
import {
|
||||||
AI_CHATS_RQ_KEY,
|
AI_CHATS_RQ_KEY,
|
||||||
AI_CHAT_MESSAGES_RQ_KEY,
|
AI_CHAT_MESSAGES_RQ_KEY,
|
||||||
|
AI_CHAT_RUN_RQ_KEY,
|
||||||
useAiChatMessagesQuery,
|
useAiChatMessagesQuery,
|
||||||
|
useAiChatRunQuery,
|
||||||
useAiChatsQuery,
|
useAiChatsQuery,
|
||||||
useAiRolesQuery,
|
useAiRolesQuery,
|
||||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||||
|
import {
|
||||||
|
shouldClearLatchOnQueryError,
|
||||||
|
shouldClearStoppingLatch,
|
||||||
|
shouldObserveRun,
|
||||||
|
} from "@/features/ai-chat/utils/run-polling.ts";
|
||||||
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
||||||
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
||||||
import { exportAiChat } from "@/features/ai-chat/services/ai-chat-service.ts";
|
import {
|
||||||
|
exportAiChat,
|
||||||
|
stopRun,
|
||||||
|
} from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||||
import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts";
|
import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts";
|
||||||
import {
|
import {
|
||||||
shouldCollapseOnOutsidePointer,
|
shouldCollapseOnOutsidePointer,
|
||||||
@@ -234,6 +245,147 @@ export default function AiChatWindow() {
|
|||||||
const { data: messageRows, isLoading: messagesLoading } =
|
const { data: messageRows, isLoading: messagesLoading } =
|
||||||
useAiChatMessagesQuery(activeChatId ?? undefined);
|
useAiChatMessagesQuery(activeChatId ?? undefined);
|
||||||
|
|
||||||
|
// #184 reconnect-and-live-follow. Whether detached agent runs are enabled for
|
||||||
|
// this workspace. The reconnect endpoint itself is NOT flag-gated server-side
|
||||||
|
// (it is only owner-gated and returns `{ run: null }` when the chat has no
|
||||||
|
// run); but when the feature is off no runs are ever created, so polling it
|
||||||
|
// would always come back empty — we gate it off here to avoid pointless polls.
|
||||||
|
const workspace = useAtomValue(workspaceAtom);
|
||||||
|
const autonomousRunsEnabled =
|
||||||
|
workspace?.settings?.ai?.autonomousRuns === true;
|
||||||
|
|
||||||
|
// Whether THIS tab is the one actively streaming the open chat's run locally
|
||||||
|
// (it started the run here and holds the SSE). Reported up from ChatThread. We
|
||||||
|
// are the STREAMER while true and a passive OBSERVER while false — the basis of
|
||||||
|
// the observer-vs-streamer detection. Reset to false by the fresh ChatThread's
|
||||||
|
// mount effect on every chat switch.
|
||||||
|
const [localStreaming, setLocalStreaming] = useState(false);
|
||||||
|
const onStreamingChange = useCallback((streaming: boolean) => {
|
||||||
|
setLocalStreaming(streaming);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// #184 Stop wiring. While a detached run is being stopped we SUPPRESS the
|
||||||
|
// observer merge so the stopping run's still-persisting output does not
|
||||||
|
// re-stream back into view between the moment the user pressed Stop and the run
|
||||||
|
// actually settling as 'aborted' server-side. Polling itself keeps running (so
|
||||||
|
// the terminal transition is still detected) — only the visual merge is gated.
|
||||||
|
// Cleared when the run is observed terminal (below) or the chat is switched.
|
||||||
|
const [stoppingRun, setStoppingRun] = useState(false);
|
||||||
|
// Reset the stopping latch whenever the open chat changes: it is scoped to the
|
||||||
|
// run of the previously-open chat.
|
||||||
|
useEffect(() => {
|
||||||
|
setStoppingRun(false);
|
||||||
|
}, [activeChatId]);
|
||||||
|
|
||||||
|
// Authoritative stop of the open chat's detached run (the Stop button in
|
||||||
|
// autonomous mode). Latch "stopping" first (suppresses the re-stream flash),
|
||||||
|
// then request the server stop — the ONLY thing that ends a detached run; a mere
|
||||||
|
// local SSE abort is a client disconnect the server ignores. On failure we
|
||||||
|
// release the latch so the observer resumes (better to show the live run than to
|
||||||
|
// freeze the view) and surface the error.
|
||||||
|
const handleServerStop = useCallback(
|
||||||
|
(chatId: string): void => {
|
||||||
|
setStoppingRun(true);
|
||||||
|
// #234 F4: drop the PREVIOUS turn's run from the cache so `run` becomes null
|
||||||
|
// until the CURRENT turn's run is fetched fresh. Without this, once the local
|
||||||
|
// stream aborts (localStreaming -> false) the run query re-enables and
|
||||||
|
// react-query SYNCHRONOUSLY returns the still-cached prior terminal run; the
|
||||||
|
// terminal effect would then clear the stopping latch against that STALE run
|
||||||
|
// before the current turn's (still-running, detached, growing) run is ever
|
||||||
|
// observed — re-opening the observer merge and flashing the growing output
|
||||||
|
// over the frozen row. With the cache cleared the terminal effect's
|
||||||
|
// `if (!run) return` holds the latch until the current run itself is observed
|
||||||
|
// terminal (see shouldClearStoppingLatch).
|
||||||
|
queryClient.removeQueries({ queryKey: AI_CHAT_RUN_RQ_KEY(chatId) });
|
||||||
|
void stopRun(chatId).catch(() => {
|
||||||
|
setStoppingRun(false);
|
||||||
|
notifications.show({
|
||||||
|
message: t("Failed to stop the run"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[t, queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Poll the latest run of the open chat ONLY when we are a passive observer:
|
||||||
|
// feature on, a chat is open, and we are NOT the local streamer (the streamer
|
||||||
|
// already has the live SSE — polling/merging too would double-render). The
|
||||||
|
// query's own status-keyed refetchInterval stops once the run is terminal.
|
||||||
|
const { data: runData, isError: runQueryFailed } = useAiChatRunQuery(
|
||||||
|
activeChatId ?? undefined,
|
||||||
|
autonomousRunsEnabled && !localStreaming,
|
||||||
|
);
|
||||||
|
const run = runData?.run ?? null;
|
||||||
|
|
||||||
|
// Safety net (#234 F4 review): after handleServerStop clears the run cache,
|
||||||
|
// `run` is null until the current turn's run is fetched fresh, and the terminal
|
||||||
|
// effect below holds the latch via `if (!run) return`. If that refetch instead
|
||||||
|
// ERRORS PERMANENTLY (the GET-run keeps failing) while we are no longer the
|
||||||
|
// streamer, the run stays null, its status-keyed refetchInterval is off, and
|
||||||
|
// nothing would ever observe a terminal run — freezing the view with the
|
||||||
|
// observer merge suppressed. Release the latch on that error so the live view
|
||||||
|
// resumes rather than stays stuck (the local stopRun may already have succeeded
|
||||||
|
// independently).
|
||||||
|
//
|
||||||
|
// #234 F7: this must NOT fire on a TRANSIENT error while `run` is still an
|
||||||
|
// ACTIVE held run. In TanStack Query v5 (retry:false) the query's `data` is
|
||||||
|
// RETAINED on error, so `runQueryFailed` can be true while `run` is still
|
||||||
|
// pending/running — releasing then would re-open the observer merge and flash
|
||||||
|
// the growing detached run over the frozen row (the very flash F4 prevents). The
|
||||||
|
// decision is the pure, unit-tested `shouldClearLatchOnQueryError`, which gates
|
||||||
|
// on the run NOT being active: it cures only the genuine permanent-null-freeze
|
||||||
|
// (`run === null`) and never releases against an active run.
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
shouldClearLatchOnQueryError({
|
||||||
|
stoppingRun,
|
||||||
|
isLocalStreaming: localStreaming,
|
||||||
|
runQueryFailed,
|
||||||
|
run,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
setStoppingRun(false);
|
||||||
|
}, [stoppingRun, localStreaming, runQueryFailed, run]);
|
||||||
|
// The run's incrementally-persisted assistant message to merge into the thread,
|
||||||
|
// but only while we are an observer (never when we are the streamer — guards
|
||||||
|
// against a stale poll fighting the live stream). Includes a terminal run so the
|
||||||
|
// final persisted output is shown on reopen.
|
||||||
|
const observedRow =
|
||||||
|
shouldObserveRun(run, localStreaming) && !stoppingRun
|
||||||
|
? (runData?.message ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// When the observed run reaches a terminal status, do a final messages refetch
|
||||||
|
// so the persisted final state (token/context badge, export source) is shown,
|
||||||
|
// then the query's refetchInterval has already stopped polling. Deduped per run
|
||||||
|
// id so it fires exactly once per run, not on every subsequent poll-less render.
|
||||||
|
const finalizedRunIdRef = useRef<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!run || !activeChatId) return;
|
||||||
|
if (run.status === "pending" || run.status === "running") {
|
||||||
|
// Active again (a new run) — re-arm so its terminal transition fires once.
|
||||||
|
finalizedRunIdRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Terminal: a stop we requested has landed (or the run finished on its own),
|
||||||
|
// so release the stopping latch — the observer merge can now show the final
|
||||||
|
// persisted (aborted/finished) output without any live re-stream. The decision
|
||||||
|
// is the pure, unit-tested `shouldClearStoppingLatch` (run-polling.ts): release
|
||||||
|
// ONLY when we requested a stop, this tab is no longer the streamer, AND the
|
||||||
|
// CURRENT run is terminal. The #234 F4 cache removal in handleServerStop makes
|
||||||
|
// `run` null (this branch's `if (!run) return` above holds) until the current
|
||||||
|
// turn's run is fetched fresh, so the latch can never clear against a stale
|
||||||
|
// cached run.
|
||||||
|
if (shouldClearStoppingLatch({ stoppingRun, run, isLocalStreaming: localStreaming }))
|
||||||
|
setStoppingRun(false);
|
||||||
|
if (finalizedRunIdRef.current === run.id) return;
|
||||||
|
finalizedRunIdRef.current = run.id;
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: AI_CHAT_MESSAGES_RQ_KEY(activeChatId),
|
||||||
|
});
|
||||||
|
}, [run, activeChatId, queryClient, stoppingRun, localStreaming]);
|
||||||
|
|
||||||
// The page the user is currently viewing. AiChatWindow lives in a pathless
|
// The page the user is currently viewing. AiChatWindow lives in a pathless
|
||||||
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
||||||
// pathname against the authenticated page route instead so "the current page"
|
// pathname against the authenticated page route instead so "the current page"
|
||||||
@@ -882,6 +1034,18 @@ export default function AiChatWindow() {
|
|||||||
assistantName={currentRole?.name}
|
assistantName={currentRole?.name}
|
||||||
onTurnFinished={onTurnFinished}
|
onTurnFinished={onTurnFinished}
|
||||||
onServerChatId={onServerChatId}
|
onServerChatId={onServerChatId}
|
||||||
|
// #184: live-follow a still-running run when we reopened the chat as
|
||||||
|
// a passive observer; null when there is nothing to observe or this
|
||||||
|
// tab is the streamer. onStreamingChange lets the window stop polling
|
||||||
|
// while we are the streamer.
|
||||||
|
observedRow={observedRow}
|
||||||
|
onStreamingChange={onStreamingChange}
|
||||||
|
// #184: in autonomous mode the Stop button must hit the authoritative
|
||||||
|
// server stop (a local SSE abort is a client disconnect the server
|
||||||
|
// ignores). onServerStop also arms the "stopping" latch above so the
|
||||||
|
// stopped run's output does not re-stream via the observer merge.
|
||||||
|
autonomousRunsEnabled={autonomousRunsEnabled}
|
||||||
|
onServerStop={handleServerStop}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const h = vi.hoisted(() => ({
|
|||||||
onFinish: null as null | ((arg: Record<string, unknown>) => void),
|
onFinish: null as null | ((arg: Record<string, unknown>) => void),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
stop: vi.fn(),
|
stop: vi.fn(),
|
||||||
|
setMessages: vi.fn(),
|
||||||
transport: null as null | {
|
transport: null as null | {
|
||||||
prepareSendMessagesRequest: (arg: {
|
prepareSendMessagesRequest: (arg: {
|
||||||
messages: unknown[];
|
messages: unknown[];
|
||||||
@@ -30,6 +31,8 @@ vi.mock("@ai-sdk/react", () => ({
|
|||||||
status: h.state.status,
|
status: h.state.status,
|
||||||
stop: h.state.stop,
|
stop: h.state.stop,
|
||||||
error: null,
|
error: null,
|
||||||
|
// #184: ChatThread reads setMessages to merge a polled observer run.
|
||||||
|
setMessages: h.state.setMessages,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -228,3 +231,56 @@ describe("ChatThread — turn-end decision (onFinish)", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #184 passive-observer merge: when reconnecting to a still-running run, the
|
||||||
|
// parent feeds the polled run message via `observedRow`; ChatThread merges it via
|
||||||
|
// setMessages — but ONLY when this tab is NOT itself streaming (the streamer's
|
||||||
|
// SSE owns the view, so a stale observedRow must never overwrite it).
|
||||||
|
describe("ChatThread — observer run merge (#184)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
h.state.onFinish = null;
|
||||||
|
h.state.setMessages.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
const observedRow = {
|
||||||
|
id: "a-run",
|
||||||
|
role: "assistant",
|
||||||
|
content: "step 1\nstep 2",
|
||||||
|
metadata: {
|
||||||
|
parts: [{ type: "text", text: "step 1\nstep 2" }],
|
||||||
|
},
|
||||||
|
createdAt: "2026-01-01T00:00:00Z",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function renderObserver(status: string) {
|
||||||
|
h.state.status = status;
|
||||||
|
render(
|
||||||
|
<MantineProvider>
|
||||||
|
<ChatThread
|
||||||
|
chatId="c1"
|
||||||
|
initialRows={[]}
|
||||||
|
onTurnFinished={vi.fn()}
|
||||||
|
observedRow={observedRow as never}
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("merges the polled run message when this tab is a passive observer", () => {
|
||||||
|
renderObserver("ready");
|
||||||
|
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
|
||||||
|
// The updater replaces/append the observed assistant row by id.
|
||||||
|
const updater = h.state.setMessages.mock.calls[0][0] as (
|
||||||
|
prev: { id: string; parts: { text: string }[] }[],
|
||||||
|
) => { id: string; parts: { text: string }[] }[];
|
||||||
|
const merged = updater([{ id: "u1", parts: [{ text: "hi" }] }]);
|
||||||
|
expect(merged).toHaveLength(2);
|
||||||
|
expect(merged[1].id).toBe("a-run");
|
||||||
|
expect(merged[1].parts[0].text).toBe("step 1\nstep 2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT merge while THIS tab is the streamer (no double-render)", () => {
|
||||||
|
renderObserver("streaming");
|
||||||
|
expect(h.state.setMessages).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
} from "@/features/ai-chat/utils/role-launch.ts";
|
} from "@/features/ai-chat/utils/role-launch.ts";
|
||||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||||
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
|
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
|
||||||
|
import { mergeObservedMessage } from "@/features/ai-chat/utils/run-polling.ts";
|
||||||
import {
|
import {
|
||||||
dequeue,
|
dequeue,
|
||||||
enqueueMessage,
|
enqueueMessage,
|
||||||
@@ -86,6 +87,29 @@ interface ChatThreadProps {
|
|||||||
* Copy/export button available mid-stream). Distinct from onTurnFinished,
|
* Copy/export button available mid-stream). Distinct from onTurnFinished,
|
||||||
* which fires only at the terminal outcome. */
|
* which fires only at the terminal outcome. */
|
||||||
onServerChatId?: (serverChatId?: string) => void;
|
onServerChatId?: (serverChatId?: string) => void;
|
||||||
|
/** #184 reconnect-and-live-follow. When THIS tab reopened a chat whose agent
|
||||||
|
* run is still going (it is a PASSIVE OBSERVER — it did not start the run here),
|
||||||
|
* the parent polls the reconnect endpoint and feeds the run's incrementally-
|
||||||
|
* persisted assistant message here; we merge it into the live list so new
|
||||||
|
* steps/tool-calls appear as they are persisted. Null when there is nothing to
|
||||||
|
* observe (no run, feature off, or this tab IS the streamer). The merge is
|
||||||
|
* ADDITIONALLY guarded by our own `isStreaming`, so a stale value can never
|
||||||
|
* fight the local stream when we are the streamer. */
|
||||||
|
observedRow?: IAiChatMessageRow | null;
|
||||||
|
/** Report this tab's live streaming status up to the parent, so it can stop
|
||||||
|
* polling the run while WE are the active streamer (the SSE owns the view) and
|
||||||
|
* resume once we go idle. Called from an effect on every transition. */
|
||||||
|
onStreamingChange?: (streaming: boolean) => void;
|
||||||
|
/** #184: whether detached/autonomous agent runs are enabled for this workspace.
|
||||||
|
* When true the Stop button must additionally hit the AUTHORITATIVE server stop
|
||||||
|
* (via onServerStop) — aborting only the local SSE is just a client disconnect,
|
||||||
|
* which the server deliberately ignores, so the detached run would keep going. */
|
||||||
|
autonomousRunsEnabled?: boolean;
|
||||||
|
/** #184: request the server-side stop of this chat's active run (the parent owns
|
||||||
|
* the endpoint call + the "stopping" latch that keeps observer-polling from
|
||||||
|
* immediately re-streaming the stopping run's output). Called with the resolved
|
||||||
|
* chat id when the user presses Stop in autonomous mode. */
|
||||||
|
onServerStop?: (chatId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -131,6 +155,10 @@ export default function ChatThread({
|
|||||||
assistantName,
|
assistantName,
|
||||||
onTurnFinished,
|
onTurnFinished,
|
||||||
onServerChatId,
|
onServerChatId,
|
||||||
|
observedRow,
|
||||||
|
onStreamingChange,
|
||||||
|
autonomousRunsEnabled,
|
||||||
|
onServerStop,
|
||||||
}: ChatThreadProps) {
|
}: ChatThreadProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -216,6 +244,16 @@ export default function ChatThread({
|
|||||||
const flushOnAbortRef = useRef(false);
|
const flushOnAbortRef = useRef(false);
|
||||||
const interruptNextSendRef = useRef(false);
|
const interruptNextSendRef = useRef(false);
|
||||||
|
|
||||||
|
// #234 F5: the user pressed Stop while streaming a BRAND-NEW chat whose server
|
||||||
|
// chat id has not been adopted yet (the `start` chunk carrying it hadn't landed
|
||||||
|
// when Stop was pressed). A local SSE abort alone does NOT stop the DETACHED
|
||||||
|
// autonomous run — it keeps burning tokens and WRITING TO PAGES — so we cannot
|
||||||
|
// just no-op. We latch the stop as PENDING and fire the authoritative server
|
||||||
|
// stop the moment onServerChatId adopts the id (below). Read-and-cleared there;
|
||||||
|
// also defused on every new turn start so it can never fire against a later,
|
||||||
|
// unrelated turn's run.
|
||||||
|
const stopPendingRef = useRef(false);
|
||||||
|
|
||||||
// FIFO dequeue + send the next queued message (no-op when the queue is empty).
|
// FIFO dequeue + send the next queued message (no-op when the queue is empty).
|
||||||
// Returns whether a message was actually sent, so callers can tell an empty
|
// Returns whether a message was actually sent, so callers can tell an empty
|
||||||
// dequeue (nothing to flush) from a real send.
|
// dequeue (nothing to flush) from a real send.
|
||||||
@@ -274,7 +312,7 @@ export default function ChatThread({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { messages, sendMessage, status, stop, error } = useChat({
|
const { messages, sendMessage, status, stop, error, setMessages } = useChat({
|
||||||
// Stable per-mount key. Existing chats use their real id; new chats use a
|
// Stable per-mount key. Existing chats use their real id; new chats use a
|
||||||
// generated client id (never `undefined`) so the store is NOT re-created on
|
// generated client id (never `undefined`) so the store is NOT re-created on
|
||||||
// every render mid-stream (see `chatStoreId` above).
|
// every render mid-stream (see `chatStoreId` above).
|
||||||
@@ -365,7 +403,14 @@ export default function ChatThread({
|
|||||||
return;
|
return;
|
||||||
lastForwardedChatIdRef.current = serverChatId;
|
lastForwardedChatIdRef.current = serverChatId;
|
||||||
onServerChatId(serverChatId);
|
onServerChatId(serverChatId);
|
||||||
}, [messages, onServerChatId]);
|
// #234 F5: if Stop was pressed before the id was known, the authoritative
|
||||||
|
// server stop was deferred to this adoption point — fire it now with the
|
||||||
|
// just-adopted id. One-shot (read-and-clear) so it can't fire twice.
|
||||||
|
if (stopPendingRef.current) {
|
||||||
|
stopPendingRef.current = false;
|
||||||
|
onServerStop?.(serverChatId);
|
||||||
|
}
|
||||||
|
}, [messages, onServerChatId, onServerStop]);
|
||||||
|
|
||||||
// Live "turn was interrupted" marker for the CURRENT session. The red error
|
// Live "turn was interrupted" marker for the CURRENT session. The red error
|
||||||
// banner (driven by `error`) covers the error case; this covers an aborted
|
// banner (driven by `error`) covers the error case; this covers an aborted
|
||||||
@@ -378,6 +423,27 @@ export default function ChatThread({
|
|||||||
|
|
||||||
const isStreaming = status === "submitted" || status === "streaming";
|
const isStreaming = status === "submitted" || status === "streaming";
|
||||||
|
|
||||||
|
// #184: report our live streaming status up so the parent stops polling the run
|
||||||
|
// while WE are the streamer (the SSE owns the view) and resumes once we go idle.
|
||||||
|
// Effect (not render) so it never updates parent state during our own render;
|
||||||
|
// fires on mount with `false`, which also re-syncs the parent after a chat
|
||||||
|
// switch remounts this thread (a fresh mount is idle until the user sends).
|
||||||
|
useEffect(() => {
|
||||||
|
onStreamingChange?.(isStreaming);
|
||||||
|
}, [isStreaming, onStreamingChange]);
|
||||||
|
|
||||||
|
// #184 passive-observer merge: when the parent feeds a polled run message (we
|
||||||
|
// reopened a chat whose run is still going and did NOT start it here), merge it
|
||||||
|
// into the live list so new steps/tool-calls appear as they are persisted. Hard-
|
||||||
|
// gated by `!isStreaming`: if THIS tab is actually the streamer, the local SSE
|
||||||
|
// owns the view and a stale observedRow must never overwrite it. `observedRow`
|
||||||
|
// is a stable per-poll object, so this runs once per poll, not per render.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isStreaming || !observedRow) return;
|
||||||
|
const observed = rowToUiMessage(observedRow);
|
||||||
|
setMessages((prev) => mergeObservedMessage(prev, observed));
|
||||||
|
}, [observedRow, isStreaming, setMessages]);
|
||||||
|
|
||||||
// "Send now" on a queued message: interrupt the current turn and immediately
|
// "Send now" on a queued message: interrupt the current turn and immediately
|
||||||
// send THIS message, keeping the agent's partial output. Other queued messages
|
// send THIS message, keeping the agent's partial output. Other queued messages
|
||||||
// stay queued and flush normally after the new turn. Reuses the existing
|
// stay queued and flush normally after the new turn. Reuses the existing
|
||||||
@@ -409,6 +475,40 @@ export default function ChatThread({
|
|||||||
[setQueue, stop],
|
[setQueue, stop],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Stop the current turn. ALWAYS abort the local SSE (`stop()`) so the composer
|
||||||
|
// returns to idle immediately. In AUTONOMOUS mode the turn is a DETACHED run:
|
||||||
|
// aborting the local SSE is only a client disconnect, which the server ignores,
|
||||||
|
// so the run would keep executing — we ADDITIONALLY request the authoritative
|
||||||
|
// server-side stop (the parent owns that call + the "stopping" latch that keeps
|
||||||
|
// observer-polling from re-streaming the stopping run's output). The chat id is
|
||||||
|
// read live from chatIdRef (adopted early at the stream's `start` chunk); if it
|
||||||
|
// is not known yet — a brand-new chat in the first moment of its first turn —
|
||||||
|
// only the local abort happens (there is no server-side run handle to stop yet).
|
||||||
|
const handleStop = useCallback(() => {
|
||||||
|
stop();
|
||||||
|
if (!autonomousRunsEnabled) return;
|
||||||
|
if (chatIdRef.current) {
|
||||||
|
onServerStop?.(chatIdRef.current);
|
||||||
|
} else {
|
||||||
|
// #234 F5: no chat id yet (brand-new chat in the first moment of its first
|
||||||
|
// turn, before the `start` chunk adopted the id). Latch the stop as pending;
|
||||||
|
// the onServerChatId adoption effect fires the deferred server stop as soon
|
||||||
|
// as the id appears, so the detached run is still authoritatively stopped
|
||||||
|
// instead of left running by a silent local-only abort.
|
||||||
|
//
|
||||||
|
// KNOWN LIMITATION (#234 F5 review): `stop()` above has already aborted the
|
||||||
|
// local SSE reader. In the rare sub-window where Stop is pressed while still
|
||||||
|
// `submitted` (request sent, not one chunk read yet), that abort can cancel
|
||||||
|
// the reader BEFORE the `start` chunk is applied to `messages`, so the
|
||||||
|
// adoption effect never runs and this pending stop never fires. The detached
|
||||||
|
// run then keeps going for that turn. This is not a regression (the pre-fix
|
||||||
|
// behavior sent no server stop at all); closing it fully would require
|
||||||
|
// deferring the local abort until adoption, which is riskier and out of scope
|
||||||
|
// for this fix. Documented so a future change can address the abort-ordering.
|
||||||
|
stopPendingRef.current = true;
|
||||||
|
}
|
||||||
|
}, [stop, autonomousRunsEnabled, onServerStop]);
|
||||||
|
|
||||||
// Clear the stopped marker as soon as a new turn begins streaming, and drop any
|
// Clear the stopped marker as soon as a new turn begins streaming, and drop any
|
||||||
// stale "Send now" interrupt flags. On the legit interrupt path both refs are
|
// stale "Send now" interrupt flags. On the legit interrupt path both refs are
|
||||||
// already consumed synchronously (onFinish + prepareSendMessagesRequest) before
|
// already consumed synchronously (onFinish + prepareSendMessagesRequest) before
|
||||||
@@ -420,6 +520,11 @@ export default function ChatThread({
|
|||||||
setStopNotice(null);
|
setStopNotice(null);
|
||||||
flushOnAbortRef.current = false;
|
flushOnAbortRef.current = false;
|
||||||
interruptNextSendRef.current = false;
|
interruptNextSendRef.current = false;
|
||||||
|
// #234 F5: a new turn is starting — drop any pending deferred-stop from a
|
||||||
|
// previous turn that never adopted an id, so it can never fire against this
|
||||||
|
// (or a later) unrelated turn's run. A deferred stop for the CURRENT turn is
|
||||||
|
// set AFTER this effect (on the Stop click), so this does not clobber it.
|
||||||
|
stopPendingRef.current = false;
|
||||||
}
|
}
|
||||||
}, [isStreaming]);
|
}, [isStreaming]);
|
||||||
|
|
||||||
@@ -539,7 +644,7 @@ export default function ChatThread({
|
|||||||
<ChatInput
|
<ChatInput
|
||||||
onSend={(text) => sendMessage({ text })}
|
onSend={(text) => sendMessage({ text })}
|
||||||
onQueue={enqueue}
|
onQueue={enqueue}
|
||||||
onStop={stop}
|
onStop={handleStop}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
useMutation,
|
useMutation,
|
||||||
|
useQueries,
|
||||||
useQuery,
|
useQuery,
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
deleteAiChat,
|
deleteAiChat,
|
||||||
deleteAiRole,
|
deleteAiRole,
|
||||||
getAiChatMessages,
|
getAiChatMessages,
|
||||||
|
getAiChatRun,
|
||||||
getAiChats,
|
getAiChats,
|
||||||
getAiRoleCatalog,
|
getAiRoleCatalog,
|
||||||
getAiRoleCatalogBundle,
|
getAiRoleCatalogBundle,
|
||||||
@@ -24,6 +26,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
IAiChat,
|
IAiChat,
|
||||||
IAiChatMessageRow,
|
IAiChatMessageRow,
|
||||||
|
IAiChatRunResponse,
|
||||||
IAiRole,
|
IAiRole,
|
||||||
IAiRoleCatalog,
|
IAiRoleCatalog,
|
||||||
IAiRoleCatalogBundle,
|
IAiRoleCatalogBundle,
|
||||||
@@ -34,6 +37,7 @@ import {
|
|||||||
IAiRoleUpdateFromCatalogResult,
|
IAiRoleUpdateFromCatalogResult,
|
||||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
|
import { runPollInterval } from "@/features/ai-chat/utils/run-polling.ts";
|
||||||
|
|
||||||
export const AI_CHATS_RQ_KEY = ["ai-chats"];
|
export const AI_CHATS_RQ_KEY = ["ai-chats"];
|
||||||
export const AI_ROLES_RQ_KEY = ["ai-roles"];
|
export const AI_ROLES_RQ_KEY = ["ai-roles"];
|
||||||
@@ -51,16 +55,18 @@ export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
|
|||||||
"ai-chat-messages",
|
"ai-chat-messages",
|
||||||
chatId,
|
chatId,
|
||||||
];
|
];
|
||||||
|
export const AI_CHAT_RUN_RQ_KEY = (chatId: string) => ["ai-chat-run", chatId];
|
||||||
|
|
||||||
/** Paginated list of the current user's chats (auto-loads further pages). */
|
/** Paginated list of the current user's chats (auto-loads further pages). */
|
||||||
export function useAiChatsQuery() {
|
export function useAiChatsQuery() {
|
||||||
const query = useInfiniteQuery({
|
const query = useInfiniteQuery({
|
||||||
queryKey: AI_CHATS_RQ_KEY,
|
queryKey: AI_CHATS_RQ_KEY,
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) => getAiChats({ cursor: pageParam, limit: 50 }),
|
||||||
getAiChats({ cursor: pageParam, limit: 50 }),
|
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
getNextPageParam: (lastPage) =>
|
getNextPageParam: (lastPage) =>
|
||||||
lastPage.meta.hasNextPage ? (lastPage.meta.nextCursor ?? undefined) : undefined,
|
lastPage.meta.hasNextPage
|
||||||
|
? (lastPage.meta.nextCursor ?? undefined)
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = useMemo<IPagination<IAiChat> | undefined>(() => {
|
const data = useMemo<IPagination<IAiChat> | undefined>(() => {
|
||||||
@@ -90,7 +96,9 @@ export function useAiChatMessagesQuery(chatId: string | undefined) {
|
|||||||
getAiChatMessages({ chatId: chatId as string, cursor: pageParam }),
|
getAiChatMessages({ chatId: chatId as string, cursor: pageParam }),
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
getNextPageParam: (lastPage) =>
|
getNextPageParam: (lastPage) =>
|
||||||
lastPage.meta.hasNextPage ? (lastPage.meta.nextCursor ?? undefined) : undefined,
|
lastPage.meta.hasNextPage
|
||||||
|
? (lastPage.meta.nextCursor ?? undefined)
|
||||||
|
: undefined,
|
||||||
enabled: !!chatId,
|
enabled: !!chatId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -131,6 +139,34 @@ export function useAiChatMessagesQuery(chatId: string | undefined) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconnect to a chat's latest agent run and LIVE-FOLLOW it (#184). While the run
|
||||||
|
* is active the query re-polls every {@link runPollInterval} ms (driven off the
|
||||||
|
* fetched `run.status`, the same status-keyed refetchInterval pattern as the
|
||||||
|
* embeddings reindex polling); once the run reaches a terminal status — or there
|
||||||
|
* is no run — the interval returns `false` and polling stops on its own. Polling
|
||||||
|
* is thus naturally bounded by the run terminating; no separate timeout cap.
|
||||||
|
*
|
||||||
|
* `enabled` gates the whole thing: callers pass `false` when the autonomous-runs
|
||||||
|
* feature is off (the endpoint is NOT flag-gated server-side, but with the feature
|
||||||
|
* off the chat has no runs, so polling would only ever return `{ run: null }`) OR
|
||||||
|
* when THIS tab is the one actively streaming the run (the live SSE owns the view,
|
||||||
|
* so we must not also poll/merge). The global `retry: false` means a failed fetch
|
||||||
|
* leaves `data` undefined, so refetchInterval(undefined run) returns false — a
|
||||||
|
* failed fetch can never spin a tight loop.
|
||||||
|
*/
|
||||||
|
export function useAiChatRunQuery(
|
||||||
|
chatId: string | undefined,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
return useQuery<IAiChatRunResponse, Error>({
|
||||||
|
queryKey: AI_CHAT_RUN_RQ_KEY(chatId ?? ""),
|
||||||
|
queryFn: () => getAiChatRun(chatId as string),
|
||||||
|
enabled: !!chatId && enabled,
|
||||||
|
refetchInterval: (query) => runPollInterval(query.state.data?.run),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useRenameAiChatMutation() {
|
export function useRenameAiChatMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -272,6 +308,29 @@ export function useAiRoleCatalogBundleQuery(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eagerly open EVERY listed bundle's content in parallel for one language. The
|
||||||
|
* redesigned catalog shows each bundle's status summary in its COLLAPSED header,
|
||||||
|
* which needs every role's install state up front — so contents can no longer be
|
||||||
|
* lazy-loaded on expand. The catalog is small, so a fan-out of `useQueries` (one
|
||||||
|
* cached read per bundle, sharing the same cache keys as
|
||||||
|
* `useAiRoleCatalogBundleQuery`) is cheap. Gated by `enabled` (modal open + a
|
||||||
|
* resolved language) so nothing fetches while the modal is closed.
|
||||||
|
*/
|
||||||
|
export function useAiRoleCatalogBundlesQueries(
|
||||||
|
bundleIds: string[],
|
||||||
|
language: string,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
return useQueries({
|
||||||
|
queries: bundleIds.map((bundleId) => ({
|
||||||
|
queryKey: AI_ROLE_CATALOG_BUNDLE_RQ_KEY(bundleId, language),
|
||||||
|
queryFn: () => getAiRoleCatalogBundle(bundleId, language),
|
||||||
|
enabled: enabled && !!language,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useImportAiRolesFromCatalogMutation() {
|
export function useImportAiRolesFromCatalogMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -280,11 +339,14 @@ export function useImportAiRolesFromCatalogMutation() {
|
|||||||
mutationFn: (payload) => importAiRolesFromCatalog(payload),
|
mutationFn: (payload) => importAiRolesFromCatalog(payload),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: t("Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}", {
|
message: t(
|
||||||
|
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}",
|
||||||
|
{
|
||||||
created: result.created,
|
created: result.created,
|
||||||
renamed: result.renamed,
|
renamed: result.renamed,
|
||||||
skipped: result.skipped,
|
skipped: result.skipped,
|
||||||
}),
|
},
|
||||||
|
),
|
||||||
});
|
});
|
||||||
// Surface partial failures (e.g. unique-name races) as a red warning.
|
// Surface partial failures (e.g. unique-name races) as a red warning.
|
||||||
if (result.errors.length > 0) {
|
if (result.errors.length > 0) {
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import React from "react";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { IAiChatRunResponse } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
|
||||||
|
// react-i18next is pulled in transitively by ai-chat-query.ts (the mutation hooks
|
||||||
|
// use it); stub it so the module imports cleanly in this hook test.
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@mantine/notifications", () => ({
|
||||||
|
notifications: { show: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the whole service module; only getAiChatRun is exercised here, but the
|
||||||
|
// other named exports must exist so ai-chat-query.ts imports resolve.
|
||||||
|
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||||
|
getAiChatRun: vi.fn(),
|
||||||
|
getAiChatMessages: vi.fn(),
|
||||||
|
getAiChats: vi.fn(),
|
||||||
|
getAiRoleCatalog: vi.fn(),
|
||||||
|
getAiRoleCatalogBundle: vi.fn(),
|
||||||
|
getAiRoles: vi.fn(),
|
||||||
|
importAiRolesFromCatalog: vi.fn(),
|
||||||
|
createAiRole: vi.fn(),
|
||||||
|
deleteAiChat: vi.fn(),
|
||||||
|
deleteAiRole: vi.fn(),
|
||||||
|
renameAiChat: vi.fn(),
|
||||||
|
updateAiRole: vi.fn(),
|
||||||
|
updateAiRoleFromCatalog: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { getAiChatRun } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||||
|
import { useAiChatRunQuery } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||||
|
|
||||||
|
function createWrapper() {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const runningResponse: IAiChatRunResponse = {
|
||||||
|
run: { id: "run-1", chatId: "c1", status: "running" },
|
||||||
|
message: {
|
||||||
|
id: "a1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "working...",
|
||||||
|
createdAt: "2026-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("useAiChatRunQuery — enable gating", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches the run when enabled (passive observer, feature on)", async () => {
|
||||||
|
vi.mocked(getAiChatRun).mockResolvedValue(runningResponse);
|
||||||
|
const { result } = renderHook(() => useAiChatRunQuery("c1", true), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
expect(getAiChatRun).toHaveBeenCalledWith("c1");
|
||||||
|
expect(result.current.data?.run?.status).toBe("running");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT fetch when disabled (this tab is the streamer / feature off)", async () => {
|
||||||
|
vi.mocked(getAiChatRun).mockResolvedValue(runningResponse);
|
||||||
|
renderHook(() => useAiChatRunQuery("c1", false), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
// Give any errant fetch a chance to fire, then assert none did.
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
expect(getAiChatRun).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT fetch when there is no chat id", async () => {
|
||||||
|
vi.mocked(getAiChatRun).mockResolvedValue(runningResponse);
|
||||||
|
renderHook(() => useAiChatRunQuery(undefined, true), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
expect(getAiChatRun).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -77,7 +77,14 @@ describe("useImportAiRolesFromCatalogMutation — success notifications", () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("errors:[] -> only the summary notification (counts interpolated)", async () => {
|
it("errors:[] -> only the summary notification (counts interpolated)", async () => {
|
||||||
await runMutation({ created: 3, renamed: 1, skipped: 2, errors: [] });
|
await runMutation({
|
||||||
|
created: 3,
|
||||||
|
renamed: 1,
|
||||||
|
skipped: 2,
|
||||||
|
errors: [],
|
||||||
|
createdRoles: [],
|
||||||
|
skippedRoles: [],
|
||||||
|
});
|
||||||
expect(notificationsShowMock).toHaveBeenCalledTimes(1);
|
expect(notificationsShowMock).toHaveBeenCalledTimes(1);
|
||||||
expect(notificationsShowMock).toHaveBeenCalledWith({
|
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||||
message: "Imported 3, renamed 1, skipped 2",
|
message: "Imported 3, renamed 1, skipped 2",
|
||||||
@@ -93,6 +100,8 @@ describe("useImportAiRolesFromCatalogMutation — success notifications", () =>
|
|||||||
{ slug: "a", message: "name taken" },
|
{ slug: "a", message: "name taken" },
|
||||||
{ slug: "b", message: "name taken" },
|
{ slug: "b", message: "name taken" },
|
||||||
],
|
],
|
||||||
|
createdRoles: [{ slug: "ok", name: "Ok" }],
|
||||||
|
skippedRoles: [],
|
||||||
});
|
});
|
||||||
expect(notificationsShowMock).toHaveBeenCalledTimes(2);
|
expect(notificationsShowMock).toHaveBeenCalledTimes(2);
|
||||||
expect(notificationsShowMock).toHaveBeenNthCalledWith(1, {
|
expect(notificationsShowMock).toHaveBeenNthCalledWith(1, {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
IAiChatListParams,
|
IAiChatListParams,
|
||||||
IAiChatMessageRow,
|
IAiChatMessageRow,
|
||||||
IAiChatMessagesParams,
|
IAiChatMessagesParams,
|
||||||
|
IAiChatRunResponse,
|
||||||
IAiRole,
|
IAiRole,
|
||||||
IAiRoleCatalog,
|
IAiRoleCatalog,
|
||||||
IAiRoleCatalogBundle,
|
IAiRoleCatalogBundle,
|
||||||
@@ -42,6 +43,38 @@ export async function getAiChatMessages(
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconnect to the latest agent run of a chat (#184). Returns the run's
|
||||||
|
* persisted lifecycle state and the assistant message it materializes (the
|
||||||
|
* partial output while the run is in-flight, the final output once it finished).
|
||||||
|
* The DB is the source of truth, so this works for an in-flight run (the browser
|
||||||
|
* dropped, the run kept going) and a finished one alike; `{ run: null }` when the
|
||||||
|
* chat has never had a run. Owner-gated server-side (the requesting user must own
|
||||||
|
* the chat); it is NOT flag-gated — when the feature is off the chat simply has no
|
||||||
|
* runs, so the endpoint returns `{ run: null }`.
|
||||||
|
*/
|
||||||
|
export async function getAiChatRun(
|
||||||
|
chatId: string,
|
||||||
|
): Promise<IAiChatRunResponse> {
|
||||||
|
const req = await api.post<IAiChatRunResponse>("/ai-chat/run", { chatId });
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicitly STOP the active agent run of a chat (#184). This is the ONLY thing
|
||||||
|
* that ends a DETACHED run — a mere browser disconnect (aborting the local SSE)
|
||||||
|
* is deliberately ignored server-side, so the client must call this to actually
|
||||||
|
* stop an autonomous run. Targeted by `chatId` (the server resolves whatever run
|
||||||
|
* is active on it); owner-gated server-side. Returns `{ stopped }` — false when
|
||||||
|
* there was nothing active to stop.
|
||||||
|
*/
|
||||||
|
export async function stopRun(
|
||||||
|
chatId: string,
|
||||||
|
): Promise<{ stopped: boolean }> {
|
||||||
|
const req = await api.post<{ stopped: boolean }>("/ai-chat/stop", { chatId });
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the chat bound to a document (the current user's most-recent chat
|
* Resolve the chat bound to a document (the current user's most-recent chat
|
||||||
* created on that page), or null when there is none. Drives auto-open-on-page.
|
* created on that page), or null when there is none. Drives auto-open-on-page.
|
||||||
|
|||||||
@@ -108,12 +108,25 @@ export interface IAiRoleImportPayload {
|
|||||||
conflict: "skip" | "rename";
|
conflict: "skip" | "rename";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Import result counts (mirrors `importFromCatalog()`). */
|
/**
|
||||||
|
* Import result (mirrors `importFromCatalog()`). The counters (`created`,
|
||||||
|
* `skipped`, `renamed`) drive the summary notification; the per-role lists
|
||||||
|
* (`createdRoles`, `skippedRoles`) drive the redesigned catalog modal's inline
|
||||||
|
* result plaque — which roles were installed (and any rename) and which were
|
||||||
|
* skipped and why (so the plaque can name the conflicting role and offer
|
||||||
|
* "Rename & install").
|
||||||
|
*/
|
||||||
export interface IAiRoleImportResult {
|
export interface IAiRoleImportResult {
|
||||||
created: number;
|
created: number;
|
||||||
skipped: number;
|
skipped: number;
|
||||||
renamed: number;
|
renamed: number;
|
||||||
errors: { slug: string; message: string }[];
|
errors: { slug: string; message: string }[];
|
||||||
|
createdRoles: { slug: string; name: string; renamedTo?: string }[];
|
||||||
|
skippedRoles: {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
reason: "name-conflict" | "already-installed";
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -200,6 +213,38 @@ export interface IAiChatMessageRow {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A persisted agent-run row (#184), mirroring the `ai_chat_runs` fields the
|
||||||
|
* client reads from `POST /ai-chat/run`. Only `status` is load-bearing for the
|
||||||
|
* reconnect-and-live-update UX (it drives the poll cadence); the rest are carried
|
||||||
|
* for display/diagnostics. The DB is the source of truth, so this resolves for an
|
||||||
|
* in-flight run (the browser dropped, the run kept going) and a finished one.
|
||||||
|
*/
|
||||||
|
export interface IAiChatRun {
|
||||||
|
id: string;
|
||||||
|
chatId: string;
|
||||||
|
// 'pending' | 'running' | 'succeeded' | 'failed' | 'aborted'. The first two are
|
||||||
|
// ACTIVE (keep polling); the rest are TERMINAL (stop polling).
|
||||||
|
status: "pending" | "running" | "succeeded" | "failed" | "aborted" | string;
|
||||||
|
error?: string | null;
|
||||||
|
stepCount?: number;
|
||||||
|
assistantMessageId?: string | null;
|
||||||
|
startedAt?: string | null;
|
||||||
|
finishedAt?: string | null;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response of `POST /ai-chat/run` (#184): the latest run of a chat and the
|
||||||
|
* assistant message it materializes (the partial/final output, projected from the
|
||||||
|
* persisted rows). Both are `null` when the chat has never had a run.
|
||||||
|
*/
|
||||||
|
export interface IAiChatRunResponse {
|
||||||
|
run: IAiChatRun | null;
|
||||||
|
message: IAiChatMessageRow | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IAiChatListParams extends QueryParams {}
|
export interface IAiChatListParams extends QueryParams {}
|
||||||
|
|
||||||
export interface IAiChatMessagesParams {
|
export interface IAiChatMessagesParams {
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
bundleCounts,
|
||||||
|
bundlePhase,
|
||||||
|
installedLangForRole,
|
||||||
|
mapBundleRolesToView,
|
||||||
|
mapCatalogRoleToView,
|
||||||
|
nameConflictSlugs,
|
||||||
|
partialOffersRename,
|
||||||
|
type CatalogViewRole,
|
||||||
|
} from "./catalog-bundle-model.ts";
|
||||||
|
import type {
|
||||||
|
IAiRole,
|
||||||
|
IAiRoleCatalogRole,
|
||||||
|
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
|
||||||
|
function installedRole(
|
||||||
|
source: { slug: string; language: string; version: number },
|
||||||
|
overrides: Partial<IAiRole> = {},
|
||||||
|
): IAiRole {
|
||||||
|
return {
|
||||||
|
id: `role-${source.slug}-${source.language}`,
|
||||||
|
name: source.slug,
|
||||||
|
emoji: null,
|
||||||
|
description: null,
|
||||||
|
enabled: true,
|
||||||
|
autoStart: true,
|
||||||
|
launchMessage: null,
|
||||||
|
source,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function catalogRole(
|
||||||
|
overrides: Partial<IAiRoleCatalogRole> = {},
|
||||||
|
): IAiRoleCatalogRole {
|
||||||
|
return {
|
||||||
|
slug: "writer",
|
||||||
|
emoji: "✍️",
|
||||||
|
name: "Writer",
|
||||||
|
description: "Drafts copy.",
|
||||||
|
instructions: "be a writer",
|
||||||
|
autoStart: true,
|
||||||
|
launchMessage: null,
|
||||||
|
version: 3,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a minimal view role for bundlePhase tests.
|
||||||
|
function viewRole(status: CatalogViewRole["status"]): CatalogViewRole {
|
||||||
|
return { slug: `s-${status}`, name: status, description: "", version: 1, status };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("bundlePhase", () => {
|
||||||
|
it("empty bundle -> empty", () => {
|
||||||
|
expect(bundlePhase([])).toBe("empty");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("all importable, none installed -> allNew", () => {
|
||||||
|
expect(bundlePhase([viewRole("import"), viewRole("import")])).toBe(
|
||||||
|
"allNew",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nothing to import or update -> allInstalled", () => {
|
||||||
|
expect(bundlePhase([viewRole("installed"), viewRole("installed")])).toBe(
|
||||||
|
"allInstalled",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates present, nothing to import -> updates", () => {
|
||||||
|
expect(bundlePhase([viewRole("update"), viewRole("installed")])).toBe(
|
||||||
|
"updates",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("import + installed (no updates) -> mixed", () => {
|
||||||
|
expect(bundlePhase([viewRole("import"), viewRole("installed")])).toBe(
|
||||||
|
"mixed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("import + update -> mixed", () => {
|
||||||
|
expect(bundlePhase([viewRole("import"), viewRole("update")])).toBe("mixed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a skipped role with nothing installed -> mixed (NOT allInstalled)", () => {
|
||||||
|
// F1: a bundle whose only non-installed role was skipped has 0 installed for
|
||||||
|
// it, so the collapsed 'All installed · up to date' header would contradict
|
||||||
|
// the open 'Installed 0 · 1 skipped' plaque. It must be mixed until resolved.
|
||||||
|
expect(bundlePhase([viewRole("skipped")])).toBe("mixed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("installed + a skipped role -> mixed (partial success is not allInstalled)", () => {
|
||||||
|
expect(bundlePhase([viewRole("installed"), viewRole("skipped")])).toBe(
|
||||||
|
"mixed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bundleCounts", () => {
|
||||||
|
it("tallies each status once", () => {
|
||||||
|
expect(
|
||||||
|
bundleCounts([
|
||||||
|
viewRole("import"),
|
||||||
|
viewRole("import"),
|
||||||
|
viewRole("installed"),
|
||||||
|
viewRole("update"),
|
||||||
|
viewRole("skipped"),
|
||||||
|
]),
|
||||||
|
).toEqual({ importable: 2, installed: 1, update: 1, skipped: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("nameConflictSlugs / partialOffersRename (reason -> action)", () => {
|
||||||
|
it("only name-conflict skips become the transient overlay / offer rename", () => {
|
||||||
|
const skipped = [
|
||||||
|
{ slug: "writer", name: "Writer", reason: "name-conflict" as const },
|
||||||
|
{ slug: "editor", name: "Editor", reason: "already-installed" as const },
|
||||||
|
];
|
||||||
|
expect(nameConflictSlugs(skipped)).toEqual(["writer"]);
|
||||||
|
expect(partialOffersRename(skipped)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an already-installed-only skip is informational: no overlay, no rename", () => {
|
||||||
|
const skipped = [
|
||||||
|
{ slug: "editor", name: "Editor", reason: "already-installed" as const },
|
||||||
|
];
|
||||||
|
expect(nameConflictSlugs(skipped)).toEqual([]);
|
||||||
|
expect(partialOffersRename(skipped)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("installedLangForRole", () => {
|
||||||
|
it("returns the other language when the same slug is installed elsewhere", () => {
|
||||||
|
const roles = [installedRole({ slug: "writer", language: "ru", version: 2 })];
|
||||||
|
expect(installedLangForRole("writer", roles, "en")).toBe("ru");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when the same slug is installed in the SAME language", () => {
|
||||||
|
const roles = [installedRole({ slug: "writer", language: "en", version: 2 })];
|
||||||
|
expect(installedLangForRole("writer", roles, "en")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when no install of the slug exists", () => {
|
||||||
|
expect(installedLangForRole("writer", [], "en")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores manually-created roles (no source)", () => {
|
||||||
|
const roles = [
|
||||||
|
installedRole({ slug: "writer", language: "ru", version: 2 }, {
|
||||||
|
source: null,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
expect(installedLangForRole("writer", roles, "en")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapCatalogRoleToView", () => {
|
||||||
|
it("no install -> import status, catalog version, emoji preserved", () => {
|
||||||
|
const view = mapCatalogRoleToView(catalogRole(), [], "en");
|
||||||
|
expect(view).toMatchObject({
|
||||||
|
slug: "writer",
|
||||||
|
emoji: "✍️",
|
||||||
|
name: "Writer",
|
||||||
|
description: "Drafts copy.",
|
||||||
|
status: "import",
|
||||||
|
version: 3,
|
||||||
|
});
|
||||||
|
expect(view.installedRoleId).toBeUndefined();
|
||||||
|
expect(view.installedLang).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("import with the slug installed in another language -> installedLang set", () => {
|
||||||
|
const roles = [installedRole({ slug: "writer", language: "ru", version: 9 })];
|
||||||
|
const view = mapCatalogRoleToView(catalogRole(), roles, "en");
|
||||||
|
expect(view.status).toBe("import");
|
||||||
|
expect(view.installedLang).toBe("ru");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("installed (up to date) -> installed status, catalog version, installedRoleId", () => {
|
||||||
|
const installed = installedRole({
|
||||||
|
slug: "writer",
|
||||||
|
language: "en",
|
||||||
|
version: 3,
|
||||||
|
});
|
||||||
|
const view = mapCatalogRoleToView(catalogRole(), [installed], "en");
|
||||||
|
expect(view).toMatchObject({
|
||||||
|
status: "installed",
|
||||||
|
version: 3,
|
||||||
|
installedRoleId: installed.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("update -> version=from, newVersion=to, installedRoleId", () => {
|
||||||
|
const installed = installedRole({
|
||||||
|
slug: "writer",
|
||||||
|
language: "en",
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
const view = mapCatalogRoleToView(catalogRole(), [installed], "en");
|
||||||
|
expect(view).toMatchObject({
|
||||||
|
status: "update",
|
||||||
|
version: 1,
|
||||||
|
newVersion: 3,
|
||||||
|
installedRoleId: installed.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("missing emoji -> emoji undefined; null description -> empty string", () => {
|
||||||
|
const view = mapCatalogRoleToView(
|
||||||
|
catalogRole({ emoji: null, description: null }),
|
||||||
|
[],
|
||||||
|
"en",
|
||||||
|
);
|
||||||
|
expect(view.emoji).toBeUndefined();
|
||||||
|
expect(view.description).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapBundleRolesToView", () => {
|
||||||
|
it("maps a bundle's roles preserving order", () => {
|
||||||
|
const roles = [
|
||||||
|
catalogRole({ slug: "a", name: "A", version: 1 }),
|
||||||
|
catalogRole({ slug: "b", name: "B", version: 1 }),
|
||||||
|
];
|
||||||
|
const installed = [installedRole({ slug: "a", language: "en", version: 1 })];
|
||||||
|
const view = mapBundleRolesToView(roles, installed, "en");
|
||||||
|
expect(view.map((r) => r.slug)).toEqual(["a", "b"]);
|
||||||
|
expect(view[0].status).toBe("installed");
|
||||||
|
expect(view[1].status).toBe("import");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import type {
|
||||||
|
IAiRole,
|
||||||
|
IAiRoleCatalogRole,
|
||||||
|
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
import { catalogRoleInstallState } from "@/features/ai-chat/utils/catalog-role-install-state.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The redesigned catalog modal renders bundles as cards with a summary status
|
||||||
|
* (readable without expanding) and a single primary action. The per-role and
|
||||||
|
* per-bundle view model that drives that UI is derived here as PURE functions so
|
||||||
|
* the mapping, the "installed in another language" hint, and the bundle-phase
|
||||||
|
* computation are unit-testable without mounting the component (mirrors the
|
||||||
|
* `catalogRoleInstallState` precedent).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A role's status in the catalog view model.
|
||||||
|
* - `import` — not installed in the current content language.
|
||||||
|
* - `installed` — installed and up to date.
|
||||||
|
* - `update` — installed, but the catalog ships a newer version.
|
||||||
|
* - `skipped` — TRANSIENT client-only status set after a conflicted import
|
||||||
|
* (a name collision under `conflict:'skip'`); never from the
|
||||||
|
* backend.
|
||||||
|
*/
|
||||||
|
export type RoleStatus = "import" | "installed" | "update" | "skipped";
|
||||||
|
|
||||||
|
/** A catalog role mapped into the modal's view model. */
|
||||||
|
export interface CatalogViewRole {
|
||||||
|
// Slug is the stable identity within a bundle; used as the row key and as the
|
||||||
|
// `slugs[]` payload for import.
|
||||||
|
slug: string;
|
||||||
|
// Optional in the catalog — the row reserves space and renders nothing when
|
||||||
|
// absent.
|
||||||
|
emoji?: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
// For `installed`/`import`: the catalog version. For `update`: the installed
|
||||||
|
// (from) version, with `newVersion` holding the catalog (to) version.
|
||||||
|
version: number;
|
||||||
|
newVersion?: number;
|
||||||
|
status: RoleStatus;
|
||||||
|
// The language a same-slug role is installed under, when it differs from the
|
||||||
|
// current content language (drives the Р5 hint). Only set for `import` roles.
|
||||||
|
installedLang?: string;
|
||||||
|
// The workspace role id, present for `installed`/`update` — needed to call the
|
||||||
|
// update-from-catalog mutation.
|
||||||
|
installedRoleId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The summary phase of a bundle, derived from its roles' statuses. Determines
|
||||||
|
* the collapsed-header summary and the bundle's single primary action.
|
||||||
|
* - `empty` — the bundle has no roles.
|
||||||
|
* - `allNew` — everything is importable, nothing installed.
|
||||||
|
* - `allInstalled` — everything installed & up to date; nothing else pending.
|
||||||
|
* - `updates` — updates available and nothing left to import.
|
||||||
|
* - `mixed` — any other combination.
|
||||||
|
*/
|
||||||
|
export type BundlePhase =
|
||||||
|
| "empty"
|
||||||
|
| "allNew"
|
||||||
|
| "allInstalled"
|
||||||
|
| "updates"
|
||||||
|
| "mixed";
|
||||||
|
|
||||||
|
/** Per-status tallies for a bundle's roles (the single source of truth). */
|
||||||
|
export interface BundleCounts {
|
||||||
|
importable: number;
|
||||||
|
installed: number;
|
||||||
|
update: number;
|
||||||
|
skipped: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count a bundle's roles by status ONCE. Both `bundlePhase` and the panel derive
|
||||||
|
* from this, so the tally logic lives in exactly one place (no rescans / drift).
|
||||||
|
*/
|
||||||
|
export function bundleCounts(roles: CatalogViewRole[]): BundleCounts {
|
||||||
|
const counts: BundleCounts = {
|
||||||
|
importable: 0,
|
||||||
|
installed: 0,
|
||||||
|
update: 0,
|
||||||
|
skipped: 0,
|
||||||
|
};
|
||||||
|
for (const r of roles) {
|
||||||
|
if (r.status === "import") counts.importable += 1;
|
||||||
|
else if (r.status === "installed") counts.installed += 1;
|
||||||
|
else if (r.status === "update") counts.update += 1;
|
||||||
|
else if (r.status === "skipped") counts.skipped += 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bundlePhase(roles: CatalogViewRole[]): BundlePhase {
|
||||||
|
if (roles.length === 0) return "empty";
|
||||||
|
const { importable, installed, update, skipped } = bundleCounts(roles);
|
||||||
|
// A `skipped` role is a pending post-import conflict (0 installed for it), so a
|
||||||
|
// bundle that has ANY skipped role is NOT "all installed & up to date" — that
|
||||||
|
// would make the collapsed green "up to date" header contradict the open
|
||||||
|
// panel's "Installed 0 · 1 skipped" plaque. It is `mixed` until resolved.
|
||||||
|
if (importable === 0 && update === 0 && skipped === 0) return "allInstalled";
|
||||||
|
if (update > 0 && importable === 0 && skipped === 0) return "updates";
|
||||||
|
if (importable > 0 && installed === 0 && update === 0 && skipped === 0)
|
||||||
|
return "allNew";
|
||||||
|
return "mixed";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subset of a skip result that should be shown as a TRANSIENT `skipped`
|
||||||
|
* overlay in the bundle (so the row offers a re-import path). Only NAME-CONFLICT
|
||||||
|
* skips qualify: an `already-installed` skip (a concurrent-import race) has
|
||||||
|
* nothing to act on — re-importing the same slug would just skip again — so it
|
||||||
|
* must NOT be overlaid (else the row shows a misleading "Rename & install" that
|
||||||
|
* self-heals into a false "installed"). Pure so both reason branches are tested.
|
||||||
|
*/
|
||||||
|
export function nameConflictSlugs(
|
||||||
|
skipped: { slug: string; reason: "name-conflict" | "already-installed" }[],
|
||||||
|
): string[] {
|
||||||
|
return skipped
|
||||||
|
.filter((s) => s.reason === "name-conflict")
|
||||||
|
.map((s) => s.slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a partial-import result should offer the "Rename & install" action:
|
||||||
|
* only when at least one skip is a name conflict (renameable). An
|
||||||
|
* `already-installed`-only partial is informational.
|
||||||
|
*/
|
||||||
|
export function partialOffersRename(
|
||||||
|
skipped: { reason: "name-conflict" | "already-installed" }[],
|
||||||
|
): boolean {
|
||||||
|
return skipped.some((s) => s.reason === "name-conflict");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For a role NOT installed in the current `language`, find a workspace role with
|
||||||
|
* the same catalog `slug` installed under a DIFFERENT language, and return that
|
||||||
|
* language. Drives the "installed in another language" hint (Р5): a different
|
||||||
|
* language of the same slug is a separate install and appears as `import`.
|
||||||
|
*/
|
||||||
|
export function installedLangForRole(
|
||||||
|
slug: string,
|
||||||
|
workspaceRoles: IAiRole[],
|
||||||
|
language: string,
|
||||||
|
): string | undefined {
|
||||||
|
const other = workspaceRoles.find(
|
||||||
|
(r) =>
|
||||||
|
r.source?.slug === slug &&
|
||||||
|
!!r.source?.language &&
|
||||||
|
r.source.language !== language,
|
||||||
|
);
|
||||||
|
return other?.source?.language;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map one catalog role to the view model, computing its install status against
|
||||||
|
* the workspace roles (via `catalogRoleInstallState`) and, for importable roles,
|
||||||
|
* the other-language hint.
|
||||||
|
*/
|
||||||
|
export function mapCatalogRoleToView(
|
||||||
|
role: IAiRoleCatalogRole,
|
||||||
|
workspaceRoles: IAiRole[],
|
||||||
|
language: string,
|
||||||
|
): CatalogViewRole {
|
||||||
|
const state = catalogRoleInstallState(role, workspaceRoles, language);
|
||||||
|
const base = {
|
||||||
|
slug: role.slug,
|
||||||
|
emoji: role.emoji ?? undefined,
|
||||||
|
name: role.name,
|
||||||
|
description: role.description ?? "",
|
||||||
|
};
|
||||||
|
if (state.state === "update") {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
status: "update",
|
||||||
|
version: state.fromVersion,
|
||||||
|
newVersion: state.toVersion,
|
||||||
|
installedRoleId: state.installed.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (state.state === "installed") {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
status: "installed",
|
||||||
|
version: role.version,
|
||||||
|
installedRoleId: state.installed.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
status: "import",
|
||||||
|
version: role.version,
|
||||||
|
installedLang: installedLangForRole(role.slug, workspaceRoles, language),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a whole bundle's catalog roles to the view model, preserving order.
|
||||||
|
*/
|
||||||
|
export function mapBundleRolesToView(
|
||||||
|
roles: IAiRoleCatalogRole[],
|
||||||
|
workspaceRoles: IAiRole[],
|
||||||
|
language: string,
|
||||||
|
): CatalogViewRole[] {
|
||||||
|
return roles.map((r) => mapCatalogRoleToView(r, workspaceRoles, language));
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
import type { IAiChatRun } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
import {
|
||||||
|
RUN_POLL_INTERVAL_MS,
|
||||||
|
isRunActive,
|
||||||
|
runPollInterval,
|
||||||
|
shouldObserveRun,
|
||||||
|
shouldClearStoppingLatch,
|
||||||
|
shouldClearLatchOnQueryError,
|
||||||
|
mergeObservedMessage,
|
||||||
|
} from "./run-polling.ts";
|
||||||
|
|
||||||
|
function makeRun(status: string): IAiChatRun {
|
||||||
|
return { id: "run-1", chatId: "c1", status };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMsg(id: string, text: string): UIMessage {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
role: "assistant",
|
||||||
|
parts: [{ type: "text", text }],
|
||||||
|
} as UIMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("isRunActive", () => {
|
||||||
|
it("treats pending and running as active", () => {
|
||||||
|
expect(isRunActive(makeRun("pending"))).toBe(true);
|
||||||
|
expect(isRunActive(makeRun("running"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats terminal / unknown / nullish as not active", () => {
|
||||||
|
expect(isRunActive(makeRun("succeeded"))).toBe(false);
|
||||||
|
expect(isRunActive(makeRun("failed"))).toBe(false);
|
||||||
|
expect(isRunActive(makeRun("aborted"))).toBe(false);
|
||||||
|
expect(isRunActive(makeRun("weird-future-status"))).toBe(false);
|
||||||
|
expect(isRunActive(null)).toBe(false);
|
||||||
|
expect(isRunActive(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runPollInterval (the refetchInterval helper)", () => {
|
||||||
|
it("returns 2000ms while the run is pending/running", () => {
|
||||||
|
expect(runPollInterval(makeRun("pending"))).toBe(RUN_POLL_INTERVAL_MS);
|
||||||
|
expect(runPollInterval(makeRun("running"))).toBe(RUN_POLL_INTERVAL_MS);
|
||||||
|
expect(RUN_POLL_INTERVAL_MS).toBe(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false (stop polling) once the run is terminal", () => {
|
||||||
|
expect(runPollInterval(makeRun("succeeded"))).toBe(false);
|
||||||
|
expect(runPollInterval(makeRun("failed"))).toBe(false);
|
||||||
|
expect(runPollInterval(makeRun("aborted"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false (no polling) when there is no run", () => {
|
||||||
|
expect(runPollInterval(null)).toBe(false);
|
||||||
|
expect(runPollInterval(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldObserveRun (observer-vs-streamer decision)", () => {
|
||||||
|
it("observes an active run when this tab is NOT the local streamer", () => {
|
||||||
|
expect(shouldObserveRun(makeRun("running"), false)).toBe(true);
|
||||||
|
expect(shouldObserveRun(makeRun("pending"), false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("observes a terminal run too (so the final output shows on reopen)", () => {
|
||||||
|
expect(shouldObserveRun(makeRun("succeeded"), false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT observe when this tab IS the streamer (no double-render)", () => {
|
||||||
|
expect(shouldObserveRun(makeRun("running"), true)).toBe(false);
|
||||||
|
expect(shouldObserveRun(makeRun("succeeded"), true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT observe when there is no run", () => {
|
||||||
|
expect(shouldObserveRun(null, false)).toBe(false);
|
||||||
|
expect(shouldObserveRun(undefined, false)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldClearStoppingLatch (#234 latch-release decision)", () => {
|
||||||
|
// The one case the latch SHOULD clear: we requested a stop, we are the passive
|
||||||
|
// observer (not streaming), and the CURRENT run is terminal.
|
||||||
|
it("clears only when stopping, observing, and the run is terminal", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: true,
|
||||||
|
run: makeRun("aborted"),
|
||||||
|
isLocalStreaming: false,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: true,
|
||||||
|
run: makeRun("succeeded"),
|
||||||
|
isLocalStreaming: false,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: true,
|
||||||
|
run: makeRun("failed"),
|
||||||
|
isLocalStreaming: false,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Round-3 regression: clearing while THIS tab is still the local streamer would
|
||||||
|
// re-open the flash for the current turn the moment we switch to observer role.
|
||||||
|
// A predicate lacking the streaming gate would (wrongly) return true here.
|
||||||
|
it("does NOT clear while this tab is the local streamer", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: true,
|
||||||
|
run: makeRun("aborted"),
|
||||||
|
isLocalStreaming: true,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: true,
|
||||||
|
run: makeRun("succeeded"),
|
||||||
|
isLocalStreaming: true,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The detached run keeps growing after a local abort — while it is still
|
||||||
|
// active the latch MUST hold so the observer merge stays suppressed.
|
||||||
|
it("does NOT clear while the run is still active", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: true,
|
||||||
|
run: makeRun("running"),
|
||||||
|
isLocalStreaming: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: true,
|
||||||
|
run: makeRun("pending"),
|
||||||
|
isLocalStreaming: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #234 F4: on Stop the stale PREVIOUS-turn run is removed from the cache, so the
|
||||||
|
// observed `run` is null until the current turn's run is fetched fresh. A null
|
||||||
|
// run HOLDS the latch — it can never clear against the just-removed stale run,
|
||||||
|
// only against the current turn's own terminal run once observed.
|
||||||
|
it("does NOT clear against a removed/absent run (F4 stale-run guard)", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: true,
|
||||||
|
run: null,
|
||||||
|
isLocalStreaming: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: true,
|
||||||
|
run: undefined,
|
||||||
|
isLocalStreaming: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT clear when no stop was requested", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: false,
|
||||||
|
run: makeRun("aborted"),
|
||||||
|
isLocalStreaming: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldClearLatchOnQueryError (#234 F7 error-safety-net decision)", () => {
|
||||||
|
// This guards the REAL anti-flash decision the component's run-query-error
|
||||||
|
// safety-net effect uses (ai-chat-window.tsx wires the effect to THIS helper,
|
||||||
|
// not a copy — so the test is non-vacuous vs the live code).
|
||||||
|
|
||||||
|
// (b) The F7 hole: a TRANSIENT run-query error while `run` is STILL ACTIVE must
|
||||||
|
// NOT clear the latch. TanStack Query v5 retains `data` on error, so
|
||||||
|
// runQueryFailed can be true while the held run is still pending/running.
|
||||||
|
// Against the PRE-F7 condition (without `!isRunActive(run)`) this would return
|
||||||
|
// true — so this assertion fails on the buggy code (non-vacuous).
|
||||||
|
it("does NOT clear on a transient error while the run is still ACTIVE (F7)", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearLatchOnQueryError({
|
||||||
|
stoppingRun: true,
|
||||||
|
isLocalStreaming: false,
|
||||||
|
runQueryFailed: true,
|
||||||
|
run: makeRun("running"),
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldClearLatchOnQueryError({
|
||||||
|
stoppingRun: true,
|
||||||
|
isLocalStreaming: false,
|
||||||
|
runQueryFailed: true,
|
||||||
|
run: makeRun("pending"),
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// (a) The genuine permanent-null-freeze: run cache cleared by removeQueries +
|
||||||
|
// the refetch keeps ERRORING, so `run === null`. This is the ONLY case the
|
||||||
|
// safety-net exists to cure — it MUST clear so the frozen view resumes.
|
||||||
|
it("clears on a permanent error when the run is null (permanent-null-freeze)", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearLatchOnQueryError({
|
||||||
|
stoppingRun: true,
|
||||||
|
isLocalStreaming: false,
|
||||||
|
runQueryFailed: true,
|
||||||
|
run: null,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldClearLatchOnQueryError({
|
||||||
|
stoppingRun: true,
|
||||||
|
isLocalStreaming: false,
|
||||||
|
runQueryFailed: true,
|
||||||
|
run: undefined,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A TERMINAL run also satisfies `!isRunActive`; clearing then is harmless — the
|
||||||
|
// terminal effect (shouldClearStoppingLatch) already clears for a terminal run,
|
||||||
|
// so this only ever agrees with it. Asserted so the (c) reasoning is pinned.
|
||||||
|
it("clears on an error when the run is terminal (harmless, agrees with terminal effect)", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearLatchOnQueryError({
|
||||||
|
stoppingRun: true,
|
||||||
|
isLocalStreaming: false,
|
||||||
|
runQueryFailed: true,
|
||||||
|
run: makeRun("aborted"),
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT clear without an actual query error", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearLatchOnQueryError({
|
||||||
|
stoppingRun: true,
|
||||||
|
isLocalStreaming: false,
|
||||||
|
runQueryFailed: false,
|
||||||
|
run: null,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT clear while this tab is the local streamer", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearLatchOnQueryError({
|
||||||
|
stoppingRun: true,
|
||||||
|
isLocalStreaming: true,
|
||||||
|
runQueryFailed: true,
|
||||||
|
run: null,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT clear when no stop was requested", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearLatchOnQueryError({
|
||||||
|
stoppingRun: false,
|
||||||
|
isLocalStreaming: false,
|
||||||
|
runQueryFailed: true,
|
||||||
|
run: null,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mergeObservedMessage", () => {
|
||||||
|
it("replaces the message with the same id in place (per-step growth)", () => {
|
||||||
|
const prev = [makeMsg("u1", "hi"), makeMsg("a1", "step 1")];
|
||||||
|
const observed = makeMsg("a1", "step 1\nstep 2");
|
||||||
|
const next = mergeObservedMessage(prev, observed);
|
||||||
|
expect(next).toHaveLength(2);
|
||||||
|
expect(next[1]).toBe(observed);
|
||||||
|
expect(next[0]).toBe(prev[0]); // untouched
|
||||||
|
expect(next).not.toBe(prev); // new array (never mutates input)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends when the observed message is not yet present", () => {
|
||||||
|
const prev = [makeMsg("u1", "hi")];
|
||||||
|
const observed = makeMsg("a1", "first token");
|
||||||
|
const next = mergeObservedMessage(prev, observed);
|
||||||
|
expect(next).toHaveLength(2);
|
||||||
|
expect(next[1]).toBe(observed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the original list unchanged when there is nothing to merge", () => {
|
||||||
|
const prev = [makeMsg("u1", "hi")];
|
||||||
|
expect(mergeObservedMessage(prev, null)).toBe(prev);
|
||||||
|
expect(mergeObservedMessage(prev, undefined)).toBe(prev);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
import type { IAiChatRun } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconnect-and-live-follow helpers (#184). When a chat is reopened while its
|
||||||
|
* agent run is STILL going, this tab is a PASSIVE OBSERVER: it did not start the
|
||||||
|
* run here (no local SSE stream), so it catches up by POLLING the reconnect
|
||||||
|
* endpoint (`POST /ai-chat/run`) and merging the run's incrementally-persisted
|
||||||
|
* assistant message into the rendered thread. These are the small pure decisions
|
||||||
|
* that machinery hangs off, extracted so they can be unit-tested in isolation
|
||||||
|
* (mirrors how reindex polling / editor-sync-state are tested).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** How often to re-poll the reconnect endpoint while a run is ACTIVE. */
|
||||||
|
export const RUN_POLL_INTERVAL_MS = 2000;
|
||||||
|
|
||||||
|
// 'pending' and 'running' are the two ACTIVE statuses; 'succeeded' | 'failed' |
|
||||||
|
// 'aborted' are TERMINAL (and any unknown future status is treated as terminal,
|
||||||
|
// so a stale/odd value never polls forever).
|
||||||
|
const ACTIVE_STATUSES = new Set(["pending", "running"]);
|
||||||
|
|
||||||
|
/** Whether a run is still going (worth polling / merging live updates from). */
|
||||||
|
export function isRunActive(run: IAiChatRun | null | undefined): boolean {
|
||||||
|
return !!run && ACTIVE_STATUSES.has(run.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The TanStack Query `refetchInterval` value for the run query: poll every
|
||||||
|
* {@link RUN_POLL_INTERVAL_MS} while the run is active, and `false` (stop) once
|
||||||
|
* it is terminal or there is no run. Polling is thus naturally bounded by the run
|
||||||
|
* reaching a terminal status — no separate timeout cap is needed.
|
||||||
|
*/
|
||||||
|
export function runPollInterval(
|
||||||
|
run: IAiChatRun | null | undefined,
|
||||||
|
): number | false {
|
||||||
|
return isRunActive(run) ? RUN_POLL_INTERVAL_MS : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observer-vs-streamer decision. We render the polled run message (catch up +
|
||||||
|
* keep advancing) ONLY when this tab is a passive observer: there IS a run AND
|
||||||
|
* this tab is NOT the one locally streaming it (we reconnected, we didn't start
|
||||||
|
* it here). When this tab is the streamer, the live SSE stream owns the view, so
|
||||||
|
* we neither poll nor merge — avoiding a double-render fight. Terminal runs still
|
||||||
|
* merge (so the final persisted output is shown on reopen); the poll itself is
|
||||||
|
* stopped separately by {@link runPollInterval}.
|
||||||
|
*/
|
||||||
|
export function shouldObserveRun(
|
||||||
|
run: IAiChatRun | null | undefined,
|
||||||
|
localStreaming: boolean,
|
||||||
|
): boolean {
|
||||||
|
return !!run && !localStreaming;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should the "stopping" latch — which suppresses the observer re-stream flash
|
||||||
|
* after the user pressed Stop — be RELEASED now? All three must hold:
|
||||||
|
* - `stoppingRun`: we actually requested a stop (otherwise nothing to release);
|
||||||
|
* - `!isLocalStreaming`: this tab is NOT the local streamer. While we are the
|
||||||
|
* streamer the run query is disabled, so the observed `run` is not the run we
|
||||||
|
* are following — releasing the latch then would re-open the flash for the
|
||||||
|
* current turn the instant we switch to observer role;
|
||||||
|
* - the observed `run` EXISTS and has reached a TERMINAL status.
|
||||||
|
*
|
||||||
|
* The null / still-active `run` case is the #234 F4 invariant. On Stop the stale
|
||||||
|
* PREVIOUS-turn run is removed from the query cache (`removeQueries`), so `run`
|
||||||
|
* is null until the CURRENT turn's run is re-fetched fresh; a null or active run
|
||||||
|
* therefore HOLDS the latch, so it can only ever clear against the current turn's
|
||||||
|
* OWN terminal run — never a stale cached one. (The cache removal itself is
|
||||||
|
* integration-level in AiChatWindow; this predicate encodes the decision given
|
||||||
|
* whatever run is currently observed, and a stale terminal run is
|
||||||
|
* indistinguishable from a current terminal run at the predicate level — hence
|
||||||
|
* the cache removal is what guarantees only the current run is ever passed here.)
|
||||||
|
*/
|
||||||
|
export function shouldClearStoppingLatch(args: {
|
||||||
|
stoppingRun: boolean;
|
||||||
|
run: IAiChatRun | null | undefined;
|
||||||
|
isLocalStreaming: boolean;
|
||||||
|
}): boolean {
|
||||||
|
const { stoppingRun, run, isLocalStreaming } = args;
|
||||||
|
if (!stoppingRun || isLocalStreaming) return false;
|
||||||
|
return !!run && !isRunActive(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should the "stopping" latch be RELEASED by the run-query ERROR safety-net?
|
||||||
|
* (#234 F7 — a NEW path of the same re-stream flash the F4 latch exists to
|
||||||
|
* prevent.) After Stop, `handleServerStop` clears the run cache; the terminal
|
||||||
|
* effect then holds the latch via `if (!run) return` until the CURRENT turn's run
|
||||||
|
* is fetched fresh. If that refetch instead ERRORS permanently, `run` stays null,
|
||||||
|
* its status-keyed refetchInterval is off, and nothing would ever observe a
|
||||||
|
* terminal run — freezing the view with the observer merge suppressed. This
|
||||||
|
* safety-net cures ONLY that genuine permanent-null-freeze.
|
||||||
|
*
|
||||||
|
* All four must hold:
|
||||||
|
* - `stoppingRun`: we actually requested a stop (otherwise nothing to release);
|
||||||
|
* - `!isLocalStreaming`: this tab is NOT the local streamer (same reason as
|
||||||
|
* {@link shouldClearStoppingLatch});
|
||||||
|
* - `runQueryFailed`: the run query is in its error state (TanStack Query v5 with
|
||||||
|
* retry:false — isError);
|
||||||
|
* - `!isRunActive(run)`: the observed `run` is NOT an active (pending/running)
|
||||||
|
* held run. This is the F7 gate. In TanStack Query v5 the query's `data` is
|
||||||
|
* RETAINED on error, so `runQueryFailed` can be true while `run` is STILL an
|
||||||
|
* ACTIVE run (a single transient GET-run failure in the window between Stop and
|
||||||
|
* settle). Without this gate a transient error would release the latch early —
|
||||||
|
* re-opening the observer merge and flashing the growing detached run over the
|
||||||
|
* frozen row (exactly the F4 flash). Gating on the run NOT being active means we
|
||||||
|
* only ever cure the permanent-null-freeze (`run === null`, so
|
||||||
|
* `isRunActive(null)` is false), never release against an active run.
|
||||||
|
*
|
||||||
|
* (A terminal `run` also satisfies `!isRunActive(run)`; clearing then is harmless
|
||||||
|
* — the terminal effect's {@link shouldClearStoppingLatch} already clears the
|
||||||
|
* latch for a terminal run, so this only ever agrees with it, never conflicts.)
|
||||||
|
*
|
||||||
|
* INVARIANT (do not break): clearing the latch on the `run === null` branch is safe
|
||||||
|
* ONLY because the run query's `refetchInterval` (see {@link runPollInterval}) stops
|
||||||
|
* polling when the data is empty — so after we clear on null+error there is no
|
||||||
|
* subsequent auto-poll that could return a still-active detached run and re-open the
|
||||||
|
* merge. If `refetchInterval` is ever changed to keep polling on `run === null`/on
|
||||||
|
* error, this null-branch clear would re-open the F7 flash through the null path.
|
||||||
|
* Do not change the run query's refetchInterval without re-checking this path.
|
||||||
|
*/
|
||||||
|
export function shouldClearLatchOnQueryError(args: {
|
||||||
|
stoppingRun: boolean;
|
||||||
|
isLocalStreaming: boolean;
|
||||||
|
runQueryFailed: boolean;
|
||||||
|
run: IAiChatRun | null | undefined;
|
||||||
|
}): boolean {
|
||||||
|
const { stoppingRun, isLocalStreaming, runQueryFailed, run } = args;
|
||||||
|
return (
|
||||||
|
stoppingRun && !isLocalStreaming && runQueryFailed && !isRunActive(run)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge an observed assistant message into the rendered list: replace the message
|
||||||
|
* with the same id in place (the in-progress assistant row is already seeded from
|
||||||
|
* history, so per-step growth replaces it), or append it when absent. Returns a
|
||||||
|
* new array; the input is never mutated.
|
||||||
|
*/
|
||||||
|
export function mergeObservedMessage(
|
||||||
|
messages: UIMessage[],
|
||||||
|
observed: UIMessage | null | undefined,
|
||||||
|
): UIMessage[] {
|
||||||
|
if (!observed) return messages;
|
||||||
|
const idx = messages.findIndex((m) => m.id === observed.id);
|
||||||
|
if (idx === -1) return [...messages, observed];
|
||||||
|
const next = messages.slice();
|
||||||
|
next[idx] = observed;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
@@ -93,6 +93,11 @@ import {
|
|||||||
isBodyEditable,
|
isBodyEditable,
|
||||||
isCollabSynced,
|
isCollabSynced,
|
||||||
} from "@/features/editor/editor-sync-state";
|
} from "@/features/editor/editor-sync-state";
|
||||||
|
import {
|
||||||
|
isVitalsActive,
|
||||||
|
measurePageOpen,
|
||||||
|
reportEditorTx,
|
||||||
|
} from "@/lib/telemetry/vitals";
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -351,6 +356,40 @@ export default function PageEditor({
|
|||||||
editor.storage.pageId = pageId;
|
editor.storage.pageId = pageId;
|
||||||
handleScrollTo(editor);
|
handleScrollTo(editor);
|
||||||
editorRef.current = editor;
|
editorRef.current = editor;
|
||||||
|
|
||||||
|
// #355 — perf instrumentation. Skip ALL of it when telemetry is
|
||||||
|
// disabled (F1 flag off) or this session isn't sampled: no page-open
|
||||||
|
// measure, and crucially NO dispatch wrapping, so a non-collecting
|
||||||
|
// session pays zero per-transaction cost.
|
||||||
|
if (isVitalsActive()) {
|
||||||
|
// page_open_ms: this is the first editor-content render, so measure
|
||||||
|
// against any page-open mark set on the tree-row/link click.
|
||||||
|
measurePageOpen();
|
||||||
|
|
||||||
|
// editor_tx_ms: time the SYNCHRONOUS part of applying each
|
||||||
|
// transaction (state.apply + updateState) by wrapping the view's
|
||||||
|
// dispatch. Only slow syncs (>8ms) are reported (see reportEditorTx),
|
||||||
|
// so the common path adds just one performance.now() pair. Passive:
|
||||||
|
// the original dispatch still runs unchanged.
|
||||||
|
try {
|
||||||
|
const view = editor.view as unknown as {
|
||||||
|
dispatch: (tr: unknown) => void;
|
||||||
|
};
|
||||||
|
const originalDispatch = view.dispatch.bind(view);
|
||||||
|
view.dispatch = (tr: unknown) => {
|
||||||
|
const started = performance.now();
|
||||||
|
originalDispatch(tr);
|
||||||
|
const elapsed = performance.now() - started;
|
||||||
|
try {
|
||||||
|
reportEditorTx(elapsed, editor.state.doc.content.size);
|
||||||
|
} catch {
|
||||||
|
// never let telemetry break editing
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// if the view shape changes, skip editor_tx instrumentation
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onUpdate({ editor }) {
|
onUpdate({ editor }) {
|
||||||
|
|||||||
+966
-265
File diff suppressed because it is too large
Load Diff
+62
@@ -394,6 +394,10 @@ export default function AiProviderSettings() {
|
|||||||
useState<boolean>(
|
useState<boolean>(
|
||||||
workspace?.settings?.ai?.publicShareAssistant ?? false,
|
workspace?.settings?.ai?.publicShareAssistant ?? false,
|
||||||
);
|
);
|
||||||
|
// #184: detached/autonomous agent runs (settings.ai.autonomousRuns).
|
||||||
|
const [autonomousRunsEnabled, setAutonomousRunsEnabled] = useState<boolean>(
|
||||||
|
workspace?.settings?.ai?.autonomousRuns ?? false,
|
||||||
|
);
|
||||||
const [chatToggleLoading, setChatToggleLoading] = useState(false);
|
const [chatToggleLoading, setChatToggleLoading] = useState(false);
|
||||||
const [searchToggleLoading, setSearchToggleLoading] = useState(false);
|
const [searchToggleLoading, setSearchToggleLoading] = useState(false);
|
||||||
const [dictationToggleLoading, setDictationToggleLoading] = useState(false);
|
const [dictationToggleLoading, setDictationToggleLoading] = useState(false);
|
||||||
@@ -403,6 +407,8 @@ export default function AiProviderSettings() {
|
|||||||
publicShareAssistantToggleLoading,
|
publicShareAssistantToggleLoading,
|
||||||
setPublicShareAssistantToggleLoading,
|
setPublicShareAssistantToggleLoading,
|
||||||
] = useState(false);
|
] = useState(false);
|
||||||
|
const [autonomousRunsToggleLoading, setAutonomousRunsToggleLoading] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
// Whether a key is currently stored server-side (drives the placeholder).
|
// Whether a key is currently stored server-side (drives the placeholder).
|
||||||
const [hasApiKey, setHasApiKey] = useState(false);
|
const [hasApiKey, setHasApiKey] = useState(false);
|
||||||
@@ -730,6 +736,37 @@ export default function AiProviderSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optimistic toggle for detached/autonomous agent runs
|
||||||
|
// (settings.ai.autonomousRuns). When on, a chat turn becomes a server-side run
|
||||||
|
// that survives a browser disconnect and can be reconnected to / live-followed;
|
||||||
|
// only an explicit Stop ends it. Off by default; single-instance-only in phase 1.
|
||||||
|
async function handleToggleAutonomousRuns(value: boolean) {
|
||||||
|
setAutonomousRunsToggleLoading(true);
|
||||||
|
const previous = autonomousRunsEnabled;
|
||||||
|
setAutonomousRunsEnabled(value);
|
||||||
|
try {
|
||||||
|
const updated = await updateWorkspace({ autonomousRuns: value });
|
||||||
|
setWorkspace({
|
||||||
|
...updated,
|
||||||
|
settings: {
|
||||||
|
...updated.settings,
|
||||||
|
ai: { ...updated.settings?.ai, autonomousRuns: value },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
notifications.show({ message: t("Updated successfully") });
|
||||||
|
} catch (err) {
|
||||||
|
setAutonomousRunsEnabled(previous);
|
||||||
|
const message = (err as { response?: { data?: { message?: string } } })
|
||||||
|
?.response?.data?.message;
|
||||||
|
notifications.show({
|
||||||
|
message: message ?? t("Failed to update data"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setAutonomousRunsToggleLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Admins only — match the previous behavior.
|
// Admins only — match the previous behavior.
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return (
|
return (
|
||||||
@@ -960,6 +997,31 @@ export default function AiProviderSettings() {
|
|||||||
{...form.getInputProps("publicShareAssistantRoleId")}
|
{...form.getInputProps("publicShareAssistantRoleId")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Detached/autonomous agent runs: a chat turn becomes a server-side run
|
||||||
|
that survives a browser disconnect; only an explicit Stop ends it.
|
||||||
|
Single-instance-only in phase 1. */}
|
||||||
|
<Group justify="space-between" align="center" wrap="nowrap" mt="md">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{t("Autonomous agent runs")}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t(
|
||||||
|
"Keep an agent turn running server-side even if the browser disconnects; reconnect and follow it on reopen. Single-instance deployments only.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Switch
|
||||||
|
label={t("Enabled")}
|
||||||
|
labelPosition="left"
|
||||||
|
checked={autonomousRunsEnabled}
|
||||||
|
disabled={autonomousRunsToggleLoading}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleToggleAutonomousRuns(e.currentTarget.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<Group mt="md" align="center">
|
<Group mt="md" align="center">
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export interface IWorkspace {
|
|||||||
aiDictation?: boolean;
|
aiDictation?: boolean;
|
||||||
aiDictationStreaming?: boolean;
|
aiDictationStreaming?: boolean;
|
||||||
aiPublicShareAssistant?: boolean;
|
aiPublicShareAssistant?: boolean;
|
||||||
|
// Write-only field for updateWorkspace({ autonomousRuns }). Read state lives at
|
||||||
|
// settings.ai.autonomousRuns.
|
||||||
|
autonomousRuns?: boolean;
|
||||||
trashRetentionDays?: number;
|
trashRetentionDays?: number;
|
||||||
// Default lifetime (HOURS) for new temporary notes; frozen per-note at creation.
|
// Default lifetime (HOURS) for new temporary notes; frozen per-note at creation.
|
||||||
temporaryNoteHours?: number;
|
temporaryNoteHours?: number;
|
||||||
@@ -65,6 +68,9 @@ export interface IWorkspaceAiSettings {
|
|||||||
dictation?: boolean;
|
dictation?: boolean;
|
||||||
dictationStreaming?: boolean;
|
dictationStreaming?: boolean;
|
||||||
publicShareAssistant?: boolean;
|
publicShareAssistant?: boolean;
|
||||||
|
// #184: detached agent runs (a run survives a browser disconnect and can be
|
||||||
|
// reconnected to / live-followed on reopen). Gates the run-reconnect polling.
|
||||||
|
autonomousRuns?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWorkspaceSharingSettings {
|
export interface IWorkspaceSharingSettings {
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ export function isCompactPageTreeEnabled(): boolean {
|
|||||||
return castToBoolean(getConfigValue("COMPACT_PAGE_TREE", "true"));
|
return castToBoolean(getConfigValue("COMPACT_PAGE_TREE", "true"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #355 — operator toggle for client perf-telemetry. DEFAULT OFF: the server
|
||||||
|
// mirrors CLIENT_TELEMETRY_ENABLED into window.CONFIG; when off the client
|
||||||
|
// installs no observers and sends nothing (the sink endpoint doesn't exist).
|
||||||
|
export function isClientTelemetryEnabled(): boolean {
|
||||||
|
return castToBoolean(getConfigValue("CLIENT_TELEMETRY_ENABLED", "false"));
|
||||||
|
}
|
||||||
|
|
||||||
export function getAvatarUrl(
|
export function getAvatarUrl(
|
||||||
avatarUrl: string,
|
avatarUrl: string,
|
||||||
type: AvatarIconType = AvatarIconType.AVATAR,
|
type: AvatarIconType = AvatarIconType.AVATAR,
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { templateRoute } from "./route-template";
|
||||||
|
|
||||||
|
describe("templateRoute", () => {
|
||||||
|
it("templates a space page path (never leaks slugs)", () => {
|
||||||
|
const t = templateRoute("/s/engineering/p/design-doc-abc123");
|
||||||
|
expect(t).toBe("/s/:space/p/:slug");
|
||||||
|
expect(t).not.toContain("engineering");
|
||||||
|
expect(t).not.toContain("design-doc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("templates share, redirect and space paths", () => {
|
||||||
|
expect(templateRoute("/share/abc/p/xyz")).toBe("/share/:shareId/p/:slug");
|
||||||
|
expect(templateRoute("/share/p/xyz")).toBe("/share/p/:slug");
|
||||||
|
expect(templateRoute("/p/some-slug")).toBe("/p/:slug");
|
||||||
|
expect(templateRoute("/s/team")).toBe("/s/:space");
|
||||||
|
expect(templateRoute("/s/team/trash")).toBe("/s/:space/trash");
|
||||||
|
expect(templateRoute("/labels/urgent")).toBe("/labels/:label");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps known static routes verbatim", () => {
|
||||||
|
expect(templateRoute("/home")).toBe("/home");
|
||||||
|
expect(templateRoute("/settings/members")).toBe("/settings/members");
|
||||||
|
expect(templateRoute("/")).toBe("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalises a trailing slash", () => {
|
||||||
|
expect(templateRoute("/s/team/p/slug/")).toBe("/s/:space/p/:slug");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses unknown paths to 'other' (bounded cardinality)", () => {
|
||||||
|
expect(templateRoute("/weird/unknown/thing")).toBe("other");
|
||||||
|
expect(templateRoute("/s/team/p/slug/extra/segments")).toBe("other");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Map a raw pathname to a BOUNDED route TEMPLATE (#355).
|
||||||
|
*
|
||||||
|
* Perf metrics must be labelled by route template only — never a raw path with
|
||||||
|
* slugs/ids — so the server-side `route` column and any downstream aggregation
|
||||||
|
* stay low-cardinality and carry NO page slugs/titles (privacy). Anything that
|
||||||
|
* does not match a known pattern collapses to `other`.
|
||||||
|
*
|
||||||
|
* The template vocabulary mirrors the issue's example (`/s/:space/p/:slug`).
|
||||||
|
*/
|
||||||
|
const ROUTE_PATTERNS: { re: RegExp; template: string }[] = [
|
||||||
|
// Share pages (public).
|
||||||
|
{ re: /^\/share\/[^/]+\/p\/[^/]+$/, template: '/share/:shareId/p/:slug' },
|
||||||
|
{ re: /^\/share\/p\/[^/]+$/, template: '/share/p/:slug' },
|
||||||
|
{ re: /^\/share\/[^/]+$/, template: '/share/:shareId' },
|
||||||
|
// Page redirect.
|
||||||
|
{ re: /^\/p\/[^/]+$/, template: '/p/:slug' },
|
||||||
|
// Space + page.
|
||||||
|
{ re: /^\/s\/[^/]+\/p\/[^/]+$/, template: '/s/:space/p/:slug' },
|
||||||
|
{ re: /^\/s\/[^/]+\/trash$/, template: '/s/:space/trash' },
|
||||||
|
{ re: /^\/s\/[^/]+$/, template: '/s/:space' },
|
||||||
|
// Misc dynamic.
|
||||||
|
{ re: /^\/labels\/[^/]+$/, template: '/labels/:label' },
|
||||||
|
{ re: /^\/invites\/[^/]+$/, template: '/invites/:invitationId' },
|
||||||
|
{ re: /^\/settings\/groups\/[^/]+$/, template: '/settings/groups/:groupId' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Static routes we accept verbatim (finite set).
|
||||||
|
const STATIC_ROUTES = new Set<string>([
|
||||||
|
'/home',
|
||||||
|
'/spaces',
|
||||||
|
'/favorites',
|
||||||
|
'/login',
|
||||||
|
'/forgot-password',
|
||||||
|
'/password-reset',
|
||||||
|
'/setup/register',
|
||||||
|
'/settings/account/profile',
|
||||||
|
'/settings/account/preferences',
|
||||||
|
'/settings/workspace',
|
||||||
|
'/settings/ai',
|
||||||
|
'/settings/members',
|
||||||
|
'/settings/groups',
|
||||||
|
'/settings/spaces',
|
||||||
|
'/settings/sharing',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function templateRoute(pathname: string): string {
|
||||||
|
// Normalise a trailing slash (except root).
|
||||||
|
const path =
|
||||||
|
pathname.length > 1 && pathname.endsWith('/')
|
||||||
|
? pathname.slice(0, -1)
|
||||||
|
: pathname;
|
||||||
|
|
||||||
|
if (path === '' || path === '/') return '/';
|
||||||
|
if (STATIC_ROUTES.has(path)) return path;
|
||||||
|
|
||||||
|
for (const { re, template } of ROUTE_PATTERNS) {
|
||||||
|
if (re.test(path)) return template;
|
||||||
|
}
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Template for the current window location. */
|
||||||
|
export function currentRouteTemplate(): string {
|
||||||
|
try {
|
||||||
|
return templateRoute(window.location.pathname);
|
||||||
|
} catch {
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import {
|
||||||
|
onCLS,
|
||||||
|
onINP,
|
||||||
|
onLCP,
|
||||||
|
onTTFB,
|
||||||
|
type CLSMetricWithAttribution,
|
||||||
|
type INPMetricWithAttribution,
|
||||||
|
type LCPMetricWithAttribution,
|
||||||
|
type TTFBMetricWithAttribution,
|
||||||
|
} from "web-vitals/attribution";
|
||||||
|
import { isClientTelemetryEnabled } from "@/lib/config";
|
||||||
|
import { currentRouteTemplate } from "./route-template";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client perf-telemetry (#355): web-vitals + custom metrics buffered and posted
|
||||||
|
* to POST /api/telemetry/vitals via sendBeacon.
|
||||||
|
*
|
||||||
|
* Design constraints from the issue:
|
||||||
|
* - Sampling is decided ONCE per session (25%), cached in sessionStorage,
|
||||||
|
* BEFORE any observer is subscribed. Non-sampled sessions send nothing.
|
||||||
|
* - Route labels are TEMPLATES only; attr is truncated to 120 chars; no page
|
||||||
|
* titles/slugs/text ever leave the browser.
|
||||||
|
* - Observers are passive and reporting is best-effort — telemetry must not
|
||||||
|
* degrade the perf it measures.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ENDPOINT = "/api/telemetry/vitals";
|
||||||
|
const SAMPLE_RATE = 0.25;
|
||||||
|
const SAMPLE_KEY = "gm_vitals_sampled";
|
||||||
|
const FLUSH_INTERVAL_MS = 15_000;
|
||||||
|
const MAX_BUFFER = 40; // flush early if the buffer fills between timers
|
||||||
|
const MAX_ATTR_LENGTH = 120;
|
||||||
|
const EDITOR_TX_MIN_MS = 8; // only report editor transactions slower than this
|
||||||
|
|
||||||
|
const ALLOWED_NAMES = new Set([
|
||||||
|
"INP",
|
||||||
|
"LCP",
|
||||||
|
"CLS",
|
||||||
|
"TTFB",
|
||||||
|
"editor_tx_ms",
|
||||||
|
"page_open_ms",
|
||||||
|
"longtask_ms",
|
||||||
|
]);
|
||||||
|
|
||||||
|
interface VitalEvent {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
rating?: string;
|
||||||
|
route?: string;
|
||||||
|
attr?: string;
|
||||||
|
docSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sampledCache: boolean | null = null;
|
||||||
|
let initialised = false;
|
||||||
|
let buffer: VitalEvent[] = [];
|
||||||
|
let longtaskSum = 0; // accumulated longtask duration (ms) for the current window
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide once per session whether this session is sampled. Cached in
|
||||||
|
* sessionStorage so the choice is stable across reloads within the session and
|
||||||
|
* identical for every observer/custom-metric caller.
|
||||||
|
*/
|
||||||
|
export function isVitalsSampled(): boolean {
|
||||||
|
if (sampledCache !== null) return sampledCache;
|
||||||
|
try {
|
||||||
|
const stored = sessionStorage.getItem(SAMPLE_KEY);
|
||||||
|
if (stored === "1") return (sampledCache = true);
|
||||||
|
if (stored === "0") return (sampledCache = false);
|
||||||
|
const sampled = Math.random() < SAMPLE_RATE;
|
||||||
|
sessionStorage.setItem(SAMPLE_KEY, sampled ? "1" : "0");
|
||||||
|
return (sampledCache = sampled);
|
||||||
|
} catch {
|
||||||
|
// sessionStorage unavailable (private mode / SSR): default to not sampled.
|
||||||
|
return (sampledCache = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True only when telemetry is BOTH enabled by the operator (F1 flag) AND this
|
||||||
|
* session is sampled. Callers outside initVitals (e.g. the editor dispatch
|
||||||
|
* wrapper) use this to skip ALL instrumentation cost on disabled/non-sampled
|
||||||
|
* sessions — no observers, no per-transaction timing.
|
||||||
|
*/
|
||||||
|
export function isVitalsActive(): boolean {
|
||||||
|
return isClientTelemetryEnabled() && isVitalsSampled();
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateAttr(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== "string" || value.length === 0) return undefined;
|
||||||
|
return value.slice(0, MAX_ATTR_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueue(event: VitalEvent): void {
|
||||||
|
if (!ALLOWED_NAMES.has(event.name)) return;
|
||||||
|
if (!Number.isFinite(event.value)) return;
|
||||||
|
buffer.push(event);
|
||||||
|
if (buffer.length >= MAX_BUFFER) flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
function flush(): void {
|
||||||
|
// Fold any pending longtask total into the batch first.
|
||||||
|
if (longtaskSum > 0) {
|
||||||
|
buffer.push({
|
||||||
|
name: "longtask_ms",
|
||||||
|
value: Math.round(longtaskSum),
|
||||||
|
route: currentRouteTemplate(),
|
||||||
|
});
|
||||||
|
longtaskSum = 0;
|
||||||
|
}
|
||||||
|
if (buffer.length === 0) return;
|
||||||
|
|
||||||
|
const payload = JSON.stringify({ events: buffer });
|
||||||
|
buffer = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = new Blob([payload], { type: "application/json" });
|
||||||
|
if (navigator.sendBeacon && navigator.sendBeacon(ENDPOINT, blob)) return;
|
||||||
|
// Fallback for browsers without sendBeacon: keepalive fetch.
|
||||||
|
void fetch(ENDPOINT, {
|
||||||
|
method: "POST",
|
||||||
|
body: payload,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
keepalive: true,
|
||||||
|
}).catch(() => undefined);
|
||||||
|
} catch {
|
||||||
|
// Best-effort: never throw out of telemetry.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report a custom client metric (editor_tx_ms, page_open_ms). No-op unless the
|
||||||
|
* session is sampled. Route is always the current TEMPLATE.
|
||||||
|
*/
|
||||||
|
export function reportClientMetric(
|
||||||
|
name: "editor_tx_ms" | "page_open_ms",
|
||||||
|
value: number,
|
||||||
|
extra?: { docSize?: number },
|
||||||
|
): void {
|
||||||
|
if (!isVitalsActive()) return;
|
||||||
|
if (!Number.isFinite(value)) return;
|
||||||
|
enqueue({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
route: currentRouteTemplate(),
|
||||||
|
docSize: extra?.docSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Threshold-gated editor transaction reporter (only reports slow syncs). */
|
||||||
|
export function reportEditorTx(ms: number, docSize: number): void {
|
||||||
|
if (ms <= EDITOR_TX_MIN_MS) return;
|
||||||
|
reportClientMetric("editor_tx_ms", ms, { docSize });
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_OPEN_MARK = "gm_page_open_start";
|
||||||
|
|
||||||
|
/** Mark the start of a page-open interaction (tree-row / link click). */
|
||||||
|
export function markPageOpenStart(): void {
|
||||||
|
try {
|
||||||
|
performance.clearMarks(PAGE_OPEN_MARK);
|
||||||
|
performance.mark(PAGE_OPEN_MARK);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure page_open_ms at first editor-content render, if a start mark exists.
|
||||||
|
* Consumes the mark so a later render doesn't double-count.
|
||||||
|
*/
|
||||||
|
export function measurePageOpen(): void {
|
||||||
|
try {
|
||||||
|
const marks = performance.getEntriesByName(PAGE_OPEN_MARK, "mark");
|
||||||
|
if (marks.length === 0) return;
|
||||||
|
const started = marks[0].startTime;
|
||||||
|
const elapsed = performance.now() - started;
|
||||||
|
performance.clearMarks(PAGE_OPEN_MARK);
|
||||||
|
if (elapsed > 0 && Number.isFinite(elapsed)) {
|
||||||
|
reportClientMetric("page_open_ms", elapsed);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function attrTarget(
|
||||||
|
metric:
|
||||||
|
| INPMetricWithAttribution
|
||||||
|
| LCPMetricWithAttribution
|
||||||
|
| CLSMetricWithAttribution,
|
||||||
|
): string | undefined {
|
||||||
|
const a = metric.attribution as Record<string, unknown> | undefined;
|
||||||
|
if (!a) return undefined;
|
||||||
|
// Different vitals expose their culprit element under different keys; only a
|
||||||
|
// CSS-selector-ish target string is taken (no text content / titles).
|
||||||
|
return (
|
||||||
|
truncateAttr(a.interactionTarget) ??
|
||||||
|
truncateAttr(a.element) ??
|
||||||
|
truncateAttr(a.largestShiftTarget) ??
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise client telemetry. Safe to call multiple times (idempotent). Returns
|
||||||
|
* immediately without subscribing when the session is not sampled — so a
|
||||||
|
* non-sampled session subscribes to NO observers and sends nothing.
|
||||||
|
*/
|
||||||
|
export function initVitals(): void {
|
||||||
|
if (initialised) return;
|
||||||
|
initialised = true;
|
||||||
|
|
||||||
|
// Operator flag gate (F1, default OFF): when telemetry is disabled the sink
|
||||||
|
// endpoint does not even exist server-side, so install ZERO observers.
|
||||||
|
if (!isClientTelemetryEnabled()) return;
|
||||||
|
|
||||||
|
// Sampling gate is evaluated BEFORE any observer subscription.
|
||||||
|
if (!isVitalsSampled()) return;
|
||||||
|
|
||||||
|
const report = (
|
||||||
|
metric:
|
||||||
|
| INPMetricWithAttribution
|
||||||
|
| LCPMetricWithAttribution
|
||||||
|
| CLSMetricWithAttribution
|
||||||
|
| TTFBMetricWithAttribution,
|
||||||
|
) => {
|
||||||
|
enqueue({
|
||||||
|
name: metric.name,
|
||||||
|
value: metric.value,
|
||||||
|
rating: metric.rating,
|
||||||
|
route: currentRouteTemplate(),
|
||||||
|
attr:
|
||||||
|
metric.name === "TTFB"
|
||||||
|
? undefined
|
||||||
|
: attrTarget(
|
||||||
|
metric as
|
||||||
|
| INPMetricWithAttribution
|
||||||
|
| LCPMetricWithAttribution
|
||||||
|
| CLSMetricWithAttribution,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onINP(report);
|
||||||
|
onLCP(report);
|
||||||
|
onCLS(report);
|
||||||
|
onTTFB(report);
|
||||||
|
|
||||||
|
// Long tasks: aggregate the total blocking time per flush window (a passive
|
||||||
|
// observer; individual entries are summed, never stored/sent individually).
|
||||||
|
try {
|
||||||
|
if (typeof PerformanceObserver !== "undefined") {
|
||||||
|
const observer = new PerformanceObserver((list) => {
|
||||||
|
for (const entry of list.getEntries()) {
|
||||||
|
longtaskSum += entry.duration;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe({ type: "longtask", buffered: true });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// longtask entry type unsupported: skip silently.
|
||||||
|
}
|
||||||
|
|
||||||
|
// page_open_ms start: mark when the user clicks a page link/tree-row (any
|
||||||
|
// anchor navigating to a page URL). Passive capture listener; the matching
|
||||||
|
// measure fires at first editor-content render (measurePageOpen). No page
|
||||||
|
// titles/slugs are read — only the click timing is marked.
|
||||||
|
document.addEventListener(
|
||||||
|
"click",
|
||||||
|
(event) => {
|
||||||
|
const target = event.target as Element | null;
|
||||||
|
const anchor = target?.closest?.("a[href]") as HTMLAnchorElement | null;
|
||||||
|
if (!anchor) return;
|
||||||
|
const href = anchor.getAttribute("href") ?? "";
|
||||||
|
// A page link is `/s/:space/p/:slug`, `/p/:slug` or a share page path.
|
||||||
|
if (/\/p\//.test(href)) markPageOpenStart();
|
||||||
|
},
|
||||||
|
{ capture: true, passive: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Flush on tab hide (most reliable delivery point) and periodically.
|
||||||
|
const onHidden = () => {
|
||||||
|
if (document.visibilityState === "hidden") flush();
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", onHidden);
|
||||||
|
window.addEventListener("pagehide", flush);
|
||||||
|
|
||||||
|
setInterval(flush, FLUSH_INTERVAL_MS);
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
isPostHogEnabled,
|
isPostHogEnabled,
|
||||||
} from "@/lib/config.ts";
|
} from "@/lib/config.ts";
|
||||||
import posthog from "posthog-js";
|
import posthog from "posthog-js";
|
||||||
|
import { initVitals } from "@/lib/telemetry/vitals";
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -43,6 +44,10 @@ if (isCloud() && isPostHogEnabled) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #355 — client perf-telemetry. Decides sampling ONCE (25%/session) before
|
||||||
|
// subscribing to any observer; non-sampled sessions send nothing.
|
||||||
|
initVitals();
|
||||||
|
|
||||||
const container = document.getElementById("root") as HTMLElement;
|
const container = document.getElementById("root") as HTMLElement;
|
||||||
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
|
"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",
|
"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",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"pretest": "pnpm --filter @docmost/editor-ext build",
|
"pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/prosemirror-markdown build",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:int": "jest --config test/jest-integration.json",
|
"test:int": "jest --config test/jest-integration.json",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
@@ -43,6 +43,7 @@
|
|||||||
"@clickhouse/client": "^1.18.2",
|
"@clickhouse/client": "^1.18.2",
|
||||||
"@docmost/mcp": "workspace:*",
|
"@docmost/mcp": "workspace:*",
|
||||||
"@docmost/pdf-inspector": "1.9.6",
|
"@docmost/pdf-inspector": "1.9.6",
|
||||||
|
"@docmost/prosemirror-markdown": "workspace:*",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/multipart": "^10.0.0",
|
"@fastify/multipart": "^10.0.0",
|
||||||
"@fastify/static": "^9.1.3",
|
"@fastify/static": "^9.1.3",
|
||||||
@@ -111,6 +112,7 @@
|
|||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"postgres": "^3.4.8",
|
"postgres": "^3.4.8",
|
||||||
"postmark": "^4.0.7",
|
"postmark": "^4.0.7",
|
||||||
|
"prom-client": "^15.1.3",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-email": "6.0.8",
|
"react-email": "6.0.8",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@@ -174,7 +176,7 @@
|
|||||||
"/node_modules/"
|
"/node_modules/"
|
||||||
],
|
],
|
||||||
"transform": {
|
"transform": {
|
||||||
"happy-dom.+\\.js$": [
|
"(happy-dom.+|prosemirror-markdown/build/.+)\\.js$": [
|
||||||
"babel-jest",
|
"babel-jest",
|
||||||
{
|
{
|
||||||
"presets": [
|
"presets": [
|
||||||
@@ -192,7 +194,7 @@
|
|||||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
"^.+\\.(t|j)sx?$": "ts-jest"
|
||||||
},
|
},
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0)(@|/))"
|
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0|@docmost/prosemirror-markdown)(@|/))"
|
||||||
],
|
],
|
||||||
"collectCoverageFrom": [
|
"collectCoverageFrom": [
|
||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
@@ -203,7 +205,8 @@
|
|||||||
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
||||||
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
||||||
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
|
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
|
||||||
"^src/(.*)$": "<rootDir>/$1"
|
"^src/(.*)$": "<rootDir>/$1",
|
||||||
|
"^@tiptap/react$": "<rootDir>/../test/stubs/tiptap-react.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import { McpModule } from './integrations/mcp/mcp.module';
|
|||||||
import { SandboxModule } from './integrations/sandbox/sandbox.module';
|
import { SandboxModule } from './integrations/sandbox/sandbox.module';
|
||||||
import { AiModule } from './integrations/ai/ai.module';
|
import { AiModule } from './integrations/ai/ai.module';
|
||||||
import { AiChatModule } from './core/ai-chat/ai-chat.module';
|
import { AiChatModule } from './core/ai-chat/ai-chat.module';
|
||||||
|
import { MetricsModule } from './integrations/metrics/metrics.module';
|
||||||
|
import { ClientTelemetryModule } from './core/telemetry/client-telemetry.module';
|
||||||
|
|
||||||
const enterpriseModules = [];
|
const enterpriseModules = [];
|
||||||
try {
|
try {
|
||||||
@@ -93,6 +95,10 @@ try {
|
|||||||
SandboxModule,
|
SandboxModule,
|
||||||
AiModule,
|
AiModule,
|
||||||
AiChatModule,
|
AiChatModule,
|
||||||
|
MetricsModule,
|
||||||
|
// Gated OFF by default: only registers the public vitals sink controller
|
||||||
|
// when CLIENT_TELEMETRY_ENABLED=true (maintainer decision E1=B).
|
||||||
|
ClientTelemetryModule.register(),
|
||||||
...enterpriseModules,
|
...enterpriseModules,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ import {
|
|||||||
Column,
|
Column,
|
||||||
Status,
|
Status,
|
||||||
addUniqueIdsToDoc,
|
addUniqueIdsToDoc,
|
||||||
htmlToMarkdown,
|
|
||||||
TransclusionSource,
|
TransclusionSource,
|
||||||
TransclusionReference,
|
TransclusionReference,
|
||||||
FootnoteReference,
|
FootnoteReference,
|
||||||
@@ -51,6 +50,7 @@ import {
|
|||||||
FootnoteDefinition,
|
FootnoteDefinition,
|
||||||
PageEmbed,
|
PageEmbed,
|
||||||
} from '@docmost/editor-ext';
|
} from '@docmost/editor-ext';
|
||||||
|
import { convertProseMirrorToMarkdown } from '@docmost/prosemirror-markdown';
|
||||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||||
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
||||||
// @tiptap/html library works best for generating prosemirror json state but not HTML
|
// @tiptap/html library works best for generating prosemirror json state but not HTML
|
||||||
@@ -239,6 +239,10 @@ export function prosemirrorNodeToYElement(node: any): Y.XmlElement | Y.XmlText {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function jsonToMarkdown(tiptapJson: any): string {
|
export function jsonToMarkdown(tiptapJson: any): string {
|
||||||
const html = jsonToHtml(tiptapJson);
|
// Direct ProseMirror JSON -> Markdown via the canonical converter
|
||||||
return htmlToMarkdown(html);
|
// (`@docmost/prosemirror-markdown`) — no HTML intermediate, no second
|
||||||
|
// editor-ext markdown layer. Same serializer as the page/space export and the
|
||||||
|
// git-sync vault writer, so every server PM->MD path emits identical canonical
|
||||||
|
// markdown (issue #345).
|
||||||
|
return convertProseMirrorToMarkdown(tiptapJson);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
HISTORY_INTERVAL,
|
HISTORY_INTERVAL,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
|
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
|
||||||
|
import { observeCollabStore } from '../../integrations/metrics/metrics.registry';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* #251 — wire format of the client→server stateless message that signals a
|
* #251 — wire format of the client→server stateless message that signals a
|
||||||
@@ -192,6 +193,17 @@ export class PersistenceExtension implements Extension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async onStoreDocument(data: onStoreDocumentPayload) {
|
async onStoreDocument(data: onStoreDocumentPayload) {
|
||||||
|
// #355 — time the full store (persist + post-store side effects) into
|
||||||
|
// collab_store_duration_seconds. No-op when METRICS_PORT is unset.
|
||||||
|
const startedAt = performance.now();
|
||||||
|
try {
|
||||||
|
await this.storeDocument(data);
|
||||||
|
} finally {
|
||||||
|
observeCollabStore((performance.now() - startedAt) / 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async storeDocument(data: onStoreDocumentPayload) {
|
||||||
const { documentName, document, context } = data;
|
const { documentName, document, context } = data;
|
||||||
|
|
||||||
const pageId = getPageId(documentName);
|
const pageId = getPageId(documentName);
|
||||||
|
|||||||
@@ -0,0 +1,527 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
AiChatRunService,
|
||||||
|
RunAlreadyActiveError,
|
||||||
|
ONE_ACTIVE_RUN_PER_CHAT_INDEX,
|
||||||
|
mapTurnStatusToRun,
|
||||||
|
} from './ai-chat-run.service';
|
||||||
|
|
||||||
|
/** Shape a Postgres unique-violation the way the postgres.js driver surfaces it:
|
||||||
|
* SQLSTATE 23505 + the offending index in `constraint_name`. */
|
||||||
|
function uniqueViolation(constraintName: string): Error & {
|
||||||
|
code: string;
|
||||||
|
constraint_name: string;
|
||||||
|
} {
|
||||||
|
return Object.assign(
|
||||||
|
new Error('duplicate key value violates unique constraint'),
|
||||||
|
{
|
||||||
|
code: '23505',
|
||||||
|
constraint_name: constraintName,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit coverage for the #184 phase-1 run lifecycle (AiChatRunService) with a
|
||||||
|
* hand-rolled mock repo — no Nest graph, no DB. The invariant under test is the
|
||||||
|
* one that makes a run "autonomous": a run keeps going when its SUBSCRIBER (the
|
||||||
|
* browser) detaches, and ONLY an explicit stop aborts it. We assert that at the
|
||||||
|
* abort-signal level (the signal the agent loop actually consumes).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Minimal EnvironmentService stub. Single-instance (CLOUD unset) by default. */
|
||||||
|
function makeEnv(isCloud = false) {
|
||||||
|
return { isCloud: () => isCloud };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRepo(overrides: Record<string, jest.Mock> = {}) {
|
||||||
|
return {
|
||||||
|
insert: jest.fn(async (v: any) => ({
|
||||||
|
id: 'run-1',
|
||||||
|
status: v.status ?? 'running',
|
||||||
|
chatId: v.chatId,
|
||||||
|
workspaceId: v.workspaceId,
|
||||||
|
})),
|
||||||
|
update: jest.fn(async () => ({ id: 'run-1' })),
|
||||||
|
markStopRequested: jest.fn(async () => ({ id: 'run-1' })),
|
||||||
|
findActiveByChat: jest.fn(async () => undefined),
|
||||||
|
findLatestByChat: jest.fn(async () => undefined),
|
||||||
|
findById: jest.fn(async () => undefined),
|
||||||
|
sweepRunning: jest.fn(async () => 0),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mapTurnStatusToRun', () => {
|
||||||
|
it('maps the turn terminal status to the run terminal status', () => {
|
||||||
|
expect(mapTurnStatusToRun('completed')).toBe('succeeded');
|
||||||
|
expect(mapTurnStatusToRun('error')).toBe('failed');
|
||||||
|
expect(mapTurnStatusToRun('aborted')).toBe('aborted');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AiChatRunService.onModuleInit (startup sweep)', () => {
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
it('calls sweepRunning and resolves; logs when > 0', async () => {
|
||||||
|
const repo = makeRepo({ sweepRunning: jest.fn(async () => 2) });
|
||||||
|
const logSpy = jest
|
||||||
|
.spyOn(Logger.prototype, 'log')
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
await expect(svc.onModuleInit()).resolves.toBeUndefined();
|
||||||
|
expect(repo.sweepRunning).toHaveBeenCalledTimes(1);
|
||||||
|
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(String(logSpy.mock.calls[0][0])).toContain('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a sweep failure is swallowed (never blocks startup)', async () => {
|
||||||
|
const repo = makeRepo({
|
||||||
|
sweepRunning: jest.fn(async () => {
|
||||||
|
throw new Error('db down');
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const warnSpy = jest
|
||||||
|
.spyOn(Logger.prototype, 'warn')
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
await expect(svc.onModuleInit()).resolves.toBeUndefined();
|
||||||
|
// The first warn is the sweep failure (the multi-instance warn never fires
|
||||||
|
// single-instance), so the message is the db error.
|
||||||
|
expect(String(warnSpy.mock.calls[0][0])).toContain('db down');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('F1 (DECISION C): the boot sweep is UNCONDITIONAL — sweepRunning is called with NO staleness window, so a fresh running run (updatedAt = now) is settled, not skipped', async () => {
|
||||||
|
// The bug: a fast restart (deploy/OOM within minutes of the last step) left a
|
||||||
|
// run stuck 'running' under the old 10-min window, 409ing every later turn in
|
||||||
|
// the chat. The fix settles ALL pending|running on boot. We assert the service
|
||||||
|
// invokes sweepRunning with no `staleMs` (the unconditional path); the repo's
|
||||||
|
// own spec proves no-window => no updatedAt filter.
|
||||||
|
const repo = makeRepo({ sweepRunning: jest.fn(async () => 1) });
|
||||||
|
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
await svc.onModuleInit();
|
||||||
|
expect(repo.sweepRunning).toHaveBeenCalledTimes(1);
|
||||||
|
const callArgs = repo.sweepRunning.mock.calls[0] as unknown[];
|
||||||
|
const firstArg = callArgs[0] as { staleMs?: number } | undefined;
|
||||||
|
// Either no opts at all, or opts without a staleMs window => unconditional.
|
||||||
|
expect(firstArg?.staleMs).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('F2 (DECISION A): warns at startup that autonomousRuns is single-instance-only when a horizontally-scaled deployment (CLOUD) is detected', async () => {
|
||||||
|
const repo = makeRepo();
|
||||||
|
const warnSpy = jest
|
||||||
|
.spyOn(Logger.prototype, 'warn')
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv(true) as never);
|
||||||
|
await svc.onModuleInit();
|
||||||
|
const warned = warnSpy.mock.calls.some((c) =>
|
||||||
|
/single-instance-only/i.test(String(c[0])),
|
||||||
|
);
|
||||||
|
expect(warned).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('F2: does NOT warn about multi-instance on a single-instance (CLOUD unset) deployment', async () => {
|
||||||
|
const repo = makeRepo();
|
||||||
|
const warnSpy = jest
|
||||||
|
.spyOn(Logger.prototype, 'warn')
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv(false) as never);
|
||||||
|
await svc.onModuleInit();
|
||||||
|
const warned = warnSpy.mock.calls.some((c) =>
|
||||||
|
/single-instance-only/i.test(String(c[0])),
|
||||||
|
);
|
||||||
|
expect(warned).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AiChatRunService run lifecycle', () => {
|
||||||
|
it('beginRun inserts a running row and registers a live abort controller', async () => {
|
||||||
|
const repo = makeRepo();
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
const handle = await svc.beginRun({
|
||||||
|
chatId: 'chat-1',
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
expect(repo.insert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
chatId: 'chat-1',
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
createdBy: 'user-1',
|
||||||
|
status: 'running',
|
||||||
|
trigger: 'user',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(handle.runId).toBe('run-1');
|
||||||
|
expect(handle.signal.aborted).toBe(false);
|
||||||
|
expect(svc.isLocallyActive('run-1')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('beginRun REJECTS the racer: a 23505 on the one-active-per-chat index throws RunAlreadyActiveError (not swallowed) and registers no controller', async () => {
|
||||||
|
// The race: the controller's cheap pre-check passed for BOTH concurrent
|
||||||
|
// turns, so the loser's INSERT hits the partial unique index. That rejection
|
||||||
|
// is the authoritative gate — it must surface, not be swallowed into an
|
||||||
|
// untracked turn.
|
||||||
|
const repo = makeRepo({
|
||||||
|
insert: jest.fn(async () => {
|
||||||
|
throw uniqueViolation(ONE_ACTIVE_RUN_PER_CHAT_INDEX);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
await expect(
|
||||||
|
svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'user-1' }),
|
||||||
|
).rejects.toBeInstanceOf(RunAlreadyActiveError);
|
||||||
|
// No controller leaked for a rejected start.
|
||||||
|
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('beginRun does NOT mask an unrelated unique violation as already-active', async () => {
|
||||||
|
// A 23505 on some OTHER constraint is a real bug, not the race — it must
|
||||||
|
// propagate unchanged so it is never silently treated as "already active".
|
||||||
|
const other = uniqueViolation('ai_chat_runs_pkey');
|
||||||
|
const repo = makeRepo({
|
||||||
|
insert: jest.fn(async () => {
|
||||||
|
throw other;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
await expect(
|
||||||
|
svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'user-1' }),
|
||||||
|
).rejects.toBe(other);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('beginRun propagates a non-unique insert failure unchanged', async () => {
|
||||||
|
const boom = new Error('connection reset');
|
||||||
|
const repo = makeRepo({
|
||||||
|
insert: jest.fn(async () => {
|
||||||
|
throw boom;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
await expect(
|
||||||
|
svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'user-1' }),
|
||||||
|
).rejects.toBe(boom);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('two concurrent begins on one chat: exactly one wins, the other is rejected as already-active', async () => {
|
||||||
|
// Integration-style: model the DB partial unique index with a one-shot slot.
|
||||||
|
// The first insert claims it; the second hits a 23505 on the active index.
|
||||||
|
let slotTaken = false;
|
||||||
|
const repo = makeRepo({
|
||||||
|
insert: jest.fn(async (v: any) => {
|
||||||
|
if (slotTaken) throw uniqueViolation(ONE_ACTIVE_RUN_PER_CHAT_INDEX);
|
||||||
|
slotTaken = true;
|
||||||
|
return { id: 'run-win', status: v.status, chatId: v.chatId };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'user-1' }),
|
||||||
|
svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'user-1' }),
|
||||||
|
]);
|
||||||
|
const fulfilled = results.filter((r) => r.status === 'fulfilled');
|
||||||
|
const rejected = results.filter((r) => r.status === 'rejected');
|
||||||
|
expect(fulfilled).toHaveLength(1);
|
||||||
|
expect(rejected).toHaveLength(1);
|
||||||
|
expect((rejected[0] as PromiseRejectedResult).reason).toBeInstanceOf(
|
||||||
|
RunAlreadyActiveError,
|
||||||
|
);
|
||||||
|
// Exactly the winner is locally active.
|
||||||
|
expect(svc.isLocallyActive('run-win')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a SUBSCRIBER detaching does NOT abort the run (only an explicit stop does)', async () => {
|
||||||
|
const repo = makeRepo();
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
const handle = await svc.beginRun({
|
||||||
|
chatId: 'chat-1',
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
// Model a browser disconnect: nothing in the run service is told to stop.
|
||||||
|
// The signal the agent loop consumes must stay un-aborted and the run stays
|
||||||
|
// locally active — i.e. it keeps running server-side.
|
||||||
|
expect(handle.signal.aborted).toBe(false);
|
||||||
|
expect(svc.isLocallyActive('run-1')).toBe(true);
|
||||||
|
// markStopRequested was never called by a mere detach.
|
||||||
|
expect(repo.markStopRequested).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requestStop aborts the live controller, marks the row, and reports true', async () => {
|
||||||
|
const repo = makeRepo();
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
const handle = await svc.beginRun({
|
||||||
|
chatId: 'chat-1',
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
const aborted = jest.fn();
|
||||||
|
handle.signal.addEventListener('abort', aborted);
|
||||||
|
|
||||||
|
const result = await svc.requestStop('run-1', 'ws-1');
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(handle.signal.aborted).toBe(true);
|
||||||
|
expect(aborted).toHaveBeenCalledTimes(1);
|
||||||
|
expect(repo.markStopRequested).toHaveBeenCalledWith('run-1', 'ws-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requestStop on a run this replica does NOT hold still marks the row (true)', async () => {
|
||||||
|
// e.g. after a restart, or a sibling replica owns the controller. The row is
|
||||||
|
// marked so the owning replica/sweep settles it; we report a stop took effect.
|
||||||
|
const repo = makeRepo({
|
||||||
|
markStopRequested: jest.fn(async () => ({ id: 'run-9' })),
|
||||||
|
});
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
const result = await svc.requestStop('run-9', 'ws-1');
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(svc.isLocallyActive('run-9')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requestStop still aborts the live controller when markStopRequested rejects (transient DB error)', async () => {
|
||||||
|
// F15: the in-memory abort is the ONLY thing that stops a run and must not be
|
||||||
|
// hostage to the audit write of stop_requested_at. A transient failure on
|
||||||
|
// markStopRequested must NOT prevent abort() nor make requestStop throw.
|
||||||
|
const warnSpy = jest
|
||||||
|
.spyOn(Logger.prototype, 'warn')
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
const repo = makeRepo({
|
||||||
|
markStopRequested: jest.fn(async () => {
|
||||||
|
throw new Error('pool exhausted');
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
const handle = await svc.beginRun({
|
||||||
|
chatId: 'chat-1',
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
const aborted = jest.fn();
|
||||||
|
handle.signal.addEventListener('abort', aborted);
|
||||||
|
|
||||||
|
// Does NOT throw despite the DB write rejecting.
|
||||||
|
const result = await svc.requestStop('run-1', 'ws-1');
|
||||||
|
|
||||||
|
// The live turn was aborted even though the audit write failed...
|
||||||
|
expect(handle.signal.aborted).toBe(true);
|
||||||
|
expect(aborted).toHaveBeenCalledTimes(1);
|
||||||
|
expect(repo.markStopRequested).toHaveBeenCalledWith('run-1', 'ws-1');
|
||||||
|
// ...the catch branch logged the swallowed failure...
|
||||||
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||||
|
// ...and a stop is reported as having taken effect (the entry existed).
|
||||||
|
expect(result).toBe(true);
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requestStop on an already-settled run (nothing active) reports false', async () => {
|
||||||
|
const repo = makeRepo({
|
||||||
|
markStopRequested: jest.fn(async () => undefined),
|
||||||
|
});
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
const result = await svc.requestStop('run-done', 'ws-1');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finalizeRun settles the row to the mapped status with finishedAt and drops the in-memory entry', async () => {
|
||||||
|
const repo = makeRepo();
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
await svc.beginRun({
|
||||||
|
chatId: 'chat-1',
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
expect(svc.isLocallyActive('run-1')).toBe(true);
|
||||||
|
|
||||||
|
await svc.finalizeRun('run-1', 'ws-1', 'error', 'provider blew up');
|
||||||
|
|
||||||
|
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||||
|
expect(repo.update).toHaveBeenCalledWith(
|
||||||
|
'run-1',
|
||||||
|
'ws-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
status: 'failed',
|
||||||
|
error: 'provider blew up',
|
||||||
|
finishedAt: expect.any(Date),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finalizeRun is IDEMPOTENT: a second settle no-ops (single terminal write)', async () => {
|
||||||
|
// The #184 review fix: AiChatService.stream wraps the turn in a safety-net
|
||||||
|
// catch that settles a failed turn AND streamText's terminal callback may
|
||||||
|
// also settle — both routes call finalizeRun. Only the FIRST may write the
|
||||||
|
// terminal row; the second must no-op so a late settle can never clobber the
|
||||||
|
// real terminal status or double-write the row.
|
||||||
|
const repo = makeRepo();
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
await svc.beginRun({
|
||||||
|
chatId: 'chat-1',
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
await svc.finalizeRun('run-1', 'ws-1', 'error', 'first');
|
||||||
|
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||||
|
// A second settle (e.g. a streamText callback firing after the catch) no-ops.
|
||||||
|
await svc.finalizeRun('run-1', 'ws-1', 'completed', undefined);
|
||||||
|
|
||||||
|
expect(repo.update).toHaveBeenCalledTimes(1);
|
||||||
|
expect(repo.update).toHaveBeenCalledWith(
|
||||||
|
'run-1',
|
||||||
|
'ws-1',
|
||||||
|
expect.objectContaining({ status: 'failed', error: 'first' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CONCURRENCY: two simultaneous finalizeRun on the same run write the terminal row EXACTLY ONCE (the 2nd caller exits synchronously at the atomic claim)', async () => {
|
||||||
|
// The CRITICAL race: AiChatService.stream's safety-net catch settles the turn
|
||||||
|
// to 'error' while a streamText terminal callback also settles it — both call
|
||||||
|
// finalizeRun for the SAME runId. The once-gate must close ATOMICALLY: a
|
||||||
|
// `settled.has` check alone is read BEFORE the awaited UPDATE, so both callers
|
||||||
|
// would pass it and BOTH write the row (last-write-wins clobber + double
|
||||||
|
// write). The fix claims the run with a SYNCHRONOUS `active.delete` before any
|
||||||
|
// await, so the second caller returns in the same tick, before the UPDATE.
|
||||||
|
//
|
||||||
|
// We force the two calls to overlap by making `update` return a promise we
|
||||||
|
// resolve only AFTER both finalizeRun calls have run their synchronous bodies.
|
||||||
|
let resolveUpdate!: (v: unknown) => void;
|
||||||
|
const updateGate = new Promise((res) => {
|
||||||
|
resolveUpdate = res;
|
||||||
|
});
|
||||||
|
const update = jest.fn(() => updateGate);
|
||||||
|
const repo = makeRepo({ update });
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
await svc.beginRun({
|
||||||
|
chatId: 'chat-1',
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fire both before the (pending) update resolves. The first synchronously
|
||||||
|
// claims the entry (active.delete) and awaits update; the second, started in
|
||||||
|
// the same macrotask, finds the entry already gone and returns at the claim
|
||||||
|
// WITHOUT ever calling update.
|
||||||
|
const p1 = svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||||
|
const p2 = svc.finalizeRun('run-1', 'ws-1', 'error', 'safety-net');
|
||||||
|
|
||||||
|
// The decisive assertion: exactly one caller reached the terminal UPDATE.
|
||||||
|
expect(update).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Let the single in-flight update land; both calls resolve cleanly.
|
||||||
|
resolveUpdate({ id: 'run-1' });
|
||||||
|
await Promise.all([p1, p2]);
|
||||||
|
|
||||||
|
expect(update).toHaveBeenCalledTimes(1);
|
||||||
|
// The winner is the FIRST caller ('completed' -> 'succeeded'); the late
|
||||||
|
// 'error' settle never wrote, so it could not clobber the real status.
|
||||||
|
expect(update).toHaveBeenCalledWith(
|
||||||
|
'run-1',
|
||||||
|
'ws-1',
|
||||||
|
expect.objectContaining({ status: 'succeeded' }),
|
||||||
|
);
|
||||||
|
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('F6: a TRANSIENT terminal-write failure is ridden out by the bounded retry — the run is settled, not stranded', async () => {
|
||||||
|
// The bug: finalizeRun used to DROP the in-memory entry BEFORE the terminal
|
||||||
|
// UPDATE, then only warn-log a failure. A single transient blip (pool
|
||||||
|
// exhaustion / deadlock / connection hiccup) on that PK UPDATE left the row
|
||||||
|
// 'running' with nothing left to recover it -> every later turn in that chat
|
||||||
|
// 409s until a restart. The fix updates FIRST and retries.
|
||||||
|
let calls = 0;
|
||||||
|
const repo = makeRepo({
|
||||||
|
update: jest.fn(async () => {
|
||||||
|
calls += 1;
|
||||||
|
if (calls === 1) throw new Error('deadlock detected');
|
||||||
|
return { id: 'run-1' };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
await svc.beginRun({
|
||||||
|
chatId: 'chat-1',
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||||
|
|
||||||
|
// The retry landed the terminal write: the entry is dropped (slot freed) and
|
||||||
|
// the row carries the real terminal status — NOT stranded at 'running'.
|
||||||
|
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||||
|
expect(repo.update).toHaveBeenCalledTimes(2);
|
||||||
|
expect(repo.update).toHaveBeenLastCalledWith(
|
||||||
|
'run-1',
|
||||||
|
'ws-1',
|
||||||
|
expect.objectContaining({ status: 'succeeded' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('F6: if the terminal write keeps failing, the entry is RETAINED and a LATER settle completes it (chat not permanently 409d)', async () => {
|
||||||
|
// Worst case: the DB is down for the whole first finalize (all attempts fail).
|
||||||
|
// The run must NOT be silently lost — the entry stays so a subsequent settle
|
||||||
|
// (a streamText callback, requestStop -> onAbort, or a future sweep) can retry.
|
||||||
|
let healthy = false;
|
||||||
|
const repo = makeRepo({
|
||||||
|
update: jest.fn(async () => {
|
||||||
|
if (!healthy) throw new Error('pool exhausted');
|
||||||
|
return { id: 'run-1' };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
|
const errorSpy = jest
|
||||||
|
.spyOn(Logger.prototype, 'error')
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
await svc.beginRun({
|
||||||
|
chatId: 'chat-1',
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
// First settle: every bounded attempt fails -> entry retained, NOT settled.
|
||||||
|
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||||
|
expect(svc.isLocallyActive('run-1')).toBe(true);
|
||||||
|
// F12: the give-up emits ONE explicit, greppable ERROR (run + chat context)
|
||||||
|
// so an operator can tell "gave up, run held in memory" from a per-attempt
|
||||||
|
// blip — distinct from the per-attempt warns.
|
||||||
|
const gaveUp = errorSpy.mock.calls.some(
|
||||||
|
(c) =>
|
||||||
|
/NON-TERMINAL/.test(String(c[0])) &&
|
||||||
|
/run-1/.test(String(c[0])) &&
|
||||||
|
/chat-1/.test(String(c[0])),
|
||||||
|
);
|
||||||
|
expect(gaveUp).toBe(true);
|
||||||
|
|
||||||
|
// The DB recovers; a later settle now succeeds and frees the slot.
|
||||||
|
healthy = true;
|
||||||
|
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||||
|
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||||
|
expect(repo.update).toHaveBeenLastCalledWith(
|
||||||
|
'run-1',
|
||||||
|
'ws-1',
|
||||||
|
expect.objectContaining({ status: 'succeeded' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// And it is now idempotent: a further settle no-ops (terminal row already
|
||||||
|
// written), so a double-settle can never clobber the real status.
|
||||||
|
const callsBefore = repo.update.mock.calls.length;
|
||||||
|
await svc.finalizeRun('run-1', 'ws-1', 'error', 'late');
|
||||||
|
expect(repo.update).toHaveBeenCalledTimes(callsBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recordStep / linkAssistantMessage are best-effort: a repo failure is swallowed', async () => {
|
||||||
|
const repo = makeRepo({
|
||||||
|
update: jest.fn(async () => {
|
||||||
|
throw new Error('transient');
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
await expect(svc.recordStep('run-1', 'ws-1', 3)).resolves.toBeUndefined();
|
||||||
|
await expect(
|
||||||
|
svc.linkAssistantMessage('run-1', 'ws-1', 'msg-1'),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { AiChatRunRepo } from '@docmost/db/repos/ai-chat/ai-chat-run.repo';
|
||||||
|
import { AiChatRun } from '@docmost/db/types/entity.types';
|
||||||
|
import { isUniqueViolation, violatedConstraint } from '@docmost/db/utils';
|
||||||
|
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||||
|
|
||||||
|
/** Name of the partial unique index enforcing "one active run per chat" (see the
|
||||||
|
* ai_chat_runs migration). A 23505 on THIS constraint is the race-safe signal
|
||||||
|
* that a concurrent turn already owns the chat — distinct from any other unique
|
||||||
|
* collision, which must NOT be silently treated as "already active". */
|
||||||
|
export const ONE_ACTIVE_RUN_PER_CHAT_INDEX = 'ai_chat_runs_one_active_per_chat';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown by {@link AiChatRunService.beginRun} when the run-row INSERT loses the
|
||||||
|
* race for a chat's single active slot (the partial unique index rejects it with
|
||||||
|
* a 23505). This is the AUTHORITATIVE concurrency gate: the controller's cheap
|
||||||
|
* pre-check is only a fast-path, and a request that slips past it must NOT run
|
||||||
|
* untracked. The caller (AiChatService.stream) translates this into a 409 and
|
||||||
|
* aborts the turn BEFORE any AI/provider call.
|
||||||
|
*/
|
||||||
|
export class RunAlreadyActiveError extends Error {
|
||||||
|
constructor(public readonly chatId: string) {
|
||||||
|
super(`An agent run is already in progress for chat ${chatId}`);
|
||||||
|
this.name = 'RunAlreadyActiveError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The terminal status of a TURN (the #183 assistant-row lifecycle) maps onto the
|
||||||
|
* terminal status of a RUN (#184). A turn that completed -> the run succeeded; a
|
||||||
|
* turn that errored -> the run failed; a turn aborted (explicit user stop) -> the
|
||||||
|
* run aborted. Pure + unit-testable.
|
||||||
|
*/
|
||||||
|
export type TurnTerminalStatus = 'completed' | 'error' | 'aborted';
|
||||||
|
export type RunTerminalStatus = 'succeeded' | 'failed' | 'aborted';
|
||||||
|
|
||||||
|
export function mapTurnStatusToRun(
|
||||||
|
status: TurnTerminalStatus,
|
||||||
|
): RunTerminalStatus {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return 'succeeded';
|
||||||
|
case 'error':
|
||||||
|
return 'failed';
|
||||||
|
case 'aborted':
|
||||||
|
return 'aborted';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An in-flight run held in process memory: its AbortController is the ONLY thing
|
||||||
|
* that can stop the turn (an explicit user stop), independent of the browser
|
||||||
|
* socket. A mere disconnect never touches it, so the run keeps going. */
|
||||||
|
interface ActiveRun {
|
||||||
|
controller: AbortController;
|
||||||
|
chatId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The live handle the streaming path drives a run through (returned by
|
||||||
|
* {@link AiChatRunService.beginRun}). The `signal` governs the agent loop's
|
||||||
|
* abort — wired to the run, NOT to the HTTP socket. */
|
||||||
|
export interface RunHandle {
|
||||||
|
runId: string;
|
||||||
|
signal: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AiChatRunService (#184 phase 1) — owns the agent RUN as a first-class,
|
||||||
|
* server-side lifecycle object detached from the HTTP request / browser window.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - create a run row when a turn starts (inserted directly as 'running'; the
|
||||||
|
* 'pending' status is only the column default + a reserved value, never
|
||||||
|
* written by code in phase 1) and register an in-memory AbortController for it
|
||||||
|
* (the explicit-stop lever);
|
||||||
|
* - finalize the run row (succeeded / failed / aborted) and unregister it;
|
||||||
|
* - service an EXPLICIT user stop (`requestStop`) — the ONLY thing that aborts a
|
||||||
|
* run; a browser disconnect deliberately does NOT;
|
||||||
|
* - crash-recovery sweep of dangling runs on startup.
|
||||||
|
*
|
||||||
|
* The agent loop itself still runs in AiChatService.stream (reusing #183's
|
||||||
|
* step-granular durable write path, `consumeStream` already drains it independent
|
||||||
|
* of the socket); this service only wraps it in a durable lifecycle and an
|
||||||
|
* abort handle that outlives the subscriber.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AiChatRunService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(AiChatRunService.name);
|
||||||
|
|
||||||
|
// runId -> ActiveRun. Process-local on purpose (phase 1 is single-process /
|
||||||
|
// in-memory transport; a cross-process BullMQ runner + Redis stop-signal is
|
||||||
|
// deferred to phase 2). A stop for a runId not in this map (e.g. after a
|
||||||
|
// restart) still records `stop_requested_at` on the row.
|
||||||
|
private readonly active = new Map<string, ActiveRun>();
|
||||||
|
|
||||||
|
// runIds whose TERMINAL row write has SUCCEEDED — the idempotency once-gate
|
||||||
|
// (F6). A finalize must short-circuit only AFTER the terminal write has landed,
|
||||||
|
// NOT merely after the in-memory entry was dropped: a transient UPDATE failure
|
||||||
|
// has to stay retryable, so "already settled" means "row already terminal", not
|
||||||
|
// "entry already gone". Grows by one short UUID per finished run over process
|
||||||
|
// uptime — negligible in phase 1's single process.
|
||||||
|
private readonly settled = new Set<string>();
|
||||||
|
|
||||||
|
// Bounded retry for the terminal write (F6): a single PK UPDATE can fail
|
||||||
|
// transiently under many fire-and-forget writes (pool exhaustion, deadlock, a
|
||||||
|
// brief connection blip). Riding out that blip in-place matters because the
|
||||||
|
// dominant success path (streamText onFinish) settles exactly ONCE — if that
|
||||||
|
// write is dropped and never retried, the row is stranded 'running' and the
|
||||||
|
// one-active-run gate 409s every future turn in the chat until a restart (no
|
||||||
|
// periodic sweep in phase 1).
|
||||||
|
private static readonly FINALIZE_MAX_ATTEMPTS = 3;
|
||||||
|
private static readonly FINALIZE_RETRY_BASE_MS = 50;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly runRepo: AiChatRunRepo,
|
||||||
|
private readonly environment: EnvironmentService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crash-recovery sweep on server start: settle EVERY run still left
|
||||||
|
* pending/running to 'aborted' (F1 / DECISION C). The boot sweep is
|
||||||
|
* UNCONDITIONAL — no staleness window — because phase 1 is single-process: on a
|
||||||
|
* fresh boot any pending|running run is definitionally hung (no live runner owns
|
||||||
|
* it), so even a fast restart (deploy/OOM within minutes of the last step) can
|
||||||
|
* no longer leave a run stuck 'running' forever (which would make the
|
||||||
|
* one-active-run gate 409 every future turn in that chat). The staleness window
|
||||||
|
* is reintroduced only for the phase-2 multi-instance timer sweep, where a
|
||||||
|
* booting replica must not abort a run another replica is actively executing.
|
||||||
|
* Best-effort — a sweep failure is logged but MUST NOT block startup (mirrors
|
||||||
|
* AiChatService.onModuleInit for #183).
|
||||||
|
*/
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
this.warnIfMultiInstance();
|
||||||
|
try {
|
||||||
|
// No `staleMs`: unconditional boot sweep (F1). See AiChatRunRepo.sweepRunning.
|
||||||
|
const swept = await this.runRepo.sweepRunning();
|
||||||
|
if (swept > 0) {
|
||||||
|
this.logger.log(
|
||||||
|
`Startup sweep: marked ${swept} dangling agent run(s) as 'aborted'.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Startup sweep of dangling runs failed: ${
|
||||||
|
err instanceof Error ? err.message : 'unknown error'
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F2 (DECISION A): autonomous runs are SINGLE-INSTANCE-ONLY in phase 1. An
|
||||||
|
* explicit Stop, and the in-memory AbortController that backs it, are
|
||||||
|
* process-local: a Stop only aborts the live turn if it lands on the SAME
|
||||||
|
* replica that owns the run (it still stamps `stop_requested_at` cross-instance,
|
||||||
|
* but nothing reads that flag during an active run yet). Cross-instance pub/sub
|
||||||
|
* stop is phase 2. So if the deployment is horizontally scaled, warn loudly at
|
||||||
|
* startup that a Stop may not reach a run executing on another replica.
|
||||||
|
*
|
||||||
|
* DETECTION: this codebase always wires the socket.io Redis adapter (REDIS_URL
|
||||||
|
* is mandatory), so the adapter alone is NOT a horizontal-scaling signal. The
|
||||||
|
* authoritative signal the codebase has is `CLOUD=true` (EnvironmentService
|
||||||
|
* .isCloud()), the Docmost-cloud multi-replica deployment. We warn whenever that
|
||||||
|
* is set, because any workspace could enable settings.ai.autonomousRuns. A
|
||||||
|
* self-hosted operator running multiple replicas behind a load balancer is also
|
||||||
|
* multi-instance; the deploy docs (.env.example / AGENTS.md) spell out the
|
||||||
|
* single-instance constraint for that case.
|
||||||
|
*/
|
||||||
|
private warnIfMultiInstance(): void {
|
||||||
|
if (this.environment.isCloud()) {
|
||||||
|
this.logger.warn(
|
||||||
|
'Autonomous agent runs (settings.ai.autonomousRuns) are SINGLE-INSTANCE-ONLY ' +
|
||||||
|
'in phase 1: a horizontally-scaled deployment was detected (CLOUD=true). ' +
|
||||||
|
'An explicit Stop only aborts a run executing on the same replica that owns ' +
|
||||||
|
'it (cross-instance Stop is not yet reliable — phase 2). Run a single ' +
|
||||||
|
'instance if you enable autonomousRuns, or keep the flag off.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a run for a turn: insert the run row (status 'running', startedAt now),
|
||||||
|
* register a fresh AbortController for it, and return a {@link RunHandle} whose
|
||||||
|
* `signal` the agent loop uses. The DB partial unique index guarantees at most
|
||||||
|
* one active run per chat — a second concurrent start on the same chat REJECTS
|
||||||
|
* at the insert (a 23505 on {@link ONE_ACTIVE_RUN_PER_CHAT_INDEX}). That
|
||||||
|
* rejection is the AUTHORITATIVE race gate: it is surfaced as a distinct
|
||||||
|
* {@link RunAlreadyActiveError} (NOT swallowed), so the caller turns it into a
|
||||||
|
* 409 and never streams an untracked turn. The controller is registered AFTER a
|
||||||
|
* successful insert so a rejected start leaks nothing.
|
||||||
|
*/
|
||||||
|
async beginRun(args: {
|
||||||
|
chatId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
userId: string;
|
||||||
|
trigger?: string;
|
||||||
|
}): Promise<RunHandle> {
|
||||||
|
let run: AiChatRun;
|
||||||
|
try {
|
||||||
|
run = await this.runRepo.insert({
|
||||||
|
chatId: args.chatId,
|
||||||
|
workspaceId: args.workspaceId,
|
||||||
|
createdBy: args.userId,
|
||||||
|
trigger: args.trigger ?? 'user',
|
||||||
|
status: 'running',
|
||||||
|
startedAt: new Date(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// The race backstop: a concurrent turn already holds this chat's single
|
||||||
|
// active slot, so the partial unique index rejected our insert. Surface a
|
||||||
|
// distinct signal — the caller MUST reject this turn (409), not run it
|
||||||
|
// untracked. Any OTHER error propagates unchanged.
|
||||||
|
if (
|
||||||
|
isUniqueViolation(err) &&
|
||||||
|
violatedConstraint(err) === ONE_ACTIVE_RUN_PER_CHAT_INDEX
|
||||||
|
) {
|
||||||
|
throw new RunAlreadyActiveError(args.chatId);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
this.active.set(run.id, {
|
||||||
|
controller,
|
||||||
|
chatId: args.chatId,
|
||||||
|
workspaceId: args.workspaceId,
|
||||||
|
});
|
||||||
|
return { runId: run.id, signal: controller.signal };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Link the assistant message (the #183 projection) to its run. Best-effort. */
|
||||||
|
async linkAssistantMessage(
|
||||||
|
runId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
assistantMessageId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.runRepo.update(runId, workspaceId, { assistantMessageId });
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to link assistant message to run ${runId}: ${
|
||||||
|
err instanceof Error ? err.message : 'unknown error'
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist progress: bump the run's finished-step count. Best-effort (never
|
||||||
|
* blocks or breaks the stream). */
|
||||||
|
async recordStep(
|
||||||
|
runId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
stepCount: number,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.runRepo.update(runId, workspaceId, { stepCount });
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to record step for run ${runId}: ${
|
||||||
|
err instanceof Error ? err.message : 'unknown error'
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalize a run to its terminal status (succeeded / failed / aborted),
|
||||||
|
* stamping finishedAt + any error. Best-effort, but ROBUST against a transient
|
||||||
|
* terminal-write failure (F6) AND atomically safe against a concurrent settle.
|
||||||
|
*
|
||||||
|
* ATOMIC ONCE-CLAIM (the gate must close in ONE synchronous tick): two
|
||||||
|
* finalizeRun calls for the SAME run can race — the documented real path is
|
||||||
|
* AiChatService.stream's safety-net catch settling the turn to 'error' while a
|
||||||
|
* streamText terminal callback (onFinish/onAbort/onError) ALSO settles it. The
|
||||||
|
* `settled.has` check alone is NOT a gate: it is read BEFORE the awaited UPDATE,
|
||||||
|
* so two callers can both see `false` and both write the row (last-write-wins
|
||||||
|
* clobbers the real terminal status, and the bounded retry only widens that
|
||||||
|
* window). The claim therefore happens via `active.delete`, a SYNCHRONOUS
|
||||||
|
* check-and-clear with NO await between the gate and the entry removal: the
|
||||||
|
* second concurrent caller finds the entry already gone and returns in the same
|
||||||
|
* tick, before any UPDATE. The transition "nobody is finalizing" -> "I am
|
||||||
|
* finalizing" is thus a single atomic step.
|
||||||
|
*
|
||||||
|
* ORDER MATTERS (F6): once we own the claim, the terminal UPDATE happens FIRST;
|
||||||
|
* only once it SUCCEEDS do we record the run as settled. If the UPDATE fails on
|
||||||
|
* every bounded attempt we RESTORE the in-memory entry, leave the run UNsettled,
|
||||||
|
* and emit an ERROR signal that the row is left non-terminal 'running' (which
|
||||||
|
* would 409 every future turn in the chat until recovery). An in-process retry
|
||||||
|
* by a LATER settle is only POSSIBLE, never guaranteed: it needs (a) the entry
|
||||||
|
* to have been restored at the give-up path AND (b) a fresh settler to arrive
|
||||||
|
* AFTER that restore. A concurrent settler that arrives DURING the retry window
|
||||||
|
* — while the entry is deleted for backoff and not yet restored — is consumed at
|
||||||
|
* the synchronous `active.delete` claim (it finds nothing to delete and returns
|
||||||
|
* a no-op), so it does NOT become an in-process retrier. The NO-streamText path
|
||||||
|
* (the turn threw before streamText was wired, so ONLY the safety-net ever
|
||||||
|
* settles) likewise has no second in-process settler at all. The UNCONDITIONAL
|
||||||
|
* backstop in every case is the boot sweep on the next restart (phase 1 has no
|
||||||
|
* periodic in-process sweep); the retained entry is bounded (cleared on restart)
|
||||||
|
* and harmless meanwhile.
|
||||||
|
*
|
||||||
|
* IDEMPOTENT on SUCCESS (#184 review): the terminal write happens AT MOST ONCE
|
||||||
|
* per run. After a successful write the once-gate keys off {@link settled} (the
|
||||||
|
* terminal row already written) so a settle arriving AFTER the entry was already
|
||||||
|
* dropped-and-settled returns early; a settle racing the in-flight write is
|
||||||
|
* stopped earlier still, by the `active.delete` claim. Either way a genuine
|
||||||
|
* double-settle collapses to a single write and a late settle can never clobber
|
||||||
|
* the real terminal status or double-write the row.
|
||||||
|
*/
|
||||||
|
async finalizeRun(
|
||||||
|
runId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
turnStatus: TurnTerminalStatus,
|
||||||
|
error?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
// ---- Atomic once-claim (synchronous; NO await before the gate closes) ----
|
||||||
|
// Already terminally written -> idempotent no-op.
|
||||||
|
if (this.settled.has(runId)) return;
|
||||||
|
// Capture the entry BEFORE the delete so a total-failure path can restore it.
|
||||||
|
const entry = this.active.get(runId);
|
||||||
|
// SYNCHRONOUS check-and-clear: the FIRST caller deletes (claims) the entry;
|
||||||
|
// any concurrent SECOND caller finds nothing to delete and returns HERE, in
|
||||||
|
// the same tick, before any await — so it can never reach the UPDATE.
|
||||||
|
if (!this.active.delete(runId)) return;
|
||||||
|
|
||||||
|
let lastError: unknown;
|
||||||
|
for (
|
||||||
|
let attempt = 1;
|
||||||
|
attempt <= AiChatRunService.FINALIZE_MAX_ATTEMPTS;
|
||||||
|
attempt++
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await this.runRepo.update(runId, workspaceId, {
|
||||||
|
status: mapTurnStatusToRun(turnStatus),
|
||||||
|
finishedAt: new Date(),
|
||||||
|
error: error ?? null,
|
||||||
|
});
|
||||||
|
// Terminal write landed: arm the once-gate. The entry is already gone
|
||||||
|
// (claimed above); we do NOT restore it. The slot is now free.
|
||||||
|
this.settled.add(runId);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err;
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to finalize run ${runId} (attempt ${attempt}/${
|
||||||
|
AiChatRunService.FINALIZE_MAX_ATTEMPTS
|
||||||
|
}): ${err instanceof Error ? err.message : 'unknown error'}`,
|
||||||
|
);
|
||||||
|
if (attempt < AiChatRunService.FINALIZE_MAX_ATTEMPTS) {
|
||||||
|
await this.delay(AiChatRunService.FINALIZE_RETRY_BASE_MS * attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Every attempt failed: this is a give-up, materially worse than a per-attempt
|
||||||
|
// blip — the row is left NON-TERMINAL ('running'), so emit ONE explicit,
|
||||||
|
// greppable ERROR so an operator can tell "survived a blip" from "gave up, run
|
||||||
|
// held in memory until recovery" (the last warn alone says only "attempt 3/3").
|
||||||
|
this.logger.error(
|
||||||
|
`Run ${runId} (chat ${entry?.chatId ?? 'unknown'}) left NON-TERMINAL ` +
|
||||||
|
`('running'): terminal write failed after ${
|
||||||
|
AiChatRunService.FINALIZE_MAX_ATTEMPTS
|
||||||
|
} attempts; entry retained in memory, recovery deferred to next settle / ` +
|
||||||
|
`boot sweep`,
|
||||||
|
lastError,
|
||||||
|
);
|
||||||
|
// RESTORE the claimed entry (and leave the run UNsettled) so a LATER settle
|
||||||
|
// that arrives AFTER this restore MAY retry the terminal write — but that
|
||||||
|
// in-process retry is NOT guaranteed (a concurrent settler caught in the retry
|
||||||
|
// window above is consumed at the `active.delete` claim, and the no-streamText
|
||||||
|
// path has no second settler at all). The UNCONDITIONAL backstop in every case
|
||||||
|
// is the boot sweep on the next restart; the restored entry is bounded and
|
||||||
|
// cleared on restart.
|
||||||
|
if (entry) this.active.set(runId, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Small async backoff between terminal-write retries (F6). Isolated so it is
|
||||||
|
* trivial to stub/fake-time in tests. */
|
||||||
|
private delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request an EXPLICIT stop of a run (the user pressed Stop). This is the ONLY
|
||||||
|
* thing that aborts a run — distinct from a browser disconnect, which leaves
|
||||||
|
* the run going. Aborts the in-process controller FIRST (the only thing that
|
||||||
|
* actually stops the run, if this replica owns it), then makes a best-effort
|
||||||
|
* attempt to stamp `stop_requested_at` — that audit write stamps only while the
|
||||||
|
* row is active and may be skipped on a DB error or lost to the finalize race,
|
||||||
|
* which is acceptable since the row still settles as 'aborted'. Returns true
|
||||||
|
* when a stop took effect (row marked and/or controller aborted), false when
|
||||||
|
* there was nothing active to stop.
|
||||||
|
*/
|
||||||
|
async requestStop(runId: string, workspaceId: string): Promise<boolean> {
|
||||||
|
const entry = this.active.get(runId);
|
||||||
|
if (entry) {
|
||||||
|
// Abort the live turn FIRST -> streamText onAbort fires -> the partial is
|
||||||
|
// persisted (#183) and finalizeRun settles the row as 'aborted'. This is
|
||||||
|
// the ONLY thing that aborts a run, so it MUST NOT be hostage to the audit
|
||||||
|
// write below: a transient failure on `markStopRequested` (pool exhaustion,
|
||||||
|
// deadlock, dropped connection) must never leave the run executing despite
|
||||||
|
// an explicit Stop. At worst only the `stop_requested_at` timestamp is lost.
|
||||||
|
entry.controller.abort();
|
||||||
|
}
|
||||||
|
// Record `stop_requested_at` (best-effort). A transient DB failure here is
|
||||||
|
// logged and treated as `marked = false`; the abort above already took
|
||||||
|
// effect, so we never rethrow and skip stopping the run. Note: because
|
||||||
|
// markStopRequested only stamps while the row is active, aborting first means
|
||||||
|
// even a healthy write can lose the race against the resulting finalize and
|
||||||
|
// skip the stamp — acceptable, as the row still settles as 'aborted' and only
|
||||||
|
// this audit timestamp may be lost.
|
||||||
|
let marked: unknown;
|
||||||
|
try {
|
||||||
|
marked = await this.runRepo.markStopRequested(runId, workspaceId);
|
||||||
|
} catch (err) {
|
||||||
|
marked = undefined;
|
||||||
|
this.logger.warn(
|
||||||
|
`requestStop: markStopRequested failed for run ${runId} ` +
|
||||||
|
`(stop_requested_at not recorded); abort already issued: ` +
|
||||||
|
`${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Boolean(marked) || Boolean(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Latest persisted run for a chat — the reconnect target (an in-flight or
|
||||||
|
* finished run). Pure read-through to the repo. */
|
||||||
|
getLatestForChat(
|
||||||
|
chatId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<AiChatRun | undefined> {
|
||||||
|
return this.runRepo.findLatestByChat(chatId, workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch a run by id (workspace-scoped). Used to resolve + ownership-check an
|
||||||
|
* explicit stop targeting a runId. */
|
||||||
|
getRun(runId: string, workspaceId: string): Promise<AiChatRun | undefined> {
|
||||||
|
return this.runRepo.findById(runId, workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The active run on a chat, if any (used to reject a concurrent start with a
|
||||||
|
* clean 409 before committing to the stream). */
|
||||||
|
getActiveForChat(
|
||||||
|
chatId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<AiChatRun | undefined> {
|
||||||
|
return this.runRepo.findActiveByChat(chatId, workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test/diagnostic seam: whether this replica is holding a live controller for
|
||||||
|
* the run. */
|
||||||
|
isLocallyActive(runId: string): boolean {
|
||||||
|
return this.active.has(runId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ describe('AiChatController.boundChat', () => {
|
|||||||
};
|
};
|
||||||
const controller = new AiChatController(
|
const controller = new AiChatController(
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never, // aiChatRunService
|
||||||
aiChatRepo as never,
|
aiChatRepo as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ describe('AiChatController.export', () => {
|
|||||||
};
|
};
|
||||||
const controller = new AiChatController(
|
const controller = new AiChatController(
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never, // aiChatRunService
|
||||||
aiChatRepo as never,
|
aiChatRepo as never,
|
||||||
aiChatMessageRepo as never,
|
aiChatMessageRepo as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { AiChatController } from './ai-chat.controller';
|
||||||
|
import type { User, Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wiring spec for the #184 run-reconnect / run-stop endpoints
|
||||||
|
* (`POST /ai-chat/run` and `POST /ai-chat/stop`). Both are OWNER-gated via
|
||||||
|
* assertOwnedChat (the requesting user must own the chat) and NOT flag-gated.
|
||||||
|
* Exercised with hand-rolled mocks — no Nest graph, no DB. The controller's
|
||||||
|
* constructor order is (aiChatService, aiChatRunService, aiChatRepo,
|
||||||
|
* aiChatMessageRepo, aiTranscription).
|
||||||
|
*/
|
||||||
|
describe('AiChatController run endpoints (#184)', () => {
|
||||||
|
const user = { id: 'u1' } as User;
|
||||||
|
const workspace = { id: 'ws1' } as Workspace;
|
||||||
|
|
||||||
|
function makeController(opts: {
|
||||||
|
chat?: unknown; // what aiChatRepo.findById returns (owner-gate)
|
||||||
|
run?: unknown; // getLatestForChat / getRun result
|
||||||
|
activeRun?: unknown; // getActiveForChat result
|
||||||
|
message?: unknown; // aiChatMessageRepo.findById result
|
||||||
|
stopped?: boolean; // requestStop result
|
||||||
|
}) {
|
||||||
|
const aiChatRunService = {
|
||||||
|
getLatestForChat: jest.fn().mockResolvedValue(opts.run),
|
||||||
|
getRun: jest.fn().mockResolvedValue(opts.run),
|
||||||
|
getActiveForChat: jest.fn().mockResolvedValue(opts.activeRun),
|
||||||
|
requestStop: jest.fn().mockResolvedValue(opts.stopped ?? false),
|
||||||
|
};
|
||||||
|
const aiChatRepo = {
|
||||||
|
findById: jest.fn().mockResolvedValue(opts.chat),
|
||||||
|
};
|
||||||
|
const aiChatMessageRepo = {
|
||||||
|
findById: jest.fn().mockResolvedValue(opts.message),
|
||||||
|
};
|
||||||
|
const controller = new AiChatController(
|
||||||
|
{} as never, // aiChatService
|
||||||
|
aiChatRunService as never,
|
||||||
|
aiChatRepo as never,
|
||||||
|
aiChatMessageRepo as never,
|
||||||
|
{} as never, // aiTranscription
|
||||||
|
{} as never, // pageRepo
|
||||||
|
);
|
||||||
|
return { controller, aiChatRunService, aiChatRepo, aiChatMessageRepo };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /ai-chat/run (getRun)', () => {
|
||||||
|
it('owner-gates: a chat the user does not own throws ForbiddenException', async () => {
|
||||||
|
const { controller, aiChatRunService } = makeController({
|
||||||
|
chat: { id: 'c1', creatorId: 'someone-else' },
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
controller.getRun({ chatId: 'c1' }, user, workspace),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
// It must NOT reach the run lookup once the owner-gate fails.
|
||||||
|
expect(aiChatRunService.getLatestForChat).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns { run: null, message: null } when the chat has never had a run', async () => {
|
||||||
|
const { controller, aiChatRunService } = makeController({
|
||||||
|
chat: { id: 'c1', creatorId: 'u1' },
|
||||||
|
run: undefined,
|
||||||
|
});
|
||||||
|
const res = await controller.getRun({ chatId: 'c1' }, user, workspace);
|
||||||
|
expect(res).toEqual({ run: null, message: null });
|
||||||
|
expect(aiChatRunService.getLatestForChat).toHaveBeenCalledWith(
|
||||||
|
'c1',
|
||||||
|
'ws1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the run and its projected assistant message', async () => {
|
||||||
|
const run = { id: 'run-1', chatId: 'c1', assistantMessageId: 'm1' };
|
||||||
|
const message = { id: 'm1', role: 'assistant' };
|
||||||
|
const { controller, aiChatMessageRepo } = makeController({
|
||||||
|
chat: { id: 'c1', creatorId: 'u1' },
|
||||||
|
run,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
const res = await controller.getRun({ chatId: 'c1' }, user, workspace);
|
||||||
|
expect(res).toEqual({ run, message });
|
||||||
|
expect(aiChatMessageRepo.findById).toHaveBeenCalledWith('m1', 'ws1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns message: null when the run has no linked assistant message', async () => {
|
||||||
|
const run = { id: 'run-1', chatId: 'c1', assistantMessageId: null };
|
||||||
|
const { controller, aiChatMessageRepo } = makeController({
|
||||||
|
chat: { id: 'c1', creatorId: 'u1' },
|
||||||
|
run,
|
||||||
|
});
|
||||||
|
const res = await controller.getRun({ chatId: 'c1' }, user, workspace);
|
||||||
|
expect(res).toEqual({ run, message: null });
|
||||||
|
expect(aiChatMessageRepo.findById).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /ai-chat/stop (stopRun)', () => {
|
||||||
|
it('throws BadRequestException when neither runId nor chatId is given', async () => {
|
||||||
|
const { controller } = makeController({});
|
||||||
|
await expect(
|
||||||
|
controller.stopRun({}, user, workspace),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops by runId: owner-gates via the run’s chat, then requests the stop', async () => {
|
||||||
|
const { controller, aiChatRunService, aiChatRepo } = makeController({
|
||||||
|
run: { id: 'run-1', chatId: 'c1' },
|
||||||
|
chat: { id: 'c1', creatorId: 'u1' },
|
||||||
|
stopped: true,
|
||||||
|
});
|
||||||
|
const res = await controller.stopRun({ runId: 'run-1' }, user, workspace);
|
||||||
|
expect(res).toEqual({ stopped: true });
|
||||||
|
expect(aiChatRunService.getRun).toHaveBeenCalledWith('run-1', 'ws1');
|
||||||
|
expect(aiChatRepo.findById).toHaveBeenCalledWith('c1', 'ws1');
|
||||||
|
expect(aiChatRunService.requestStop).toHaveBeenCalledWith('run-1', 'ws1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops by runId: a foreign run’s chat throws ForbiddenException (no stop)', async () => {
|
||||||
|
const { controller, aiChatRunService } = makeController({
|
||||||
|
run: { id: 'run-1', chatId: 'c1' },
|
||||||
|
chat: { id: 'c1', creatorId: 'someone-else' },
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
controller.stopRun({ runId: 'run-1' }, user, workspace),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(aiChatRunService.requestStop).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops by runId: an unknown run reports { stopped: false }', async () => {
|
||||||
|
const { controller, aiChatRunService } = makeController({
|
||||||
|
run: undefined,
|
||||||
|
});
|
||||||
|
const res = await controller.stopRun({ runId: 'gone' }, user, workspace);
|
||||||
|
expect(res).toEqual({ stopped: false });
|
||||||
|
expect(aiChatRunService.requestStop).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops by chatId: owner-gates, resolves the active run, requests the stop', async () => {
|
||||||
|
const { controller, aiChatRunService, aiChatRepo } = makeController({
|
||||||
|
chat: { id: 'c1', creatorId: 'u1' },
|
||||||
|
activeRun: { id: 'run-9' },
|
||||||
|
stopped: true,
|
||||||
|
});
|
||||||
|
const res = await controller.stopRun({ chatId: 'c1' }, user, workspace);
|
||||||
|
expect(res).toEqual({ stopped: true });
|
||||||
|
expect(aiChatRepo.findById).toHaveBeenCalledWith('c1', 'ws1');
|
||||||
|
expect(aiChatRunService.getActiveForChat).toHaveBeenCalledWith(
|
||||||
|
'c1',
|
||||||
|
'ws1',
|
||||||
|
);
|
||||||
|
expect(aiChatRunService.requestStop).toHaveBeenCalledWith('run-9', 'ws1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops by chatId: reports { stopped: false } when no run is active', async () => {
|
||||||
|
const { controller, aiChatRunService } = makeController({
|
||||||
|
chat: { id: 'c1', creatorId: 'u1' },
|
||||||
|
activeRun: undefined,
|
||||||
|
});
|
||||||
|
const res = await controller.stopRun({ chatId: 'c1' }, user, workspace);
|
||||||
|
expect(res).toEqual({ stopped: false });
|
||||||
|
expect(aiChatRunService.requestStop).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
|
ConflictException,
|
||||||
Controller,
|
Controller,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
@@ -20,7 +21,13 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
|||||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||||
import { SkipTransform } from '../../common/decorators/skip-transform.decorator';
|
import { SkipTransform } from '../../common/decorators/skip-transform.decorator';
|
||||||
import { AiChat, User, Workspace } from '@docmost/db/types/entity.types';
|
import {
|
||||||
|
AiChat,
|
||||||
|
AiChatMessage,
|
||||||
|
AiChatRun,
|
||||||
|
User,
|
||||||
|
Workspace,
|
||||||
|
} from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
||||||
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||||
@@ -28,7 +35,12 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
|||||||
import { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard';
|
import { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard';
|
||||||
import { AI_CHAT_THROTTLER } from '../../integrations/throttle/throttler-names';
|
import { AI_CHAT_THROTTLER } from '../../integrations/throttle/throttler-names';
|
||||||
import { FileInterceptor } from '../../common/interceptors/file.interceptor';
|
import { FileInterceptor } from '../../common/interceptors/file.interceptor';
|
||||||
import { AiChatService, AiChatStreamBody } from './ai-chat.service';
|
import {
|
||||||
|
AiChatRunHooks,
|
||||||
|
AiChatService,
|
||||||
|
AiChatStreamBody,
|
||||||
|
} from './ai-chat.service';
|
||||||
|
import { AiChatRunService } from './ai-chat-run.service';
|
||||||
import { AiTranscriptionService } from './ai-transcription.service';
|
import { AiTranscriptionService } from './ai-transcription.service';
|
||||||
import {
|
import {
|
||||||
BoundChatDto,
|
BoundChatDto,
|
||||||
@@ -36,7 +48,9 @@ import {
|
|||||||
ExportChatDto,
|
ExportChatDto,
|
||||||
GeneratePageTitleDto,
|
GeneratePageTitleDto,
|
||||||
GetChatMessagesDto,
|
GetChatMessagesDto,
|
||||||
|
GetRunDto,
|
||||||
RenameChatDto,
|
RenameChatDto,
|
||||||
|
StopRunDto,
|
||||||
} from './dto/ai-chat.dto';
|
} from './dto/ai-chat.dto';
|
||||||
import { describeProviderError } from '../../integrations/ai/ai-error.util';
|
import { describeProviderError } from '../../integrations/ai/ai-error.util';
|
||||||
import { buildChatMarkdown } from './chat-markdown.util';
|
import { buildChatMarkdown } from './chat-markdown.util';
|
||||||
@@ -53,6 +67,7 @@ export class AiChatController {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly aiChatService: AiChatService,
|
private readonly aiChatService: AiChatService,
|
||||||
|
private readonly aiChatRunService: AiChatRunService,
|
||||||
private readonly aiChatRepo: AiChatRepo,
|
private readonly aiChatRepo: AiChatRepo,
|
||||||
private readonly aiChatMessageRepo: AiChatMessageRepo,
|
private readonly aiChatMessageRepo: AiChatMessageRepo,
|
||||||
private readonly aiTranscription: AiTranscriptionService,
|
private readonly aiTranscription: AiTranscriptionService,
|
||||||
@@ -149,6 +164,75 @@ export class AiChatController {
|
|||||||
return { markdown };
|
return { markdown };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconnect to the latest run of a chat (#184 phase 1). Returns the run's
|
||||||
|
* persisted lifecycle state ({ status, error, stepCount, timings, ... }) plus
|
||||||
|
* the assistant message it projects (the partial/final output) — the DB is the
|
||||||
|
* source of truth, so this works for an in-flight run (the browser dropped, the
|
||||||
|
* run kept going) and a finished one alike. Owner-gated via assertOwnedChat.
|
||||||
|
* `{ run: null }` when the chat has never had a run.
|
||||||
|
*/
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('run')
|
||||||
|
async getRun(
|
||||||
|
@Body() dto: GetRunDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
): Promise<{ run: AiChatRun | null; message: AiChatMessage | null }> {
|
||||||
|
await this.assertOwnedChat(dto.chatId, user, workspace);
|
||||||
|
const run = await this.aiChatRunService.getLatestForChat(
|
||||||
|
dto.chatId,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
if (!run) return { run: null, message: null };
|
||||||
|
const message = run.assistantMessageId
|
||||||
|
? await this.aiChatMessageRepo.findById(
|
||||||
|
run.assistantMessageId,
|
||||||
|
workspace.id,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
return { run, message: message ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicitly STOP an agent run (#184 phase 1) — the user pressed Stop. This is
|
||||||
|
* the ONLY thing that ends a detached run; a browser disconnect deliberately
|
||||||
|
* does not. Target by `runId` (from the streamed start metadata) or by `chatId`
|
||||||
|
* (stop whatever run is active on it). Owner-gated. Returns
|
||||||
|
* `{ stopped }` — false when there was nothing active to stop.
|
||||||
|
*/
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('stop')
|
||||||
|
async stopRun(
|
||||||
|
@Body() dto: StopRunDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
): Promise<{ stopped: boolean }> {
|
||||||
|
let runId = dto.runId;
|
||||||
|
if (!runId && !dto.chatId) {
|
||||||
|
throw new BadRequestException('runId or chatId is required');
|
||||||
|
}
|
||||||
|
if (runId) {
|
||||||
|
// Resolve the run to its chat and owner-gate via that chat.
|
||||||
|
const run = await this.aiChatRunService.getRun(runId, workspace.id);
|
||||||
|
if (!run) return { stopped: false };
|
||||||
|
await this.assertOwnedChat(run.chatId, user, workspace);
|
||||||
|
} else {
|
||||||
|
await this.assertOwnedChat(dto.chatId!, user, workspace);
|
||||||
|
const active = await this.aiChatRunService.getActiveForChat(
|
||||||
|
dto.chatId!,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
if (!active) return { stopped: false };
|
||||||
|
runId = active.id;
|
||||||
|
}
|
||||||
|
const stopped = await this.aiChatRunService.requestStop(
|
||||||
|
runId,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
return { stopped };
|
||||||
|
}
|
||||||
|
|
||||||
/** Rename a chat. */
|
/** Rename a chat. */
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('rename')
|
@Post('rename')
|
||||||
@@ -200,11 +284,20 @@ export class AiChatController {
|
|||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// A7 gate: the workspace must have AI chat explicitly enabled.
|
// A7 gate: the workspace must have AI chat explicitly enabled.
|
||||||
const settings = (workspace.settings ?? {}) as { ai?: { chat?: boolean } };
|
const settings = (workspace.settings ?? {}) as {
|
||||||
|
ai?: { chat?: boolean; autonomousRuns?: boolean };
|
||||||
|
};
|
||||||
if (settings.ai?.chat !== true) {
|
if (settings.ai?.chat !== true) {
|
||||||
throw new ForbiddenException('AI chat is disabled');
|
throw new ForbiddenException('AI chat is disabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #184 phase 1 flag: when ON, the turn becomes a detached, durable RUN — its
|
||||||
|
// lifecycle is tracked in ai_chat_runs, a browser disconnect no longer aborts
|
||||||
|
// it, and only an explicit /ai-chat/stop ends it. When OFF (the default) the
|
||||||
|
// turn is socket-bound exactly as before, so existing deployments are
|
||||||
|
// unaffected.
|
||||||
|
const autonomousRuns = settings.ai?.autonomousRuns === true;
|
||||||
|
|
||||||
const sessionId = (req.raw as { sessionId?: string }).sessionId;
|
const sessionId = (req.raw as { sessionId?: string }).sessionId;
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
// The chat requires an interactive session to mint loopback tokens
|
// The chat requires an interactive session to mint loopback tokens
|
||||||
@@ -228,6 +321,58 @@ export class AiChatController {
|
|||||||
// HttpException) instead of breaking mid-stream.
|
// HttpException) instead of breaking mid-stream.
|
||||||
const model = await this.aiChatService.getChatModel(workspace.id, role);
|
const model = await this.aiChatService.getChatModel(workspace.id, role);
|
||||||
|
|
||||||
|
// #184: one active run per chat. For an EXISTING chat reject a concurrent
|
||||||
|
// start with a clean 409 BEFORE hijack (the common double-submit / second-tab
|
||||||
|
// case), so the user gets JSON, not a mid-stream error. A brand-new chat
|
||||||
|
// (no chatId) cannot have a prior run, and the DB partial unique index is the
|
||||||
|
// backstop against any race that slips past this check.
|
||||||
|
if (autonomousRuns && body.chatId) {
|
||||||
|
const active = await this.aiChatRunService.getActiveForChat(
|
||||||
|
body.chatId,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
if (active) {
|
||||||
|
throw new ConflictException({
|
||||||
|
message: 'An agent run is already in progress for this chat',
|
||||||
|
code: 'A_RUN_ALREADY_ACTIVE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run-lifecycle hooks (#184), only when the flag is on. They wrap the turn in
|
||||||
|
// a durable run whose abort is governed by the run (explicit stop), persist
|
||||||
|
// its progress, and settle its terminal status — see AiChatRunService.
|
||||||
|
const runHooks: AiChatRunHooks | undefined = autonomousRuns
|
||||||
|
? {
|
||||||
|
begin: (chatId) =>
|
||||||
|
this.aiChatRunService.beginRun({
|
||||||
|
chatId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
userId: user.id,
|
||||||
|
trigger: 'user',
|
||||||
|
}),
|
||||||
|
onAssistantSeeded: (runId, messageId) =>
|
||||||
|
this.aiChatRunService.linkAssistantMessage(
|
||||||
|
runId,
|
||||||
|
workspace.id,
|
||||||
|
messageId,
|
||||||
|
),
|
||||||
|
onStep: (runId, stepCount) =>
|
||||||
|
void this.aiChatRunService.recordStep(
|
||||||
|
runId,
|
||||||
|
workspace.id,
|
||||||
|
stepCount,
|
||||||
|
),
|
||||||
|
onSettled: (runId, status, error) =>
|
||||||
|
this.aiChatRunService.finalizeRun(
|
||||||
|
runId,
|
||||||
|
workspace.id,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// Abort the agent loop when the client disconnects. `close` also fires on
|
// Abort the agent loop when the client disconnects. `close` also fires on
|
||||||
// normal completion, so only abort when the response has not finished
|
// normal completion, so only abort when the response has not finished
|
||||||
// writing (a genuine disconnect). `once` fires at most once and self-removes;
|
// writing (a genuine disconnect). `once` fires at most once and self-removes;
|
||||||
@@ -242,18 +387,44 @@ export class AiChatController {
|
|||||||
// A genuine disconnect leaves the response unfinished (unlike a normal
|
// A genuine disconnect leaves the response unfinished (unlike a normal
|
||||||
// completion, which also fires `close`). Such a drop — e.g. a reverse
|
// completion, which also fires `close`). Such a drop — e.g. a reverse
|
||||||
// proxy cutting the SSE mid-answer — is otherwise invisible server-side,
|
// proxy cutting the SSE mid-answer — is otherwise invisible server-side,
|
||||||
// so log it here before aborting the agent loop.
|
// so log it here.
|
||||||
if (!res.raw.writableEnded) {
|
if (!res.raw.writableEnded) {
|
||||||
|
if (autonomousRuns) {
|
||||||
|
// #184: the turn is a DETACHED run. A disconnect must NOT abort it —
|
||||||
|
// the run keeps executing and persisting server-side; the client
|
||||||
|
// reconnects via /ai-chat/run (or re-stops via /ai-chat/stop). Log only.
|
||||||
|
this.logger.log(
|
||||||
|
`AI chat stream: client disconnected; run continues server-side ` +
|
||||||
|
`(elapsed=${Date.now() - reqStartedAt}ms since request received)`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`AI chat stream: client disconnected before completion; aborting turn ` +
|
`AI chat stream: client disconnected before completion; aborting turn ` +
|
||||||
`(elapsed=${Date.now() - reqStartedAt}ms since request received)`,
|
`(elapsed=${Date.now() - reqStartedAt}ms since request received)`,
|
||||||
);
|
);
|
||||||
controller.abort();
|
controller.abort();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
req.raw.once('close', onClose);
|
req.raw.once('close', onClose);
|
||||||
res.raw.once('finish', () => req.raw.off('close', onClose));
|
res.raw.once('finish', () => req.raw.off('close', onClose));
|
||||||
|
|
||||||
|
// #184: in detached mode the turn is NOT aborted on disconnect, so the SDK's
|
||||||
|
// pipe keeps writing to a socket the client may have dropped — for the rest of
|
||||||
|
// the (continuing) run. A write to the dead socket can emit an 'error' on the
|
||||||
|
// raw response; without a listener that surfaces as an unhandled error event.
|
||||||
|
// Swallow it (the run continues server-side regardless). Legacy mode aborts on
|
||||||
|
// disconnect, so it does not need this and keeps its exact prior behavior.
|
||||||
|
if (autonomousRuns) {
|
||||||
|
res.raw.on('error', (err) => {
|
||||||
|
this.logger.debug(
|
||||||
|
`AI chat detached stream: post-disconnect socket error swallowed: ${
|
||||||
|
err instanceof Error ? err.message : String(err)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Commit to streaming: hijack so Fastify stops managing the response and
|
// Commit to streaming: hijack so Fastify stops managing the response and
|
||||||
// the AI SDK can write the UI-message stream directly to the Node socket.
|
// the AI SDK can write the UI-message stream directly to the Node socket.
|
||||||
res.hijack();
|
res.hijack();
|
||||||
@@ -268,15 +439,32 @@ export class AiChatController {
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
model,
|
model,
|
||||||
role,
|
role,
|
||||||
|
// #184: present only when the flag is on; wraps the turn in a durable run.
|
||||||
|
runHooks,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Any failure AFTER hijack can no longer send a clean JSON error, so emit
|
// Any failure AFTER hijack can no longer go through Nest's exception
|
||||||
// a minimal error on the raw socket if nothing has been written yet.
|
// filter, so emit the error on the raw socket if nothing has been written
|
||||||
|
// yet. The lost-the-race 409 (RunAlreadyActiveError -> ConflictException)
|
||||||
|
// is raised by stream() BEFORE it writes a byte, so headers are still
|
||||||
|
// unsent here: honor the HttpException's real status + body (a clean 409),
|
||||||
|
// not a blanket 500. Everything else stays a 500.
|
||||||
|
const isHttp = err instanceof HttpException;
|
||||||
|
if (!isHttp) {
|
||||||
this.logger.error('AI chat stream failed', err as Error);
|
this.logger.error('AI chat stream failed', err as Error);
|
||||||
|
}
|
||||||
if (!res.raw.headersSent) {
|
if (!res.raw.headersSent) {
|
||||||
res.raw.statusCode = 500;
|
const status = isHttp ? err.getStatus() : 500;
|
||||||
|
const payload = isHttp
|
||||||
|
? err.getResponse()
|
||||||
|
: { error: 'Internal server error' };
|
||||||
|
res.raw.statusCode = status;
|
||||||
res.raw.setHeader('Content-Type', 'application/json');
|
res.raw.setHeader('Content-Type', 'application/json');
|
||||||
res.raw.end(JSON.stringify({ error: 'Internal server error' }));
|
res.raw.end(
|
||||||
|
JSON.stringify(
|
||||||
|
typeof payload === 'string' ? { message: payload } : payload,
|
||||||
|
),
|
||||||
|
);
|
||||||
} else if (!res.raw.writableEnded) {
|
} else if (!res.raw.writableEnded) {
|
||||||
res.raw.end();
|
res.raw.end();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ describe('AiChatController.generatePageTitle', () => {
|
|||||||
const aiChatService = { generatePageTitle: generate };
|
const aiChatService = { generatePageTitle: generate };
|
||||||
const controller = new AiChatController(
|
const controller = new AiChatController(
|
||||||
aiChatService as never,
|
aiChatService as never,
|
||||||
|
{} as never, // aiChatRunService
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { AiModule } from '../../integrations/ai/ai.module';
|
|||||||
import { TokenModule } from '../auth/token.module';
|
import { TokenModule } from '../auth/token.module';
|
||||||
import { AiChatController } from './ai-chat.controller';
|
import { AiChatController } from './ai-chat.controller';
|
||||||
import { AiChatService } from './ai-chat.service';
|
import { AiChatService } from './ai-chat.service';
|
||||||
|
import { AiChatRunService } from './ai-chat-run.service';
|
||||||
import { AiTranscriptionService } from './ai-transcription.service';
|
import { AiTranscriptionService } from './ai-transcription.service';
|
||||||
import { AiChatToolsService } from './tools/ai-chat-tools.service';
|
import { AiChatToolsService } from './tools/ai-chat-tools.service';
|
||||||
import { EmbeddingModule } from './embedding/embedding.module';
|
import { EmbeddingModule } from './embedding/embedding.module';
|
||||||
@@ -42,6 +43,7 @@ import { PublicShareChatToolsService } from './tools/public-share-chat-tools.ser
|
|||||||
controllers: [AiChatController, PublicShareChatController],
|
controllers: [AiChatController, PublicShareChatController],
|
||||||
providers: [
|
providers: [
|
||||||
AiChatService,
|
AiChatService,
|
||||||
|
AiChatRunService,
|
||||||
AiTranscriptionService,
|
AiTranscriptionService,
|
||||||
AiChatToolsService,
|
AiChatToolsService,
|
||||||
PublicShareChatService,
|
PublicShareChatService,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { AiChatService } from './ai-chat.service';
|
import { AiChatService, AiChatRunHooks } from './ai-chat.service';
|
||||||
|
import { AiChatRunService } from './ai-chat-run.service';
|
||||||
|
import type { User, Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lifecycle unit tests for AiChatService.onModuleInit (#183 crash-recovery
|
* Lifecycle unit tests for AiChatService.onModuleInit (#183 crash-recovery
|
||||||
@@ -61,3 +63,99 @@ describe('AiChatService.onModuleInit (startup sweep)', () => {
|
|||||||
expect(String(warnSpy.mock.calls[0][0])).toContain('db unavailable');
|
expect(String(warnSpy.mock.calls[0][0])).toContain('db unavailable');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #184 CRITICAL run-lifecycle safety net (review fix). A transient failure
|
||||||
|
* AFTER a successful beginRun but BEFORE streamText's terminal callbacks own the
|
||||||
|
* lifecycle must STILL settle the run — otherwise the run row is stuck 'running'
|
||||||
|
* forever (sweepRunning only runs at startup) and the partial unique index + the
|
||||||
|
* controller pre-check 409 every future turn in that chat until a restart. Here
|
||||||
|
* we model the very first bare await after beginRun (the user-message insert)
|
||||||
|
* throwing, wiring the run hooks to a REAL AiChatRunService (mock repo) exactly
|
||||||
|
* as the controller does, and assert the run is settled to 'error' and its
|
||||||
|
* in-memory entry dropped (so a follow-up turn would NOT be 409'd).
|
||||||
|
*/
|
||||||
|
describe('AiChatService.stream run-lifecycle safety net (#184)', () => {
|
||||||
|
const user = { id: 'u1' } as User;
|
||||||
|
const workspace = { id: 'ws1' } as Workspace;
|
||||||
|
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
it('an exception after beginRun settles the run to error and drops the in-memory entry', async () => {
|
||||||
|
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
// Real run service over a mock repo, so finalizeRun's in-memory bookkeeping
|
||||||
|
// (active.delete) is exercised for real.
|
||||||
|
const runRepo = {
|
||||||
|
insert: jest.fn().mockResolvedValue({ id: 'run-1', status: 'running' }),
|
||||||
|
update: jest.fn().mockResolvedValue({ id: 'run-1' }),
|
||||||
|
};
|
||||||
|
const runService = new AiChatRunService(runRepo as never, { isCloud: () => false } as never);
|
||||||
|
|
||||||
|
// The user-message insert (the first bare await after beginRun) throws.
|
||||||
|
const aiChatMessageRepo = {
|
||||||
|
insert: jest.fn().mockRejectedValue(new Error('insert boom')),
|
||||||
|
};
|
||||||
|
const aiChatRepo = {
|
||||||
|
// Existing chat -> chatId stays, no new-chat insert path.
|
||||||
|
findById: jest.fn().mockResolvedValue({ id: 'chat-1', creatorId: 'u1' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new AiChatService(
|
||||||
|
{} as never, // ai
|
||||||
|
aiChatRepo as never,
|
||||||
|
aiChatMessageRepo as never,
|
||||||
|
{} as never, // aiChatPageSnapshotRepo
|
||||||
|
{} as never, // aiSettings
|
||||||
|
{} as never, // tools
|
||||||
|
{} as never, // mcpClients
|
||||||
|
{} as never, // aiAgentRoleRepo
|
||||||
|
{} as never, // pageRepo
|
||||||
|
{} as never, // pageAccess
|
||||||
|
{} as never, // environment
|
||||||
|
);
|
||||||
|
|
||||||
|
const runHooks: AiChatRunHooks = {
|
||||||
|
begin: (chatId) =>
|
||||||
|
runService.beginRun({
|
||||||
|
chatId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
userId: user.id,
|
||||||
|
trigger: 'user',
|
||||||
|
}),
|
||||||
|
onSettled: (runId, status, error) =>
|
||||||
|
runService.finalizeRun(runId, workspace.id, status, error),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.stream({
|
||||||
|
user,
|
||||||
|
workspace,
|
||||||
|
sessionId: 'sess',
|
||||||
|
body: {
|
||||||
|
chatId: 'chat-1',
|
||||||
|
messages: [
|
||||||
|
{ id: 'm', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
res: {} as never,
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
model: {} as never,
|
||||||
|
role: null,
|
||||||
|
runHooks,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('insert boom');
|
||||||
|
|
||||||
|
// The run was begun...
|
||||||
|
expect(runRepo.insert).toHaveBeenCalledTimes(1);
|
||||||
|
// ...then settled to a terminal FAILED status by the safety net...
|
||||||
|
expect(runRepo.update).toHaveBeenCalledTimes(1);
|
||||||
|
expect(runRepo.update).toHaveBeenCalledWith(
|
||||||
|
'run-1',
|
||||||
|
'ws1',
|
||||||
|
expect.objectContaining({ status: 'failed' }),
|
||||||
|
);
|
||||||
|
// ...and the in-memory entry is gone, so a follow-up turn is NOT 409'd.
|
||||||
|
expect(runService.isLocallyActive('run-1')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,489 @@
|
|||||||
|
import { ConflictException, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
// Mock the AI SDK so we can PROVE no provider call is made for the turn we are
|
||||||
|
// about to reject. The race rejection happens at runHooks.begin(), long before
|
||||||
|
// any streamText/generateText, so these never resolve a real model.
|
||||||
|
jest.mock('ai', () => ({
|
||||||
|
streamText: jest.fn(),
|
||||||
|
generateText: jest.fn(),
|
||||||
|
convertToModelMessages: jest.fn(() => []),
|
||||||
|
stepCountIs: jest.fn(() => () => false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { streamText, generateText } from 'ai';
|
||||||
|
import { AiChatService } from './ai-chat.service';
|
||||||
|
import { RunAlreadyActiveError } from './ai-chat-run.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Race-closure coverage for the "one active run per chat" guard (#184).
|
||||||
|
*
|
||||||
|
* THE BUG: two simultaneous POST /ai-chat/stream on the same chat both pass the
|
||||||
|
* controller's cheap pre-check (TOCTOU), so the loser's run-row INSERT hits the
|
||||||
|
* partial unique index. Previously that 23505 was SWALLOWED and the second turn
|
||||||
|
* streamed UNTRACKED (no runId, not stoppable). THE FIX: beginRun surfaces a
|
||||||
|
* RunAlreadyActiveError and stream() turns it into a 409 BEFORE any AI call —
|
||||||
|
* the second turn never runs.
|
||||||
|
*/
|
||||||
|
describe('AiChatService.stream — concurrent-run race rejection (#184)', () => {
|
||||||
|
const streamTextMock = streamText as unknown as jest.Mock;
|
||||||
|
const generateTextMock = generateText as unknown as jest.Mock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
streamTextMock.mockReset();
|
||||||
|
generateTextMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Minimal service whose only reachable deps before begin() are aiChatRepo
|
||||||
|
// (resolve the existing chat) — everything past begin must remain untouched.
|
||||||
|
function makeService(beginImpl: () => Promise<unknown>) {
|
||||||
|
const aiChatMessageRepo = { insert: jest.fn() };
|
||||||
|
const aiChatRepo = {
|
||||||
|
// An existing chat: stream keeps the supplied chatId and skips creation.
|
||||||
|
findById: jest.fn(async () => ({ id: 'chat-1', workspaceId: 'ws-1' })),
|
||||||
|
insert: jest.fn(),
|
||||||
|
};
|
||||||
|
const svc = new AiChatService(
|
||||||
|
{} as never, // ai
|
||||||
|
aiChatRepo as never,
|
||||||
|
aiChatMessageRepo as never,
|
||||||
|
{} as never, // aiChatPageSnapshotRepo
|
||||||
|
{} as never, // aiSettings
|
||||||
|
{} as never, // tools
|
||||||
|
{} as never, // mcpClients
|
||||||
|
{} as never, // aiAgentRoleRepo
|
||||||
|
{} as never, // pageRepo
|
||||||
|
{} as never, // pageAccess
|
||||||
|
{ isAiChatDeferredToolsEnabled: () => false } as never, // environment
|
||||||
|
);
|
||||||
|
const begin = jest.fn(beginImpl);
|
||||||
|
return { svc, begin, aiChatRepo, aiChatMessageRepo };
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseArgs = (begin: jest.Mock) => ({
|
||||||
|
user: { id: 'user-1' } as never,
|
||||||
|
workspace: { id: 'ws-1' } as never,
|
||||||
|
sessionId: 'sess-1',
|
||||||
|
body: { chatId: 'chat-1', messages: [] } as never,
|
||||||
|
res: { raw: {} } as never,
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
model: {} as never,
|
||||||
|
role: null,
|
||||||
|
runHooks: {
|
||||||
|
begin,
|
||||||
|
onAssistantSeeded: jest.fn(),
|
||||||
|
onStep: jest.fn(),
|
||||||
|
onSettled: jest.fn(),
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects the racer with a 409 ConflictException BEFORE any AI call, and never persists an untracked turn', async () => {
|
||||||
|
// begin loses the unique-index race -> RunAlreadyActiveError.
|
||||||
|
const { svc, begin, aiChatMessageRepo } = makeService(() => {
|
||||||
|
throw new RunAlreadyActiveError('chat-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
const promise = svc.stream(baseArgs(begin));
|
||||||
|
|
||||||
|
await expect(promise).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
await promise.catch((err: ConflictException) => {
|
||||||
|
expect(err.getStatus()).toBe(409);
|
||||||
|
expect((err.getResponse() as { code?: string }).code).toBe(
|
||||||
|
'A_RUN_ALREADY_ACTIVE',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The decisive assertions: the rejected racer spent NO tokens and left NO
|
||||||
|
// untracked turn behind.
|
||||||
|
expect(begin).toHaveBeenCalledTimes(1);
|
||||||
|
expect(streamTextMock).not.toHaveBeenCalled();
|
||||||
|
expect(generateTextMock).not.toHaveBeenCalled();
|
||||||
|
expect(aiChatMessageRepo.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F3 — the LOAD-BEARING run-detach wiring: `effectiveSignal = handle.signal`
|
||||||
|
* after runHooks.begin, then `abortSignal: effectiveSignal` passed to streamText.
|
||||||
|
* That single line is what makes a run survive a browser disconnect (the agent
|
||||||
|
* loop's abort is governed by the RUN's signal, not the socket): a regression to
|
||||||
|
* the socket-bound signal would still pass every other test green while silently
|
||||||
|
* breaking Stop + durability. These two tests pin the exact signal streamText
|
||||||
|
* consumes on both paths.
|
||||||
|
*/
|
||||||
|
describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
|
||||||
|
const streamTextMock = streamText as unknown as jest.Mock;
|
||||||
|
|
||||||
|
// A streamText result stub: the post-call drain + pipe are no-ops here; we only
|
||||||
|
// care WHICH abortSignal streamText was handed.
|
||||||
|
function makeStreamResult() {
|
||||||
|
return {
|
||||||
|
consumeStream: jest.fn(),
|
||||||
|
pipeUIMessageStreamToResponse: jest.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A raw-response stub sufficient for the post-streamText wiring
|
||||||
|
// (stripStreamingHopByHopHeaders binds writeHead; startSseHeartbeat registers
|
||||||
|
// close/finish listeners; flushHeaders is belt-and-braces).
|
||||||
|
function makeRes() {
|
||||||
|
return {
|
||||||
|
raw: {
|
||||||
|
writeHead: jest.fn(),
|
||||||
|
write: jest.fn(),
|
||||||
|
once: jest.fn(),
|
||||||
|
on: jest.fn(),
|
||||||
|
flushHeaders: jest.fn(),
|
||||||
|
writableEnded: false,
|
||||||
|
destroyed: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire only the deps reached on the way to streamText: resolve the existing
|
||||||
|
// chat, persist the user + seed the assistant row, load (empty) history, the
|
||||||
|
// admin settings, an empty external toolset + Docmost toolset.
|
||||||
|
function makeService() {
|
||||||
|
const aiChatRepo = {
|
||||||
|
findById: jest.fn(async () => ({ id: 'chat-1', workspaceId: 'ws-1' })),
|
||||||
|
insert: jest.fn(),
|
||||||
|
};
|
||||||
|
const aiChatMessageRepo = {
|
||||||
|
insert: jest.fn(async () => ({ id: 'msg-1' })),
|
||||||
|
findAllByChat: jest.fn(async () => []),
|
||||||
|
update: jest.fn(async () => ({ id: 'msg-1' })),
|
||||||
|
};
|
||||||
|
const aiSettings = { resolve: jest.fn(async () => ({})) };
|
||||||
|
const tools = { forUser: jest.fn(async () => ({})) };
|
||||||
|
const mcpClients = {
|
||||||
|
toolsFor: jest.fn(async () => ({
|
||||||
|
tools: {},
|
||||||
|
clients: [],
|
||||||
|
outcomes: [],
|
||||||
|
instructions: [],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const svc = new AiChatService(
|
||||||
|
{} as never, // ai
|
||||||
|
aiChatRepo as never,
|
||||||
|
aiChatMessageRepo as never,
|
||||||
|
{} as never, // aiChatPageSnapshotRepo
|
||||||
|
aiSettings as never,
|
||||||
|
tools as never,
|
||||||
|
mcpClients as never,
|
||||||
|
{} as never, // aiAgentRoleRepo
|
||||||
|
{} as never, // pageRepo (openPage undefined -> never touched)
|
||||||
|
{} as never, // pageAccess
|
||||||
|
{ isAiChatDeferredToolsEnabled: () => false } as never, // environment
|
||||||
|
);
|
||||||
|
return { svc };
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
chatId: 'chat-1',
|
||||||
|
messages: [
|
||||||
|
{ id: 'm1', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
streamTextMock.mockReset();
|
||||||
|
streamTextMock.mockImplementation(() => makeStreamResult());
|
||||||
|
jest
|
||||||
|
.spyOn(Logger.prototype, 'log')
|
||||||
|
.mockImplementation(() => undefined as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
it('happy path (run-wrapped): streamText is driven with abortSignal === handle.signal (the RUN signal, NOT the socket)', async () => {
|
||||||
|
const { svc } = makeService();
|
||||||
|
const runController = new AbortController();
|
||||||
|
const runSignal = runController.signal;
|
||||||
|
const socketSignal = new AbortController().signal;
|
||||||
|
|
||||||
|
const begin = jest.fn(async () => ({ runId: 'run-1', signal: runSignal }));
|
||||||
|
await svc.stream({
|
||||||
|
user: { id: 'user-1' } as never,
|
||||||
|
workspace: { id: 'ws-1' } as never,
|
||||||
|
sessionId: 'sess-1',
|
||||||
|
body: body as never,
|
||||||
|
res: makeRes() as never,
|
||||||
|
signal: socketSignal,
|
||||||
|
model: {} as never,
|
||||||
|
role: null,
|
||||||
|
runHooks: {
|
||||||
|
begin,
|
||||||
|
onAssistantSeeded: jest.fn(),
|
||||||
|
onStep: jest.fn(),
|
||||||
|
onSettled: jest.fn(),
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(begin).toHaveBeenCalledTimes(1);
|
||||||
|
expect(streamTextMock).toHaveBeenCalledTimes(1);
|
||||||
|
// THE assertion: the agent loop's abort is wired to the RUN, so a browser
|
||||||
|
// disconnect (which aborts only `socketSignal`) cannot end the turn.
|
||||||
|
expect(streamTextMock.mock.calls[0][0].abortSignal).toBe(runSignal);
|
||||||
|
expect(streamTextMock.mock.calls[0][0].abortSignal).not.toBe(socketSignal);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('legacy path (no runHooks): streamText is driven with the SOCKET signal', async () => {
|
||||||
|
const { svc } = makeService();
|
||||||
|
const socketSignal = new AbortController().signal;
|
||||||
|
|
||||||
|
await svc.stream({
|
||||||
|
user: { id: 'user-1' } as never,
|
||||||
|
workspace: { id: 'ws-1' } as never,
|
||||||
|
sessionId: 'sess-1',
|
||||||
|
body: body as never,
|
||||||
|
res: makeRes() as never,
|
||||||
|
signal: socketSignal,
|
||||||
|
model: {} as never,
|
||||||
|
role: null,
|
||||||
|
// No runHooks -> the turn stays socket-bound (flag off / default).
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(streamTextMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(streamTextMock.mock.calls[0][0].abortSignal).toBe(socketSignal);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F9 — streamText's TERMINAL callbacks carry the #184 run lifecycle:
|
||||||
|
* onStepFinish -> runHooks.onStep(runId, stepCount)
|
||||||
|
* onFinish -> runHooks.onSettled(runId, 'completed') (dominant path)
|
||||||
|
* onAbort -> runHooks.onSettled(runId, 'aborted')
|
||||||
|
* onError -> runHooks.onSettled(runId, 'error', cause)
|
||||||
|
* makeStreamResult() ignores the streamText options, so these callbacks never
|
||||||
|
* fire on their own — a regression in this wiring (esp. the success path) would
|
||||||
|
* strand the run with NO test catching it. Here we CAPTURE the options streamText
|
||||||
|
* was handed and invoke each callback with the real wiring, asserting the run
|
||||||
|
* hooks fire with the right args.
|
||||||
|
*/
|
||||||
|
// Drive stream() to the point streamText is called, capturing the options object
|
||||||
|
// (which carries onStepFinish/onFinish/onError/onAbort) and the run hooks.
|
||||||
|
async function captureStreamCallbacks() {
|
||||||
|
const { svc } = makeService();
|
||||||
|
let capturedOpts: any;
|
||||||
|
streamTextMock.mockImplementation((opts: any) => {
|
||||||
|
capturedOpts = opts;
|
||||||
|
return makeStreamResult();
|
||||||
|
});
|
||||||
|
const runHooks = {
|
||||||
|
begin: jest.fn(async () => ({
|
||||||
|
runId: 'run-1',
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
})),
|
||||||
|
onAssistantSeeded: jest.fn(),
|
||||||
|
onStep: jest.fn(),
|
||||||
|
onSettled: jest.fn(),
|
||||||
|
};
|
||||||
|
await svc.stream({
|
||||||
|
user: { id: 'user-1' } as never,
|
||||||
|
workspace: { id: 'ws-1' } as never,
|
||||||
|
sessionId: 'sess-1',
|
||||||
|
body: body as never,
|
||||||
|
res: makeRes() as never,
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
model: {} as never,
|
||||||
|
role: null,
|
||||||
|
runHooks: runHooks as never,
|
||||||
|
});
|
||||||
|
expect(capturedOpts).toBeDefined();
|
||||||
|
return { capturedOpts, runHooks };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('F9: onStepFinish bumps the run step count, onFinish settles the run "completed" (the dominant autonomous-run path)', async () => {
|
||||||
|
const { capturedOpts, runHooks } = await captureStreamCallbacks();
|
||||||
|
|
||||||
|
// A finished step -> onStep(runId, finishedStepCount).
|
||||||
|
capturedOpts.onStepFinish({ text: 'step one', toolCalls: [], content: [] });
|
||||||
|
expect(runHooks.onStep).toHaveBeenCalledWith('run-1', 1);
|
||||||
|
capturedOpts.onStepFinish({ text: 'step two', toolCalls: [], content: [] });
|
||||||
|
expect(runHooks.onStep).toHaveBeenLastCalledWith('run-1', 2);
|
||||||
|
|
||||||
|
// The success terminal callback settles the run.
|
||||||
|
await capturedOpts.onFinish({
|
||||||
|
text: 'done',
|
||||||
|
finishReason: 'stop',
|
||||||
|
totalUsage: {},
|
||||||
|
usage: {},
|
||||||
|
steps: [],
|
||||||
|
});
|
||||||
|
expect(runHooks.onSettled).toHaveBeenCalledWith('run-1', 'completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('F9: onAbort settles the run "aborted"', async () => {
|
||||||
|
jest
|
||||||
|
.spyOn(Logger.prototype, 'warn')
|
||||||
|
.mockImplementation(() => undefined as never);
|
||||||
|
const { capturedOpts, runHooks } = await captureStreamCallbacks();
|
||||||
|
|
||||||
|
await capturedOpts.onAbort({ steps: [] });
|
||||||
|
expect(runHooks.onSettled).toHaveBeenCalledWith('run-1', 'aborted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('F9: onError settles the run "error" carrying the provider cause', async () => {
|
||||||
|
jest
|
||||||
|
.spyOn(Logger.prototype, 'error')
|
||||||
|
.mockImplementation(() => undefined as never);
|
||||||
|
jest
|
||||||
|
.spyOn(Logger.prototype, 'warn')
|
||||||
|
.mockImplementation(() => undefined as never);
|
||||||
|
const { capturedOpts, runHooks } = await captureStreamCallbacks();
|
||||||
|
|
||||||
|
await capturedOpts.onError({ error: new Error('provider exploded') });
|
||||||
|
expect(runHooks.onSettled).toHaveBeenCalledWith(
|
||||||
|
'run-1',
|
||||||
|
'error',
|
||||||
|
expect.stringContaining('provider exploded'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F14 — the begin-failure RESILIENCE branch (the `else` of the run-race guard).
|
||||||
|
*
|
||||||
|
* stream() wraps runHooks.begin in try/catch with TWO branches:
|
||||||
|
* - RunAlreadyActiveError -> 409 ConflictException (pinned above).
|
||||||
|
* - ANY OTHER begin failure -> SWALLOW + continue UNTRACKED on the socket signal
|
||||||
|
* (legacy fallback): it logs "...streaming without run tracking", leaves
|
||||||
|
* `effectiveSignal = signal` (runId undefined) and serves the turn anyway.
|
||||||
|
*
|
||||||
|
* The contract: a transient beginRun failure (e.g. a non-unique DB error inserting
|
||||||
|
* the run row) must STILL serve the user's turn — it must NOT re-throw and must NOT
|
||||||
|
* be misclassified as a 409. A regression that re-threw here would break EVERY turn
|
||||||
|
* on a begin failure with nothing to catch it. This branch is otherwise undriven by
|
||||||
|
* any spec, so it is pinned here SEPARATELY from the 409 path: a plain begin error
|
||||||
|
* proceeds to streamText with the SOCKET signal and still persists the user turn.
|
||||||
|
*/
|
||||||
|
describe('AiChatService.stream — begin-failure resilience / legacy fallback (#184 F14)', () => {
|
||||||
|
const streamTextMock = streamText as unknown as jest.Mock;
|
||||||
|
|
||||||
|
function makeStreamResult() {
|
||||||
|
return {
|
||||||
|
consumeStream: jest.fn(),
|
||||||
|
pipeUIMessageStreamToResponse: jest.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRes() {
|
||||||
|
return {
|
||||||
|
raw: {
|
||||||
|
writeHead: jest.fn(),
|
||||||
|
write: jest.fn(),
|
||||||
|
once: jest.fn(),
|
||||||
|
on: jest.fn(),
|
||||||
|
flushHeaders: jest.fn(),
|
||||||
|
writableEnded: false,
|
||||||
|
destroyed: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same harness as the F3 abortSignal block, but it also exposes
|
||||||
|
// aiChatMessageRepo so we can assert the user turn IS persisted (the turn really
|
||||||
|
// streamed) despite begin() blowing up.
|
||||||
|
function makeService() {
|
||||||
|
const aiChatRepo = {
|
||||||
|
findById: jest.fn(async () => ({ id: 'chat-1', workspaceId: 'ws-1' })),
|
||||||
|
insert: jest.fn(),
|
||||||
|
};
|
||||||
|
const aiChatMessageRepo = {
|
||||||
|
insert: jest.fn(async () => ({ id: 'msg-1' })),
|
||||||
|
findAllByChat: jest.fn(async () => []),
|
||||||
|
update: jest.fn(async () => ({ id: 'msg-1' })),
|
||||||
|
};
|
||||||
|
const aiSettings = { resolve: jest.fn(async () => ({})) };
|
||||||
|
const tools = { forUser: jest.fn(async () => ({})) };
|
||||||
|
const mcpClients = {
|
||||||
|
toolsFor: jest.fn(async () => ({
|
||||||
|
tools: {},
|
||||||
|
clients: [],
|
||||||
|
outcomes: [],
|
||||||
|
instructions: [],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const svc = new AiChatService(
|
||||||
|
{} as never, // ai
|
||||||
|
aiChatRepo as never,
|
||||||
|
aiChatMessageRepo as never,
|
||||||
|
{} as never, // aiChatPageSnapshotRepo
|
||||||
|
aiSettings as never,
|
||||||
|
tools as never,
|
||||||
|
mcpClients as never,
|
||||||
|
{} as never, // aiAgentRoleRepo
|
||||||
|
{} as never, // pageRepo
|
||||||
|
{} as never, // pageAccess
|
||||||
|
{ isAiChatDeferredToolsEnabled: () => false } as never, // environment
|
||||||
|
);
|
||||||
|
return { svc, aiChatMessageRepo };
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
chatId: 'chat-1',
|
||||||
|
messages: [
|
||||||
|
{ id: 'm1', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
streamTextMock.mockReset();
|
||||||
|
streamTextMock.mockImplementation(() => makeStreamResult());
|
||||||
|
jest
|
||||||
|
.spyOn(Logger.prototype, 'log')
|
||||||
|
.mockImplementation(() => undefined as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
it('a PLAIN begin() failure (NOT RunAlreadyActiveError) does NOT 409 — it swallows, logs, and streams the turn UNTRACKED on the socket signal', async () => {
|
||||||
|
const errorSpy = jest
|
||||||
|
.spyOn(Logger.prototype, 'error')
|
||||||
|
.mockImplementation(() => undefined as never);
|
||||||
|
|
||||||
|
const { svc, aiChatMessageRepo } = makeService();
|
||||||
|
const socketSignal = new AbortController().signal;
|
||||||
|
|
||||||
|
// A transient, NON-race begin failure (e.g. a non-unique DB error inserting
|
||||||
|
// the run row). This is the `else` branch of the begin try/catch.
|
||||||
|
const begin = jest.fn(async () => {
|
||||||
|
throw new Error('insert failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
const promise = svc.stream({
|
||||||
|
user: { id: 'user-1' } as never,
|
||||||
|
workspace: { id: 'ws-1' } as never,
|
||||||
|
sessionId: 'sess-1',
|
||||||
|
body: body as never,
|
||||||
|
res: makeRes() as never,
|
||||||
|
signal: socketSignal,
|
||||||
|
model: {} as never,
|
||||||
|
role: null,
|
||||||
|
runHooks: {
|
||||||
|
begin,
|
||||||
|
onAssistantSeeded: jest.fn(),
|
||||||
|
onStep: jest.fn(),
|
||||||
|
onSettled: jest.fn(),
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The turn proceeds: NO throw at all (in particular NOT a 409).
|
||||||
|
await expect(promise).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(begin).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// The resilience branch logged the legacy-fallback warning.
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('streaming without run tracking'),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The turn really streamed: the user message was persisted and streamText ran.
|
||||||
|
expect(aiChatMessageRepo.insert).toHaveBeenCalled();
|
||||||
|
expect(streamTextMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// The decisive wiring: with no run handle, the fallback uses the SOCKET signal
|
||||||
|
// (effectiveSignal = signal, runId undefined) — not a run-bound signal.
|
||||||
|
expect(streamTextMock.mock.calls[0][0].abortSignal).toBe(socketSignal);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -453,6 +453,12 @@ describe('chatStreamMetadata', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('attaches the runId on the start part when a run wraps the turn (#184)', () => {
|
||||||
|
expect(
|
||||||
|
chatStreamMetadata({ type: 'start' }, 'chat-1', undefined, 'run-1'),
|
||||||
|
).toEqual({ chatId: 'chat-1', runId: 'run-1' });
|
||||||
|
});
|
||||||
|
|
||||||
it('returns the CUMULATIVE step usage passed in for the finish-step part', () => {
|
it('returns the CUMULATIVE step usage passed in for the finish-step part', () => {
|
||||||
// finish-step usage is per-step in v6; the caller accumulates and passes the
|
// finish-step usage is per-step in v6; the caller accumulates and passes the
|
||||||
// running sum, which this just wraps.
|
// running sum, which this just wraps.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
ConflictException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
@@ -39,6 +40,7 @@ import {
|
|||||||
makeLoadToolsTool,
|
makeLoadToolsTool,
|
||||||
buildExternalToolCatalog,
|
buildExternalToolCatalog,
|
||||||
} from './tools/tool-tiers';
|
} from './tools/tool-tiers';
|
||||||
|
import { RunAlreadyActiveError } from './ai-chat-run.service';
|
||||||
import { computePageChange } from './page-change/page-change.util';
|
import { computePageChange } from './page-change/page-change.util';
|
||||||
import { roleModelOverride } from './roles/role-model-config';
|
import { roleModelOverride } from './roles/role-model-config';
|
||||||
import {
|
import {
|
||||||
@@ -196,6 +198,31 @@ export interface AiChatStreamBody {
|
|||||||
messages?: UIMessage[];
|
messages?: UIMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional run-lifecycle hooks (#184 phase 1). When supplied, the turn is wrapped
|
||||||
|
* in a first-class server-side RUN: `begin` is called once the chat id is known
|
||||||
|
* and returns the run's AbortSignal (decoupled from the HTTP socket — a browser
|
||||||
|
* disconnect no longer governs the abort), and the lifecycle callbacks persist
|
||||||
|
* the run's progress and terminal status. Absent (the default) => the legacy
|
||||||
|
* socket-bound behavior is unchanged.
|
||||||
|
*/
|
||||||
|
export interface AiChatRunHooks {
|
||||||
|
// Called once the chat id is resolved; returns the run handle whose `signal`
|
||||||
|
// drives the agent loop's abort. Returning null disables run tracking (the
|
||||||
|
// turn falls back to the passed-in socket signal).
|
||||||
|
begin(chatId: string): Promise<{ runId: string; signal: AbortSignal } | null>;
|
||||||
|
onAssistantSeeded?(
|
||||||
|
runId: string,
|
||||||
|
assistantMessageId: string,
|
||||||
|
): Promise<void> | void;
|
||||||
|
onStep?(runId: string, stepCount: number): void;
|
||||||
|
onSettled?(
|
||||||
|
runId: string,
|
||||||
|
status: 'completed' | 'error' | 'aborted',
|
||||||
|
error?: string,
|
||||||
|
): Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AiChatStreamArgs {
|
export interface AiChatStreamArgs {
|
||||||
user: User;
|
user: User;
|
||||||
workspace: Workspace;
|
workspace: Workspace;
|
||||||
@@ -203,6 +230,10 @@ export interface AiChatStreamArgs {
|
|||||||
body: AiChatStreamBody;
|
body: AiChatStreamBody;
|
||||||
res: FastifyReply;
|
res: FastifyReply;
|
||||||
signal: AbortSignal;
|
signal: AbortSignal;
|
||||||
|
// Run-lifecycle hooks (#184). When present the turn becomes a detached,
|
||||||
|
// durable RUN whose abort is governed by the run (explicit stop), not the
|
||||||
|
// socket; when absent the turn stays socket-bound (legacy behavior).
|
||||||
|
runHooks?: AiChatRunHooks;
|
||||||
// Resolved by the controller BEFORE res.hijack(), so an unconfigured provider
|
// Resolved by the controller BEFORE res.hijack(), so an unconfigured provider
|
||||||
// (AiNotConfiguredException -> 503) surfaces as clean JSON before streaming.
|
// (AiNotConfiguredException -> 503) surfaces as clean JSON before streaming.
|
||||||
// For a role with a model override this already carries the override-resolved
|
// For a role with a model override this already carries the override-resolved
|
||||||
@@ -487,6 +518,7 @@ export class AiChatService implements OnModuleInit {
|
|||||||
signal,
|
signal,
|
||||||
model,
|
model,
|
||||||
role,
|
role,
|
||||||
|
runHooks,
|
||||||
}: AiChatStreamArgs): Promise<void> {
|
}: AiChatStreamArgs): Promise<void> {
|
||||||
// Resolve / create the chat. A new chat is created when no valid chatId is
|
// Resolve / create the chat. A new chat is created when no valid chatId is
|
||||||
// supplied or the supplied one does not belong to this workspace.
|
// supplied or the supplied one does not belong to this workspace.
|
||||||
@@ -531,6 +563,44 @@ export class AiChatService implements OnModuleInit {
|
|||||||
isNewChat = true;
|
isNewChat = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start the durable RUN now that the chat id is known (#184 phase 1). The
|
||||||
|
// returned `runId` + `signal` make the turn a first-class server-side object
|
||||||
|
// whose abort is governed by the run (an explicit user stop), NOT by the HTTP
|
||||||
|
// socket — so a browser disconnect no longer ends the turn. With no runHooks
|
||||||
|
// (the default / flag off) the turn stays socket-bound via `signal` and
|
||||||
|
// `runId` is undefined, leaving the legacy path byte-for-byte unchanged.
|
||||||
|
let runId: string | undefined;
|
||||||
|
let effectiveSignal = signal;
|
||||||
|
if (runHooks) {
|
||||||
|
try {
|
||||||
|
const handle = await runHooks.begin(chatId);
|
||||||
|
if (handle) {
|
||||||
|
runId = handle.runId;
|
||||||
|
effectiveSignal = handle.signal;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// RACE BACKSTOP: the run-row INSERT lost the chat's single active slot
|
||||||
|
// (the partial unique index rejected it). This is the AUTHORITATIVE
|
||||||
|
// concurrency gate — the controller's pre-check is only a fast-path, and a
|
||||||
|
// request that slipped past it must NOT proceed. Reject the turn with a
|
||||||
|
// 409 NOW, BEFORE any AI/provider call: no tokens are spent and no
|
||||||
|
// untracked turn streams. (Matches the controller's pre-check 409.)
|
||||||
|
if (err instanceof RunAlreadyActiveError) {
|
||||||
|
throw new ConflictException({
|
||||||
|
message: 'An agent run is already in progress for this chat',
|
||||||
|
code: 'A_RUN_ALREADY_ACTIVE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Any OTHER run-start failure must not break the turn — fall back to the
|
||||||
|
// socket signal (legacy behavior) and stream anyway.
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to begin agent run (chat ${chatId}); streaming without run tracking`,
|
||||||
|
err as Error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
// Extract the incoming user turn (the last user message from useChat).
|
// Extract the incoming user turn (the last user message from useChat).
|
||||||
const incoming = lastUserMessage(body.messages);
|
const incoming = lastUserMessage(body.messages);
|
||||||
const incomingText = uiMessageText(incoming);
|
const incomingText = uiMessageText(incoming);
|
||||||
@@ -788,6 +858,20 @@ export class AiChatService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Link the assistant message (the #183 projection) to its run (#184), so a
|
||||||
|
// reconnecting client can resolve the run's output. Best-effort.
|
||||||
|
if (runId && assistantId) {
|
||||||
|
try {
|
||||||
|
await runHooks?.onAssistantSeeded?.(runId, assistantId);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to link assistant row to run ${runId}: ${
|
||||||
|
err instanceof Error ? err.message : 'unknown error'
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Per-step (non-terminal) update: persist the finished steps the moment a
|
// Per-step (non-terminal) update: persist the finished steps the moment a
|
||||||
// step ends. Tolerant — a failed update is logged and swallowed so it never
|
// step ends. Tolerant — a failed update is logged and swallowed so it never
|
||||||
// throws into the stream. Keeps status 'streaming'.
|
// throws into the stream. Keeps status 'streaming'.
|
||||||
@@ -884,7 +968,10 @@ export class AiChatService implements OnModuleInit {
|
|||||||
// concatenated onto the original `system` so the persona is preserved.
|
// concatenated onto the original `system` so the persona is preserved.
|
||||||
prepareStep: ({ stepNumber }) =>
|
prepareStep: ({ stepNumber }) =>
|
||||||
prepareAgentStep(stepNumber, system, activatedTools, deferredEnabled),
|
prepareAgentStep(stepNumber, system, activatedTools, deferredEnabled),
|
||||||
abortSignal: signal,
|
// #184: the RUN's signal (explicit-stop) when a run wraps this turn, else
|
||||||
|
// the socket-bound signal (legacy). A browser disconnect aborts only in
|
||||||
|
// the legacy path.
|
||||||
|
abortSignal: effectiveSignal,
|
||||||
onChunk: ({ chunk }) => {
|
onChunk: ({ chunk }) => {
|
||||||
// DIAGNOSTIC (Safari stream-drop investigation) — temporary. Any model
|
// DIAGNOSTIC (Safari stream-drop investigation) — temporary. Any model
|
||||||
// output chunk means the stream is actively emitting bytes; track first
|
// output chunk means the stream is actively emitting bytes; track first
|
||||||
@@ -907,6 +994,9 @@ export class AiChatService implements OnModuleInit {
|
|||||||
// stream), but SERIALIZED via stepUpdateChain so the writes commit in
|
// stream), but SERIALIZED via stepUpdateChain so the writes commit in
|
||||||
// step order; updateStreaming is error-tolerant (logs + swallows).
|
// step order; updateStreaming is error-tolerant (logs + swallows).
|
||||||
stepUpdateChain = stepUpdateChain.then(() => updateStreaming());
|
stepUpdateChain = stepUpdateChain.then(() => updateStreaming());
|
||||||
|
// #184: persist the run's progress (finished-step count). Fire-and-
|
||||||
|
// forget; the hook swallows its own errors.
|
||||||
|
if (runId) runHooks?.onStep?.(runId, capturedSteps.length);
|
||||||
},
|
},
|
||||||
onFinish: async ({ text, finishReason, totalUsage, usage, steps }) => {
|
onFinish: async ({ text, finishReason, totalUsage, usage, steps }) => {
|
||||||
// DIAGNOSTIC (Safari stream-drop investigation) — temporary: success
|
// DIAGNOSTIC (Safari stream-drop investigation) — temporary: success
|
||||||
@@ -947,6 +1037,9 @@ export class AiChatService implements OnModuleInit {
|
|||||||
pageChanged,
|
pageChanged,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
// #184: settle the RUN as succeeded (best-effort, after the projection
|
||||||
|
// is finalized above).
|
||||||
|
if (runId) await runHooks?.onSettled?.(runId, 'completed');
|
||||||
// Lifecycle: release the external MCP clients leased for this turn.
|
// Lifecycle: release the external MCP clients leased for this turn.
|
||||||
await closeExternalClients();
|
await closeExternalClients();
|
||||||
|
|
||||||
@@ -999,6 +1092,8 @@ export class AiChatService implements OnModuleInit {
|
|||||||
pageChanged,
|
pageChanged,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
// #184: settle the RUN as failed, carrying the provider/transport cause.
|
||||||
|
if (runId) await runHooks?.onSettled?.(runId, 'error', errorText);
|
||||||
await closeExternalClients();
|
await closeExternalClients();
|
||||||
// Advance the page snapshot even on failure (#274): an agent edit that
|
// Advance the page snapshot even on failure (#274): an agent edit that
|
||||||
// committed before the error must be baked into the snapshot, or the
|
// committed before the error must be baked into the snapshot, or the
|
||||||
@@ -1030,6 +1125,9 @@ export class AiChatService implements OnModuleInit {
|
|||||||
pageChanged,
|
pageChanged,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
// #184: settle the RUN as aborted (an explicit user stop reached the
|
||||||
|
// run's signal; a disconnect does not abort a run-wrapped turn).
|
||||||
|
if (runId) await runHooks?.onSettled?.(runId, 'aborted');
|
||||||
await closeExternalClients();
|
await closeExternalClients();
|
||||||
// Advance the page snapshot even on abort (#274): an agent edit that
|
// Advance the page snapshot even on abort (#274): an agent edit that
|
||||||
// committed before the client disconnect / stop() must be baked into the
|
// committed before the client disconnect / stop() must be baked into the
|
||||||
@@ -1100,7 +1198,7 @@ export class AiChatService implements OnModuleInit {
|
|||||||
normalizeStreamUsage(p.usage),
|
normalizeStreamUsage(p.usage),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return chatStreamMetadata(p, chatId, cumulativeStepUsage);
|
return chatStreamMetadata(p, chatId, cumulativeStepUsage, runId);
|
||||||
},
|
},
|
||||||
// Stream reasoning (thinking) parts to the client so the live counter can
|
// Stream reasoning (thinking) parts to the client so the live counter can
|
||||||
// estimate reasoning tokens from streamed text. v6 default is already
|
// estimate reasoning tokens from streamed text. v6 default is already
|
||||||
@@ -1134,6 +1232,23 @@ export class AiChatService implements OnModuleInit {
|
|||||||
await closeExternalClients();
|
await closeExternalClients();
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// #184 safety net (see the opening comment): settle the run on ANY failure
|
||||||
|
// before streamText's callbacks own the lifecycle, so the run row never
|
||||||
|
// stays 'running' forever (which would 409 every later turn in this chat).
|
||||||
|
// finalizeRun (onSettled) is idempotent — a settle here and a settle from a
|
||||||
|
// streamText callback collapse to a single terminal write.
|
||||||
|
if (runId) {
|
||||||
|
await runHooks?.onSettled?.(
|
||||||
|
runId,
|
||||||
|
'error',
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'Agent run failed before streaming started',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1144,7 +1259,10 @@ export class AiChatService implements OnModuleInit {
|
|||||||
* permission). The content is truncated to keep the prompt cheap and within
|
* permission). The content is truncated to keep the prompt cheap and within
|
||||||
* context limits. Throws AiNotConfiguredException (503) if AI is unconfigured.
|
* context limits. Throws AiNotConfiguredException (503) if AI is unconfigured.
|
||||||
*/
|
*/
|
||||||
async generatePageTitle(workspaceId: string, content: string): Promise<string> {
|
async generatePageTitle(
|
||||||
|
workspaceId: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<string> {
|
||||||
const model = await this.ai.getChatModel(workspaceId);
|
const model = await this.ai.getChatModel(workspaceId);
|
||||||
const { text } = await generateText({
|
const { text } = await generateText({
|
||||||
model,
|
model,
|
||||||
@@ -1267,8 +1385,12 @@ export function chatStreamMetadata(
|
|||||||
part: StreamMetadataPart,
|
part: StreamMetadataPart,
|
||||||
chatId: string,
|
chatId: string,
|
||||||
cumulativeStepUsage?: ChatStreamUsage,
|
cumulativeStepUsage?: ChatStreamUsage,
|
||||||
): { chatId: string } | { usage: ChatStreamUsage } | undefined {
|
// #184: the active run's id, attached alongside `chatId` on the `start` part so
|
||||||
if (part.type === 'start') return { chatId };
|
// the client learns the run it can reconnect to / stop. Omitted when the turn
|
||||||
|
// is not run-wrapped (legacy path).
|
||||||
|
runId?: string,
|
||||||
|
): { chatId: string; runId?: string } | { usage: ChatStreamUsage } | undefined {
|
||||||
|
if (part.type === 'start') return runId ? { chatId, runId } : { chatId };
|
||||||
if (part.type === 'finish-step') {
|
if (part.type === 'finish-step') {
|
||||||
return cumulativeStepUsage ? { usage: cumulativeStepUsage } : undefined;
|
return cumulativeStepUsage ? { usage: cumulativeStepUsage } : undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,30 @@ export class BoundChatDto {
|
|||||||
pageId: string;
|
pageId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconnect to the latest run of a chat (#184): fetch its persisted lifecycle
|
||||||
|
* state (and the assistant message it projects) for an in-flight or finished run.
|
||||||
|
*/
|
||||||
|
export class GetRunDto {
|
||||||
|
@IsString()
|
||||||
|
chatId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicitly STOP an agent run (#184): the user pressed Stop — distinct from a
|
||||||
|
* browser disconnect, which never stops a run. Either the run id (preferred, from
|
||||||
|
* the streamed start metadata) or the chat id (stop whatever run is active on it).
|
||||||
|
*/
|
||||||
|
export class StopRunDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
runId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
chatId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Export a chat to Markdown (#183). `lang` localizes the few fixed
|
/** Export a chat to Markdown (#183). `lang` localizes the few fixed
|
||||||
* role/tool-action labels; defaults to English server-side. */
|
* role/tool-action labels; defaults to English server-side. */
|
||||||
export class ExportChatDto {
|
export class ExportChatDto {
|
||||||
|
|||||||
@@ -610,6 +610,63 @@ describe('AiAgentRolesService guards', () => {
|
|||||||
expect(repo.insert.mock.calls[0][0].name).toBe('Researcher (2)');
|
expect(repo.insert.mock.calls[0][0].name).toBe('Researcher (2)');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('createdRoles lists the installed role (no renamedTo when not renamed)', async () => {
|
||||||
|
const { service } = makeImportService({});
|
||||||
|
const res = await service.importFromCatalog('ws-1', 'u1', dto());
|
||||||
|
expect(res.createdRoles).toEqual([
|
||||||
|
{ slug: 'researcher', name: 'Researcher' },
|
||||||
|
]);
|
||||||
|
expect(res.skippedRoles).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createdRoles carries renamedTo on a rename', async () => {
|
||||||
|
const existing = [makeRow({ id: 'r-x', name: 'Researcher' })];
|
||||||
|
const { service } = makeImportService({ existing });
|
||||||
|
const res = await service.importFromCatalog(
|
||||||
|
'ws-1',
|
||||||
|
'u1',
|
||||||
|
dto({ conflict: 'rename' }),
|
||||||
|
);
|
||||||
|
expect(res.createdRoles).toEqual([
|
||||||
|
{ slug: 'researcher', name: 'Researcher', renamedTo: 'Researcher (2)' },
|
||||||
|
]);
|
||||||
|
expect(res.skippedRoles).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skippedRoles: already-installed slug carries reason "already-installed"', async () => {
|
||||||
|
const existing = [
|
||||||
|
makeRow({
|
||||||
|
id: 'r-existing',
|
||||||
|
name: 'Old researcher',
|
||||||
|
source: { slug: 'researcher', language: 'en', version: 1 } as never,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const { service } = makeImportService({ existing });
|
||||||
|
const res = await service.importFromCatalog('ws-1', 'u1', dto());
|
||||||
|
expect(res.skippedRoles).toEqual([
|
||||||
|
{
|
||||||
|
slug: 'researcher',
|
||||||
|
name: 'Researcher',
|
||||||
|
reason: 'already-installed',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(res.createdRoles).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skippedRoles: a name collision under conflict:skip carries reason "name-conflict"', async () => {
|
||||||
|
const existing = [makeRow({ id: 'r-x', name: 'Researcher' })];
|
||||||
|
const { service } = makeImportService({ existing });
|
||||||
|
const res = await service.importFromCatalog(
|
||||||
|
'ws-1',
|
||||||
|
'u1',
|
||||||
|
dto({ conflict: 'skip' }),
|
||||||
|
);
|
||||||
|
expect(res.skippedRoles).toEqual([
|
||||||
|
{ slug: 'researcher', name: 'Researcher', reason: 'name-conflict' },
|
||||||
|
]);
|
||||||
|
expect(res.createdRoles).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('dto.slugs filters; an unknown slug becomes an error entry', async () => {
|
it('dto.slugs filters; an unknown slug becomes an error entry', async () => {
|
||||||
const { service, repo } = makeImportService({
|
const { service, repo } = makeImportService({
|
||||||
bundleRoles: [catalogRole()],
|
bundleRoles: [catalogRole()],
|
||||||
@@ -677,6 +734,15 @@ describe('AiAgentRolesService guards', () => {
|
|||||||
// 'a' converged on the concurrent install (skip); 'b' imported; no errors.
|
// 'a' converged on the concurrent install (skip); 'b' imported; no errors.
|
||||||
expect(res).toMatchObject({ created: 1, skipped: 1, renamed: 0 });
|
expect(res).toMatchObject({ created: 1, skipped: 1, renamed: 0 });
|
||||||
expect(res.errors).toEqual([]);
|
expect(res.errors).toEqual([]);
|
||||||
|
// The per-role list records 'a' as an already-installed skip (the UI reads
|
||||||
|
// skippedRoles, not the counter, to render its plaque — assert the array,
|
||||||
|
// not just the count).
|
||||||
|
expect(res.skippedRoles).toContainEqual({
|
||||||
|
slug: 'a',
|
||||||
|
name: 'A',
|
||||||
|
reason: 'already-installed',
|
||||||
|
});
|
||||||
|
expect(res.createdRoles.map((r) => r.slug)).toEqual(['b']);
|
||||||
// Both inserts were attempted (the batch did not abort on the 23505).
|
// Both inserts were attempted (the batch did not abort on the 23505).
|
||||||
expect(repo.insert).toHaveBeenCalledTimes(2);
|
expect(repo.insert).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -305,6 +305,16 @@ export class AiAgentRolesService {
|
|||||||
skipped: number;
|
skipped: number;
|
||||||
renamed: number;
|
renamed: number;
|
||||||
errors: { slug: string; message: string }[];
|
errors: { slug: string; message: string }[];
|
||||||
|
// Per-role lists alongside the counters (kept for back-compat). The redesigned
|
||||||
|
// catalog UI needs the actual roles — which were created (and any rename) and
|
||||||
|
// which were skipped and why — to render an inline result plaque with the
|
||||||
|
// conflicting role's name and a "Rename & install" affordance.
|
||||||
|
createdRoles: { slug: string; name: string; renamedTo?: string }[];
|
||||||
|
skippedRoles: {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
reason: 'name-conflict' | 'already-installed';
|
||||||
|
}[];
|
||||||
}> {
|
}> {
|
||||||
const { file, versions } = await this.loadBundleById(
|
const { file, versions } = await this.loadBundleById(
|
||||||
dto.bundleId,
|
dto.bundleId,
|
||||||
@@ -312,6 +322,13 @@ export class AiAgentRolesService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const errors: { slug: string; message: string }[] = [];
|
const errors: { slug: string; message: string }[] = [];
|
||||||
|
const createdRoles: { slug: string; name: string; renamedTo?: string }[] =
|
||||||
|
[];
|
||||||
|
const skippedRoles: {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
reason: 'name-conflict' | 'already-installed';
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
// Resolve the selected catalog roles (honor dto.slugs; flag unknown ones).
|
// Resolve the selected catalog roles (honor dto.slugs; flag unknown ones).
|
||||||
let selected = file.roles;
|
let selected = file.roles;
|
||||||
@@ -351,16 +368,27 @@ export class AiAgentRolesService {
|
|||||||
// Already installed from the catalog in THIS language => skip (use
|
// Already installed from the catalog in THIS language => skip (use
|
||||||
// update-from-catalog). A different language of the same slug still imports.
|
// update-from-catalog). A different language of the same slug still imports.
|
||||||
const installKey = `${role.slug}:${dto.language}`;
|
const installKey = `${role.slug}:${dto.language}`;
|
||||||
|
const originalName = role.name.trim();
|
||||||
if (installedKeys.has(installKey)) {
|
if (installedKeys.has(installKey)) {
|
||||||
skipped++;
|
skipped++;
|
||||||
|
skippedRoles.push({
|
||||||
|
slug: role.slug,
|
||||||
|
name: originalName,
|
||||||
|
reason: 'already-installed',
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let name = role.name.trim();
|
let name = originalName;
|
||||||
let didRename = false;
|
let didRename = false;
|
||||||
if (takenNames.has(name.toLowerCase())) {
|
if (takenNames.has(name.toLowerCase())) {
|
||||||
if (dto.conflict === 'skip') {
|
if (dto.conflict === 'skip') {
|
||||||
skipped++;
|
skipped++;
|
||||||
|
skippedRoles.push({
|
||||||
|
slug: role.slug,
|
||||||
|
name: originalName,
|
||||||
|
reason: 'name-conflict',
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// conflict === 'rename': find a free " (N)" suffix.
|
// conflict === 'rename': find a free " (N)" suffix.
|
||||||
@@ -380,6 +408,11 @@ export class AiAgentRolesService {
|
|||||||
});
|
});
|
||||||
created++;
|
created++;
|
||||||
if (didRename) renamed++;
|
if (didRename) renamed++;
|
||||||
|
createdRoles.push({
|
||||||
|
slug: role.slug,
|
||||||
|
name: originalName,
|
||||||
|
...(didRename ? { renamedTo: name } : {}),
|
||||||
|
});
|
||||||
takenNames.add(name.toLowerCase());
|
takenNames.add(name.toLowerCase());
|
||||||
installedKeys.add(installKey);
|
installedKeys.add(installKey);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -391,6 +424,11 @@ export class AiAgentRolesService {
|
|||||||
// skipped (already installed) and continue; do NOT abort or error.
|
// skipped (already installed) and continue; do NOT abort or error.
|
||||||
if (isSourceUniqueViolation(err)) {
|
if (isSourceUniqueViolation(err)) {
|
||||||
skipped++;
|
skipped++;
|
||||||
|
skippedRoles.push({
|
||||||
|
slug: role.slug,
|
||||||
|
name: originalName,
|
||||||
|
reason: 'already-installed',
|
||||||
|
});
|
||||||
installedKeys.add(installKey);
|
installedKeys.add(installKey);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -407,7 +445,7 @@ export class AiAgentRolesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { created, skipped, renamed, errors };
|
return { created, skipped, renamed, errors, createdRoles, skippedRoles };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -539,3 +539,115 @@ describe('AiChatToolsService model-friendly input validation (#190)', () => {
|
|||||||
expect(result.error?.message).toContain('parameter "pageId": missing (required)');
|
expect(result.error?.message).toContain('parameter "pageId": missing (required)');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #294 F1 — the contract-parity test introspects only the ADVERTISED schema keys
|
||||||
|
* (buildShape), not the execute bodies. Most execs are unchanged pass-throughs,
|
||||||
|
* but two wirings actually CHANGED in the migration and are otherwise untested:
|
||||||
|
* - movePage now forwards the newly-added optional `position` field to the
|
||||||
|
* client (client.movePage(pageId, parentPageId, position));
|
||||||
|
* - the table trio unified its `tableRef` param to `table` and must forward it
|
||||||
|
* positionally. A field destructured under the wrong name would silently pass
|
||||||
|
* `undefined` to the client (execute is `any`-cast, so tsc won't catch it).
|
||||||
|
*/
|
||||||
|
describe('AiChatToolsService #294 changed execute wirings', () => {
|
||||||
|
const calls: Record<string, unknown[][]> = {
|
||||||
|
movePage: [],
|
||||||
|
tableInsertRow: [],
|
||||||
|
tableDeleteRow: [],
|
||||||
|
tableUpdateCell: [],
|
||||||
|
};
|
||||||
|
const fakeClient: Partial<DocmostClientLike> = {
|
||||||
|
movePage: (...args: unknown[]) => {
|
||||||
|
calls.movePage.push(args);
|
||||||
|
return Promise.resolve({ success: true });
|
||||||
|
},
|
||||||
|
tableInsertRow: (...args: unknown[]) => {
|
||||||
|
calls.tableInsertRow.push(args);
|
||||||
|
return Promise.resolve({ ok: true });
|
||||||
|
},
|
||||||
|
tableDeleteRow: (...args: unknown[]) => {
|
||||||
|
calls.tableDeleteRow.push(args);
|
||||||
|
return Promise.resolve({ ok: true });
|
||||||
|
},
|
||||||
|
tableUpdateCell: (...args: unknown[]) => {
|
||||||
|
calls.tableUpdateCell.push(args);
|
||||||
|
return Promise.resolve({ ok: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const tokenServiceStub = {
|
||||||
|
generateAccessToken: jest.fn().mockResolvedValue('access-token'),
|
||||||
|
generateCollabToken: jest.fn().mockResolvedValue('collab-token'),
|
||||||
|
};
|
||||||
|
let service: AiChatToolsService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const k of Object.keys(calls)) calls[k].length = 0;
|
||||||
|
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,
|
||||||
|
{
|
||||||
|
asSink: () => ({ put: jest.fn(), has: jest.fn(), evict: jest.fn() }),
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
const buildTools = () =>
|
||||||
|
service.forUser(
|
||||||
|
{ id: 'user-1', email: 'u@example.com', workspaceId: 'ws-1' } as never,
|
||||||
|
'session-1',
|
||||||
|
'ws-1',
|
||||||
|
'chat-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
it('movePage forwards the optional position to the client', async () => {
|
||||||
|
const tools = await buildTools();
|
||||||
|
await tools.movePage.execute(
|
||||||
|
{ pageId: 'p1', parentPageId: 'parent1', position: 'a5' } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
expect(calls.movePage).toEqual([['p1', 'parent1', 'a5']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('movePage passes undefined position and null parent when omitted (unchanged behavior)', async () => {
|
||||||
|
const tools = await buildTools();
|
||||||
|
await tools.movePage.execute({ pageId: 'p2' } as never, {} as never);
|
||||||
|
expect(calls.movePage).toEqual([['p2', null, undefined]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tableInsertRow forwards the unified `table` param positionally', async () => {
|
||||||
|
const tools = await buildTools();
|
||||||
|
await tools.tableInsertRow.execute(
|
||||||
|
{ pageId: 'p1', table: '#0', cells: ['a', 'b'], index: 2 } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
expect(calls.tableInsertRow).toEqual([['p1', '#0', ['a', 'b'], 2]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tableDeleteRow forwards `table` positionally', async () => {
|
||||||
|
const tools = await buildTools();
|
||||||
|
await tools.tableDeleteRow.execute(
|
||||||
|
{ pageId: 'p1', table: '#0', index: 1 } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
expect(calls.tableDeleteRow).toEqual([['p1', '#0', 1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tableUpdateCell forwards `table` positionally', async () => {
|
||||||
|
const tools = await buildTools();
|
||||||
|
await tools.tableUpdateCell.execute(
|
||||||
|
{ pageId: 'p1', table: '#0', row: 1, col: 2, text: 'x' } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
expect(calls.tableUpdateCell).toEqual([['p1', '#0', 1, 2, 'x']]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -316,16 +316,9 @@ export class AiChatToolsService {
|
|||||||
execute: async () => resolveCurrentPageResult(openedPage),
|
execute: async () => resolveCurrentPageResult(openedPage),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getPage: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
// The execute body keeps this layer's { title, markdown } projection.
|
||||||
'Fetch a single page as Markdown by its page id. Returns the page ' +
|
getPage: sharedTool(sharedToolSpecs.getPage, async ({ pageId }) => {
|
||||||
'title and its Markdown content. Inline <span data-comment-id> tags ' +
|
|
||||||
'in the markdown are comment highlight anchors (also present for ' +
|
|
||||||
'RESOLVED threads) — treat them as markup, not page text.',
|
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id (or slugId) of the page.'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId }) => {
|
|
||||||
// getPage(pageId) -> { data: filterPage(page, markdown), success }.
|
// getPage(pageId) -> { data: filterPage(page, markdown), success }.
|
||||||
const result = await client.getPage(pageId);
|
const result = await client.getPage(pageId);
|
||||||
const data = (result?.data ?? {}) as {
|
const data = (result?.data ?? {}) as {
|
||||||
@@ -336,30 +329,14 @@ export class AiChatToolsService {
|
|||||||
title: data.title ?? '',
|
title: data.title ?? '',
|
||||||
markdown: typeof data.content === 'string' ? data.content : '',
|
markdown: typeof data.content === 'string' ? data.content : '',
|
||||||
};
|
};
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// --- WRITE tools (all reversible — history/trash; §6.5 / D3) ---
|
// --- WRITE tools (all reversible — history/trash; §6.5 / D3) ---
|
||||||
|
|
||||||
createPage: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
createPage: sharedTool(
|
||||||
'Create a new page with a Markdown body in a space, optionally under ' +
|
sharedToolSpecs.createPage,
|
||||||
'a parent page. Returns the new page id and title. Reversible: a page ' +
|
async ({ title, content, spaceId, parentPageId }) => {
|
||||||
'can be moved to trash later.',
|
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
title: z.string().describe('The title of the new page.'),
|
|
||||||
content: z
|
|
||||||
.string()
|
|
||||||
.describe('The page body as Markdown (may be empty).'),
|
|
||||||
spaceId: z
|
|
||||||
.string()
|
|
||||||
.describe('The id of the space to create the page in.'),
|
|
||||||
parentPageId: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe('Optional parent page id to nest the new page under.'),
|
|
||||||
}),
|
|
||||||
execute: async ({ title, content, spaceId, parentPageId }) => {
|
|
||||||
// createPage(title, content, spaceId, parentPageId?) ->
|
// createPage(title, content, spaceId, parentPageId?) ->
|
||||||
// { data: filterPage(page, markdown), success }.
|
// { data: filterPage(page, markdown), success }.
|
||||||
const result = await client.createPage(
|
const result = await client.createPage(
|
||||||
@@ -375,7 +352,7 @@ export class AiChatToolsService {
|
|||||||
};
|
};
|
||||||
return { id: data.id ?? data.slugId, title: data.title ?? title };
|
return { id: data.id ?? data.slugId, title: data.title ?? title };
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
updatePageContent: tool({
|
updatePageContent: tool({
|
||||||
description:
|
description:
|
||||||
@@ -399,115 +376,46 @@ export class AiChatToolsService {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
renamePage: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
renamePage: sharedTool(
|
||||||
"Rename a page (change its title only; the body is untouched). " +
|
sharedToolSpecs.renamePage,
|
||||||
'Reversible: rename back at any time.',
|
async ({ pageId, title }) => {
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id of the page to rename.'),
|
|
||||||
title: z.string().describe('The new title.'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId, title }) => {
|
|
||||||
// renamePage(pageId, title) -> { success, pageId, title }.
|
// renamePage(pageId, title) -> { success, pageId, title }.
|
||||||
await client.renamePage(pageId, title);
|
await client.renamePage(pageId, title);
|
||||||
return { pageId, title };
|
return { pageId, title };
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
|
|
||||||
movePage: tool({
|
|
||||||
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: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id of the page to move.'),
|
|
||||||
parentPageId: z
|
|
||||||
.string()
|
|
||||||
.nullable()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'Target parent page id. Null/omitted moves the page to the ' +
|
|
||||||
'space root.',
|
|
||||||
),
|
),
|
||||||
}),
|
|
||||||
execute: async ({ pageId, parentPageId }) => {
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
|
// The shared schema adds the optional `position` field this layer lacked
|
||||||
|
// before; the execute now forwards it (the client already accepted it).
|
||||||
|
movePage: sharedTool(
|
||||||
|
sharedToolSpecs.movePage,
|
||||||
|
async ({ pageId, parentPageId, position }) => {
|
||||||
// movePage(pageId, parentPageId, position?) -> raw move response.
|
// movePage(pageId, parentPageId, position?) -> raw move response.
|
||||||
await client.movePage(pageId, parentPageId ?? null);
|
await client.movePage(pageId, parentPageId ?? null, position);
|
||||||
return { pageId, parentPageId: parentPageId ?? null, moved: true };
|
return { pageId, parentPageId: parentPageId ?? null, moved: true };
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
deletePage: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
// GUARDRAIL (§14 H4) preserved: the shared schema exposes ONLY pageId, so
|
||||||
'Move a page to the trash (SOFT delete only — fully reversible; the ' +
|
// permanentlyDelete/forceDelete are never part of the input and can never
|
||||||
'page can be restored from trash). This NEVER permanently deletes.',
|
// be forwarded — the agent physically cannot permanently delete a page.
|
||||||
inputSchema: modelFriendlyInput({
|
deletePage: sharedTool(sharedToolSpecs.deletePage, async ({ pageId }) => {
|
||||||
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
|
|
||||||
// pageId. permanentlyDelete/forceDelete are not part of the schema and
|
|
||||||
// are never forwarded, so the agent physically cannot permanently
|
|
||||||
// delete a page through this tool.
|
|
||||||
execute: async ({ pageId }) => {
|
|
||||||
// deletePage(pageId) hits POST /pages/delete with { pageId } only,
|
// deletePage(pageId) hits POST /pages/delete with { pageId } only,
|
||||||
// which is the soft-delete (trash) path on the server.
|
// which is the soft-delete (trash) path on the server.
|
||||||
await client.deletePage(pageId);
|
await client.deletePage(pageId);
|
||||||
return { pageId, trashed: true };
|
return { pageId, trashed: true };
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// INTENTIONAL per-transport divergence (not shared): the description is
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
// tuned for the in-app agent (e.g. "retry with a corrected EXACT selection"
|
// This layer keeps only its own execute-side guards (require a selection
|
||||||
// and "Reversible via the comment UI"); the standalone MCP `create_comment`
|
// for a top-level comment; reject suggestedText on a reply / without a
|
||||||
// keeps its own wording. Kept per-layer.
|
// selection) — the schema+description are shared.
|
||||||
createComment: tool({
|
createComment: sharedTool(
|
||||||
description:
|
sharedToolSpecs.createComment,
|
||||||
'Add an INLINE comment to a page, or reply to an existing top-level ' +
|
async ({
|
||||||
'comment (one level only — the backend rejects replies to replies). ' +
|
|
||||||
'The comment is anchored inline to the given exact `selection` text ' +
|
|
||||||
'(which gets highlighted); page-level comments are NOT supported. A ' +
|
|
||||||
"new top-level comment REQUIRES a `selection`. Replies inherit the " +
|
|
||||||
"parent's anchor and take no selection. If the call fails with a " +
|
|
||||||
'"selection not found" error, retry with a corrected EXACT selection ' +
|
|
||||||
'copied verbatim from a single paragraph/block. You may also attach a ' +
|
|
||||||
'`suggestedText` proposing a replacement for the `selection` (a human ' +
|
|
||||||
'applies it from the UI); when set, the `selection` must occur exactly ' +
|
|
||||||
'once in the page. Reversible via the comment UI.',
|
|
||||||
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
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.max(250)
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'EXACT contiguous text from a SINGLE paragraph/block to anchor ' +
|
|
||||||
'(highlight) the comment on (<=250 chars, avoid spanning across ' +
|
|
||||||
'formatting boundaries). Required for a new top-level comment; ' +
|
|
||||||
'omit only when replying via parentCommentId.',
|
|
||||||
),
|
|
||||||
parentCommentId: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'Optional id of a TOP-LEVEL comment to reply to (one level ' +
|
|
||||||
'of replies only).',
|
|
||||||
),
|
|
||||||
suggestedText: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.max(2000)
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'Optional proposed replacement (PLAIN TEXT) for the `selection`, ' +
|
|
||||||
'applied by a human via the UI (never auto-applied). REQUIRES a ' +
|
|
||||||
'`selection`; NOT allowed on a reply. When set, the `selection` ' +
|
|
||||||
'must be UNIQUE in the page — expand it with surrounding context ' +
|
|
||||||
'(still <=250 chars) if it occurs more than once, or the call is ' +
|
|
||||||
'refused.',
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
execute: async ({
|
|
||||||
pageId,
|
pageId,
|
||||||
content,
|
content,
|
||||||
selection,
|
selection,
|
||||||
@@ -548,26 +456,17 @@ export class AiChatToolsService {
|
|||||||
const data = (result?.data ?? {}) as { id?: string };
|
const data = (result?.data ?? {}) as { id?: string };
|
||||||
return { commentId: data.id, pageId };
|
return { commentId: data.id, pageId };
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
resolveComment: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
resolveComment: sharedTool(
|
||||||
'Resolve or reopen a top-level comment thread (reversible — toggle ' +
|
sharedToolSpecs.resolveComment,
|
||||||
'the resolved flag). Only top-level comments can be resolved.',
|
async ({ commentId, resolved }) => {
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
commentId: z
|
|
||||||
.string()
|
|
||||||
.describe('The id of the top-level comment to resolve/reopen.'),
|
|
||||||
resolved: z
|
|
||||||
.boolean()
|
|
||||||
.describe('true to resolve the thread, false to reopen it.'),
|
|
||||||
}),
|
|
||||||
execute: async ({ commentId, resolved }) => {
|
|
||||||
// resolveComment(commentId, resolved) -> { success, commentId, resolved }.
|
// resolveComment(commentId, resolved) -> { success, commentId, resolved }.
|
||||||
await client.resolveComment(commentId, resolved);
|
await client.resolveComment(commentId, resolved);
|
||||||
return { commentId, resolved };
|
return { commentId, resolved };
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
// --- READ tools (added) ---
|
// --- READ tools (added) ---
|
||||||
|
|
||||||
@@ -585,33 +484,12 @@ export class AiChatToolsService {
|
|||||||
// hierarchy mode but is worded for the in-app agent; the standalone MCP
|
// hierarchy mode but is worded for the in-app agent; the standalone MCP
|
||||||
// `list_pages` carries its own wording. Kept per-layer so each side tunes
|
// `list_pages` carries its own wording. Kept per-layer so each side tunes
|
||||||
// its own guidance.
|
// its own guidance.
|
||||||
listPages: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
listPages: sharedTool(
|
||||||
'List the most recent pages, optionally scoped to a single space. ' +
|
sharedToolSpecs.listPages,
|
||||||
'Returns a bounded list (default 50, max 100). Pass tree:true (with ' +
|
async ({ spaceId, limit, tree }) =>
|
||||||
"spaceId) to instead get the space's full page hierarchy as a nested tree.",
|
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
spaceId: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe('Optional space id to scope the listing to.'),
|
|
||||||
limit: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.min(1)
|
|
||||||
.max(100)
|
|
||||||
.optional()
|
|
||||||
.describe('Maximum number of pages (1-100).'),
|
|
||||||
tree: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'When true, return the full page hierarchy of the given space as a nested tree (children arrays) instead of the recent-pages flat list. Requires spaceId; ignores limit.',
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
execute: async ({ spaceId, limit, tree }) =>
|
|
||||||
await client.listPages(spaceId, limit, tree),
|
await client.listPages(spaceId, limit, tree),
|
||||||
}),
|
),
|
||||||
|
|
||||||
listSidebarPages: tool({
|
listSidebarPages: tool({
|
||||||
description:
|
description:
|
||||||
@@ -656,41 +534,34 @@ export class AiChatToolsService {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// NOT shared (kept inline): the MCP tool name `table_get` is noun-first
|
||||||
|
// while this key is `getTable` (verb-first), breaking the
|
||||||
|
// snake_case(inAppKey) convention the shared registry enforces. Its
|
||||||
|
// reference parameter is still named `table` (was `tableRef`) so it matches
|
||||||
|
// the migrated table row/cell tools below.
|
||||||
getTable: tool({
|
getTable: tool({
|
||||||
description:
|
description:
|
||||||
'Read a table as a matrix of cell texts (plus a parallel cellIds ' +
|
'Read a table as a matrix of cell texts (plus a parallel cellIds ' +
|
||||||
'matrix so cells can be addressed for rich edits).',
|
'matrix so cells can be addressed for rich edits).',
|
||||||
inputSchema: modelFriendlyInput({
|
inputSchema: modelFriendlyInput({
|
||||||
pageId: z.string().describe('The id of the page.'),
|
pageId: z.string().describe('The id of the page.'),
|
||||||
tableRef: z
|
table: z
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe(
|
||||||
'"#<index>" from getOutline, or a block id of any node inside ' +
|
'"#<index>" from the page outline, or a block id of any node ' +
|
||||||
'the table.',
|
'inside the table.',
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
execute: async ({ pageId, tableRef }) =>
|
execute: async ({ pageId, table }) =>
|
||||||
await client.getTable(pageId, tableRef),
|
await client.getTable(pageId, table),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
listComments: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
listComments: sharedTool(
|
||||||
'List comments on a page in one call. By DEFAULT only ACTIVE ' +
|
sharedToolSpecs.listComments,
|
||||||
'threads are returned; resolved threads (a resolved top-level ' +
|
async ({ pageId, includeResolved }) =>
|
||||||
'comment and all its replies) are hidden and their count reported ' +
|
|
||||||
'as `resolvedThreadsHidden` so you can re-query with ' +
|
|
||||||
'`includeResolved: true` to see everything. Returns ' +
|
|
||||||
'`{ items, resolvedThreadsHidden }`. Content is returned as Markdown.',
|
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id of the page.'),
|
|
||||||
includeResolved: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.describe('default only active threads; true — include resolved'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId, includeResolved }) =>
|
|
||||||
await client.listComments(pageId, includeResolved),
|
await client.listComments(pageId, includeResolved),
|
||||||
}),
|
),
|
||||||
|
|
||||||
getComment: tool({
|
getComment: tool({
|
||||||
description: 'Fetch a single comment by id (content as Markdown).',
|
description: 'Fetch a single comment by id (content as Markdown).',
|
||||||
@@ -700,26 +571,12 @@ export class AiChatToolsService {
|
|||||||
execute: async ({ commentId }) => await client.getComment(commentId),
|
execute: async ({ commentId }) => await client.getComment(commentId),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
checkNewComments: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
checkNewComments: sharedTool(
|
||||||
'Find new comments across a space (optionally scoped to a subtree) ' +
|
sharedToolSpecs.checkNewComments,
|
||||||
'created after a given timestamp.',
|
async ({ spaceId, since, parentPageId }) =>
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
spaceId: z.string().describe('The id of the space to scan.'),
|
|
||||||
since: z
|
|
||||||
.string()
|
|
||||||
.describe('An ISO-8601 timestamp; only comments created after it.'),
|
|
||||||
parentPageId: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'Optional page id to scope the scan to that page and its ' +
|
|
||||||
'descendants.',
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
execute: async ({ spaceId, since, parentPageId }) =>
|
|
||||||
await client.checkNewComments(spaceId, since, parentPageId),
|
await client.checkNewComments(spaceId, since, parentPageId),
|
||||||
}),
|
),
|
||||||
|
|
||||||
listShares: sharedTool(
|
listShares: sharedTool(
|
||||||
sharedToolSpecs.listShares,
|
sharedToolSpecs.listShares,
|
||||||
@@ -749,19 +606,14 @@ export class AiChatToolsService {
|
|||||||
await client.diffPageVersions(pageId, from, to),
|
await client.diffPageVersions(pageId, from, to),
|
||||||
),
|
),
|
||||||
|
|
||||||
exportPageMarkdown: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
exportPageMarkdown: sharedTool(
|
||||||
'Export a page to a single self-contained Docmost-flavoured ' +
|
sharedToolSpecs.exportPageMarkdown,
|
||||||
'Markdown file (meta + body + comment threads). Lossless round-trip ' +
|
async ({ pageId }) => {
|
||||||
'with importPageMarkdown.',
|
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id of the page to export.'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId }) => {
|
|
||||||
const markdown = await client.exportPageMarkdown(pageId);
|
const markdown = await client.exportPageMarkdown(pageId);
|
||||||
return { markdown };
|
return { markdown };
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
// --- WRITE tools (added; reversible via page history/trash) ---
|
// --- WRITE tools (added; reversible via page history/trash) ---
|
||||||
|
|
||||||
@@ -811,28 +663,12 @@ export class AiChatToolsService {
|
|||||||
async ({ pageId, nodeId }) => await client.deleteNode(pageId, nodeId),
|
async ({ pageId, nodeId }) => await client.deleteNode(pageId, nodeId),
|
||||||
),
|
),
|
||||||
|
|
||||||
updatePageJson: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
// The execute body keeps this layer's content normalization (parity with
|
||||||
"Replace a page's body with a full ProseMirror document — a full " +
|
// the standalone MCP server, index.ts update_page_json).
|
||||||
'overwrite — and/or update its title. Minimal example content: ' +
|
updatePageJson: sharedTool(
|
||||||
'{"type":"doc","content":[{"type":"paragraph","content":' +
|
sharedToolSpecs.updatePageJson,
|
||||||
'[{"type":"text","text":"Hi"}]}]}. The content arg may be a JSON ' +
|
async ({ pageId, content, title }) => {
|
||||||
'object or a JSON string (both accepted). Omit content for a ' +
|
|
||||||
'title-only update. Reversible: the previous version is kept in page ' +
|
|
||||||
'history.',
|
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id of the page to update.'),
|
|
||||||
content: z
|
|
||||||
.any()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'Full ProseMirror doc {"type":"doc","content":[...]} (JSON ' +
|
|
||||||
'object or JSON string); omit for a title-only update.',
|
|
||||||
),
|
|
||||||
title: z.string().optional().describe('Optional new title.'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId, content, title }) => {
|
|
||||||
// Parity with the standalone MCP server (index.ts update_page_json):
|
|
||||||
// undefined/null pass through as undefined (title-only / no-op); any
|
// undefined/null pass through as undefined (title-only / no-op); any
|
||||||
// string is JSON.parsed (so an empty string "" throws, matching the
|
// string is JSON.parsed (so an empty string "" throws, matching the
|
||||||
// MCP server); an object is passed through unchanged.
|
// MCP server); an object is passed through unchanged.
|
||||||
@@ -845,66 +681,29 @@ export class AiChatToolsService {
|
|||||||
}
|
}
|
||||||
return await client.updatePageJson(pageId, doc, title);
|
return await client.updatePageJson(pageId, doc, title);
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
// NOT in the shared registry: this layer names the table argument
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
// `tableRef`, while the standalone MCP tool names it `table` (index.ts).
|
// The table reference parameter was unified to `table` (was `tableRef`).
|
||||||
// Sharing one buildShape would rename a model-facing parameter on one
|
tableInsertRow: sharedTool(
|
||||||
// transport, so the table row/cell tools stay per-layer by design.
|
sharedToolSpecs.tableInsertRow,
|
||||||
tableInsertRow: tool({
|
async ({ pageId, table, cells, index }) =>
|
||||||
description:
|
await client.tableInsertRow(pageId, table, cells, index),
|
||||||
'Insert a row of plain-text cells into a table. Reversible via ' +
|
),
|
||||||
'page history.',
|
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id of the page.'),
|
|
||||||
tableRef: z
|
|
||||||
.string()
|
|
||||||
.describe('"#<index>" from getOutline, or a block id in the table.'),
|
|
||||||
cells: z.array(z.string()).describe('The cell texts for the row.'),
|
|
||||||
index: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.optional()
|
|
||||||
.describe('0-based insert position (omit/out-of-range to append).'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId, tableRef, cells, index }) =>
|
|
||||||
await client.tableInsertRow(pageId, tableRef, cells, index),
|
|
||||||
}),
|
|
||||||
|
|
||||||
// NOT shared — same `tableRef` (here) vs `table` (MCP) parameter-name
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
// divergence as tableInsertRow.
|
tableDeleteRow: sharedTool(
|
||||||
tableDeleteRow: tool({
|
sharedToolSpecs.tableDeleteRow,
|
||||||
description:
|
async ({ pageId, table, index }) =>
|
||||||
'Delete a table row at a 0-based index. Reversible via page history.',
|
await client.tableDeleteRow(pageId, table, index),
|
||||||
inputSchema: modelFriendlyInput({
|
),
|
||||||
pageId: z.string().describe('The id of the page.'),
|
|
||||||
tableRef: z
|
|
||||||
.string()
|
|
||||||
.describe('"#<index>" from getOutline, or a block id in the table.'),
|
|
||||||
index: z.number().int().describe('0-based row index to delete.'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId, tableRef, index }) =>
|
|
||||||
await client.tableDeleteRow(pageId, tableRef, index),
|
|
||||||
}),
|
|
||||||
|
|
||||||
// NOT shared — same `tableRef` (here) vs `table` (MCP) parameter-name
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
// divergence as tableInsertRow.
|
tableUpdateCell: sharedTool(
|
||||||
tableUpdateCell: tool({
|
sharedToolSpecs.tableUpdateCell,
|
||||||
description:
|
async ({ pageId, table, row, col, text }) =>
|
||||||
'Set the plain-text content of a table cell at [row, col] (0-based). ' +
|
await client.tableUpdateCell(pageId, table, row, col, text),
|
||||||
'Reversible via page history.',
|
),
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id of the page.'),
|
|
||||||
tableRef: z
|
|
||||||
.string()
|
|
||||||
.describe('"#<index>" from getOutline, or a block id in the table.'),
|
|
||||||
row: z.number().int().describe('0-based row index.'),
|
|
||||||
col: z.number().int().describe('0-based column index.'),
|
|
||||||
text: z.string().describe('The new cell text.'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId, tableRef, row, col, text }) =>
|
|
||||||
await client.tableUpdateCell(pageId, tableRef, row, col, text),
|
|
||||||
}),
|
|
||||||
|
|
||||||
copyPageContent: sharedTool(
|
copyPageContent: sharedTool(
|
||||||
sharedToolSpecs.copyPageContent,
|
sharedToolSpecs.copyPageContent,
|
||||||
@@ -918,25 +717,14 @@ export class AiChatToolsService {
|
|||||||
await client.importPageMarkdown(pageId, markdown),
|
await client.importPageMarkdown(pageId, markdown),
|
||||||
),
|
),
|
||||||
|
|
||||||
// INTENTIONAL per-transport divergence (not shared): adds a security
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
// confirmation framing ("Only share when the user explicitly asked, since
|
// Both layers already carried the security-confirmation framing, so there
|
||||||
// this exposes the page to anyone with the link") for the in-app agent; the
|
// was no real divergence to preserve — only wording drift.
|
||||||
// standalone MCP `share_page` keeps the plain public-URL wording.
|
sharePage: sharedTool(
|
||||||
sharePage: tool({
|
sharedToolSpecs.sharePage,
|
||||||
description:
|
async ({ pageId, searchIndexing }) =>
|
||||||
'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: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id of the page to share.'),
|
|
||||||
searchIndexing: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.describe('Allow public search engines to index it (default true).'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId, searchIndexing }) =>
|
|
||||||
await client.sharePage(pageId, searchIndexing),
|
await client.sharePage(pageId, searchIndexing),
|
||||||
}),
|
),
|
||||||
|
|
||||||
unsharePage: sharedTool(
|
unsharePage: sharedTool(
|
||||||
sharedToolSpecs.unsharePage,
|
sharedToolSpecs.unsharePage,
|
||||||
|
|||||||
@@ -100,54 +100,26 @@ export const INLINE_TOOL_TIERS: Record<
|
|||||||
tier: 'core',
|
tier: 'core',
|
||||||
catalogLine: 'getCurrentPage — the page the user is currently viewing.',
|
catalogLine: 'getCurrentPage — the page the user is currently viewing.',
|
||||||
},
|
},
|
||||||
getPage: {
|
// NOTE: getPage and listPages moved to @docmost/mcp's SHARED_TOOL_SPECS
|
||||||
tier: 'core',
|
// (#294); they carry their own tier ('core') + catalogLine there.
|
||||||
catalogLine: 'getPage — fetch a page as Markdown by its id.',
|
// NOTE: createComment, listComments and resolveComment moved to
|
||||||
},
|
// @docmost/mcp's SHARED_TOOL_SPECS (#294); they carry their own tier +
|
||||||
listPages: {
|
// catalogLine there. getComment stays inline (MCP-only shape divergence is
|
||||||
tier: 'core',
|
// n/a — it simply has no shared spec).
|
||||||
catalogLine: "listPages — list recent pages, or a space's full page tree.",
|
|
||||||
},
|
|
||||||
listComments: {
|
|
||||||
tier: 'core',
|
|
||||||
catalogLine: 'listComments — list all comments on a page (including resolved).',
|
|
||||||
},
|
|
||||||
getComment: {
|
getComment: {
|
||||||
tier: 'core',
|
tier: 'core',
|
||||||
catalogLine: 'getComment — fetch a single comment by id.',
|
catalogLine: 'getComment — fetch a single comment by id.',
|
||||||
},
|
},
|
||||||
createComment: {
|
|
||||||
tier: 'core',
|
|
||||||
catalogLine:
|
|
||||||
'createComment — add an inline comment (optionally with a suggested edit).',
|
|
||||||
},
|
|
||||||
resolveComment: {
|
|
||||||
tier: 'core',
|
|
||||||
catalogLine: 'resolveComment — resolve or reopen a comment thread.',
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- deferred inline ---
|
// --- deferred inline ---
|
||||||
createPage: {
|
// NOTE: createPage, renamePage, movePage, deletePage, updatePageJson and
|
||||||
tier: 'deferred',
|
// exportPageMarkdown moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); they
|
||||||
catalogLine: 'createPage — create a new page with a Markdown body in a space.',
|
// carry their own deferred tier + catalogLine there.
|
||||||
},
|
|
||||||
updatePageContent: {
|
updatePageContent: {
|
||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine:
|
catalogLine:
|
||||||
"updatePageContent — replace a page's body (and optionally title) with new Markdown.",
|
"updatePageContent — replace a page's body (and optionally title) with new Markdown.",
|
||||||
},
|
},
|
||||||
renamePage: {
|
|
||||||
tier: 'deferred',
|
|
||||||
catalogLine: "renamePage — change a page's title only (body untouched).",
|
|
||||||
},
|
|
||||||
movePage: {
|
|
||||||
tier: 'deferred',
|
|
||||||
catalogLine: 'movePage — move a page under a new parent or to the space root.',
|
|
||||||
},
|
|
||||||
deletePage: {
|
|
||||||
tier: 'deferred',
|
|
||||||
catalogLine: 'deletePage — move a page to trash (soft delete, reversible).',
|
|
||||||
},
|
|
||||||
listSidebarPages: {
|
listSidebarPages: {
|
||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine:
|
catalogLine:
|
||||||
@@ -157,42 +129,21 @@ export const INLINE_TOOL_TIERS: Record<
|
|||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine: 'getTable — read a table as a matrix of cell texts and cell ids.',
|
catalogLine: 'getTable — read a table as a matrix of cell texts and cell ids.',
|
||||||
},
|
},
|
||||||
checkNewComments: {
|
// NOTE: tableInsertRow, tableDeleteRow and tableUpdateCell moved to
|
||||||
tier: 'deferred',
|
// @docmost/mcp's SHARED_TOOL_SPECS (#294); they carry their own deferred tier +
|
||||||
catalogLine:
|
// catalogLine there. getTable stays inline (its MCP name table_get breaks the
|
||||||
'checkNewComments — find comments in a space created after a timestamp.',
|
// snake_case(inAppKey) convention, so it has no shared spec).
|
||||||
},
|
// NOTE: checkNewComments moved to @docmost/mcp's SHARED_TOOL_SPECS (#294);
|
||||||
|
// it carries its own deferred tier + catalogLine there.
|
||||||
getPageHistory: {
|
getPageHistory: {
|
||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine:
|
catalogLine:
|
||||||
'getPageHistory — fetch one page-history version with its ProseMirror content.',
|
'getPageHistory — fetch one page-history version with its ProseMirror content.',
|
||||||
},
|
},
|
||||||
exportPageMarkdown: {
|
// NOTE: sharePage moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); it carries
|
||||||
tier: 'deferred',
|
// its own deferred tier + catalogLine there. transformPage stays inline (its
|
||||||
catalogLine:
|
// schema deliberately diverges — it omits the deleteComments field the MCP
|
||||||
'exportPageMarkdown — export a page to self-contained Markdown (body + comments).',
|
// docmost_transform exposes, a comment-deletion guardrail).
|
||||||
},
|
|
||||||
updatePageJson: {
|
|
||||||
tier: 'deferred',
|
|
||||||
catalogLine:
|
|
||||||
"updatePageJson — overwrite a page's body with a full ProseMirror document.",
|
|
||||||
},
|
|
||||||
tableInsertRow: {
|
|
||||||
tier: 'deferred',
|
|
||||||
catalogLine: 'tableInsertRow — insert a row of plain-text cells into a table.',
|
|
||||||
},
|
|
||||||
tableDeleteRow: {
|
|
||||||
tier: 'deferred',
|
|
||||||
catalogLine: 'tableDeleteRow — delete a table row at a 0-based index.',
|
|
||||||
},
|
|
||||||
tableUpdateCell: {
|
|
||||||
tier: 'deferred',
|
|
||||||
catalogLine: 'tableUpdateCell — set the text of a table cell at [row, col].',
|
|
||||||
},
|
|
||||||
sharePage: {
|
|
||||||
tier: 'deferred',
|
|
||||||
catalogLine: 'sharePage — make a page publicly accessible and return its URL.',
|
|
||||||
},
|
|
||||||
transformPage: {
|
transformPage: {
|
||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine: "transformPage — run a sandboxed JS transform over a page's document.",
|
catalogLine: "transformPage — run a sandboxed JS transform over a page's document.",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
AUTH_THROTTLER,
|
AUTH_THROTTLER,
|
||||||
PAGE_TEMPLATE_THROTTLER,
|
PAGE_TEMPLATE_THROTTLER,
|
||||||
PUBLIC_SHARE_AI_THROTTLER,
|
PUBLIC_SHARE_AI_THROTTLER,
|
||||||
|
VITALS_THROTTLER,
|
||||||
} from '../../integrations/throttle/throttler-names';
|
} from '../../integrations/throttle/throttler-names';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { AuthService } from './services/auth.service';
|
import { AuthService } from './services/auth.service';
|
||||||
@@ -184,16 +185,21 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The global ThrottlerGuard applies ALL named throttlers to every route by
|
// The global ThrottlerGuard applies ALL named throttlers to every route by
|
||||||
// default, so each non-AUTH bucket (AI chat, page template, public-share AI)
|
// default, so each non-AUTH bucket (AI chat, page template, public-share AI,
|
||||||
// is explicitly skipped here. collab-token is auth-guarded (JwtAuthGuard),
|
// client vitals) is explicitly skipped here. collab-token is auth-guarded
|
||||||
// per-user and client-cached, so those feature buckets are irrelevant to it;
|
// (JwtAuthGuard), per-user and client-cached, so those feature buckets are
|
||||||
// skipping them avoids spurious 429s when a user opens many pages in a short
|
// irrelevant to it; skipping them avoids spurious 429s when a user opens many
|
||||||
// window. The AUTH bucket is skipped too for the same per-user, cached reason.
|
// pages in a short window. The VITALS bucket must be skipped too: it is a
|
||||||
|
// process-wide named throttler, so without this skip its per-IP limit would
|
||||||
|
// silently cap collab-token (the one route that opts out of every other
|
||||||
|
// bucket) and break editing behind shared/NAT IPs. The AUTH bucket is skipped
|
||||||
|
// for the same per-user, cached reason.
|
||||||
@SkipThrottle({
|
@SkipThrottle({
|
||||||
[AUTH_THROTTLER]: true,
|
[AUTH_THROTTLER]: true,
|
||||||
[AI_CHAT_THROTTLER]: true,
|
[AI_CHAT_THROTTLER]: true,
|
||||||
[PAGE_TEMPLATE_THROTTLER]: true,
|
[PAGE_TEMPLATE_THROTTLER]: true,
|
||||||
[PUBLIC_SHARE_AI_THROTTLER]: true,
|
[PUBLIC_SHARE_AI_THROTTLER]: true,
|
||||||
|
[VITALS_THROTTLER]: true,
|
||||||
})
|
})
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ import {
|
|||||||
INTERNAL_LINK_REGEX,
|
INTERNAL_LINK_REGEX,
|
||||||
extractPageSlugId,
|
extractPageSlugId,
|
||||||
} from '../../../integrations/export/utils';
|
} from '../../../integrations/export/utils';
|
||||||
import { markdownToHtml, canonicalizeFootnotes } from '@docmost/editor-ext';
|
import { canonicalizeFootnotes } from '@docmost/editor-ext';
|
||||||
|
import { markdownToProseMirror } from '@docmost/prosemirror-markdown';
|
||||||
|
import { normalizeForeignMarkdown } from '../../../integrations/import/utils/foreign-markdown';
|
||||||
import { WatcherService } from '../../watcher/watcher.service';
|
import { WatcherService } from '../../watcher/watcher.service';
|
||||||
import { sql } from 'kysely';
|
import { sql } from 'kysely';
|
||||||
import { TransclusionService } from '../transclusion/transclusion.service';
|
import { TransclusionService } from '../transclusion/transclusion.service';
|
||||||
@@ -1301,8 +1303,14 @@ export class PageService {
|
|||||||
|
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case 'markdown': {
|
case 'markdown': {
|
||||||
const html = await markdownToHtml(content as string);
|
// Canonical markdown -> ProseMirror JSON directly via
|
||||||
prosemirrorJson = htmlToJson(html as string);
|
// `@docmost/prosemirror-markdown` (issue #345) — no HTML intermediate,
|
||||||
|
// no editor-ext markdown layer. Foreign markdown surfaces the strict
|
||||||
|
// parser rejects (GFM `[^id]` reference footnotes) are normalized to the
|
||||||
|
// canonical inline form first.
|
||||||
|
prosemirrorJson = await markdownToProseMirror(
|
||||||
|
normalizeForeignMarkdown(content as string),
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'html': {
|
case 'html': {
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Server-side whitelist + limits for POST /api/telemetry/vitals (#355).
|
||||||
|
*
|
||||||
|
* The endpoint is PUBLIC (browsers post it, no auth) so it is a privacy and
|
||||||
|
* abuse surface: everything not on these lists is silently DROPPED and the
|
||||||
|
* request still returns 200 (never 400 — a 400 would make browsers retry).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// The only metric names accepted. Anything else is dropped.
|
||||||
|
export const ALLOWED_METRIC_NAMES = new Set<string>([
|
||||||
|
'INP',
|
||||||
|
'LCP',
|
||||||
|
'CLS',
|
||||||
|
'TTFB',
|
||||||
|
'editor_tx_ms',
|
||||||
|
'page_open_ms',
|
||||||
|
'longtask_ms',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The only rating values accepted (web-vitals). Anything else -> null.
|
||||||
|
export const ALLOWED_RATINGS = new Set<string>([
|
||||||
|
'good',
|
||||||
|
'needs-improvement',
|
||||||
|
'poor',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Max events accepted per batch; the rest are ignored.
|
||||||
|
export const MAX_EVENTS_PER_BATCH = 50;
|
||||||
|
|
||||||
|
// Defence-in-depth body cap (~16KB). Fastify's global bodyLimit is far larger,
|
||||||
|
// so we re-check the parsed payload size here and drop oversized batches.
|
||||||
|
export const MAX_BODY_BYTES = 16 * 1024;
|
||||||
|
|
||||||
|
// attr is truncated to this many characters (attribution target only, no PII).
|
||||||
|
export const MAX_ATTR_LENGTH = 120;
|
||||||
|
|
||||||
|
// route label sanity cap (client sends a template like /s/:space/p/:slug).
|
||||||
|
export const MAX_ROUTE_LENGTH = 200;
|
||||||
|
|
||||||
|
// `client_metrics.doc_size` is a Postgres `int` (int4). A garbage/huge docSize
|
||||||
|
// on a single event would overflow int4 and make Postgres reject the WHOLE
|
||||||
|
// batch INSERT, losing every event in it. Values outside this range are DROPPED
|
||||||
|
// to null (the event is still kept) so one bad field never loses the batch.
|
||||||
|
export const DOC_SIZE_MAX = 2147483647; // 2^31 - 1 (int4 max)
|
||||||
|
|
||||||
|
export interface ClientMetricRow {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
rating: string | null;
|
||||||
|
route: string | null;
|
||||||
|
attr: string | null;
|
||||||
|
docSize: number | null;
|
||||||
|
workspaceId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate + normalise a single incoming event into a DB row, or return null to
|
||||||
|
* DROP it. Pure so it is directly unit-testable. Enforces the name whitelist,
|
||||||
|
* numeric value, rating whitelist, attr truncation and doc_size (int) coercion.
|
||||||
|
*/
|
||||||
|
export function sanitizeVitalEvent(
|
||||||
|
raw: unknown,
|
||||||
|
workspaceId: string | null,
|
||||||
|
): ClientMetricRow | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null;
|
||||||
|
const e = raw as Record<string, unknown>;
|
||||||
|
|
||||||
|
const name = e.name;
|
||||||
|
if (typeof name !== 'string' || !ALLOWED_METRIC_NAMES.has(name)) return null;
|
||||||
|
|
||||||
|
const value =
|
||||||
|
typeof e.value === 'number' && Number.isFinite(e.value) ? e.value : null;
|
||||||
|
if (value === null) return null;
|
||||||
|
|
||||||
|
const rating =
|
||||||
|
typeof e.rating === 'string' && ALLOWED_RATINGS.has(e.rating)
|
||||||
|
? e.rating
|
||||||
|
: null;
|
||||||
|
|
||||||
|
let route: string | null = null;
|
||||||
|
if (typeof e.route === 'string' && e.route.length > 0) {
|
||||||
|
route = e.route.slice(0, MAX_ROUTE_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
let attr: string | null = null;
|
||||||
|
if (typeof e.attr === 'string' && e.attr.length > 0) {
|
||||||
|
attr = e.attr.slice(0, MAX_ATTR_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
let docSize: number | null = null;
|
||||||
|
if (typeof e.docSize === 'number' && Number.isFinite(e.docSize)) {
|
||||||
|
docSize = Math.trunc(e.docSize);
|
||||||
|
} else if (typeof e.doc_size === 'number' && Number.isFinite(e.doc_size)) {
|
||||||
|
// Accept snake_case too, in case a client sends the raw column name.
|
||||||
|
docSize = Math.trunc(e.doc_size as number);
|
||||||
|
}
|
||||||
|
// Guard the int4 column: an out-of-range docSize would overflow int4 and make
|
||||||
|
// Postgres reject the whole batch INSERT. Drop the field (keep the event)
|
||||||
|
// rather than lose every other event in the batch.
|
||||||
|
if (docSize !== null && (docSize < 0 || docSize > DOC_SIZE_MAX)) {
|
||||||
|
docSize = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name, value, rating, route, attr, docSize, workspaceId };
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { ClientTelemetryModule } from './client-telemetry.module';
|
||||||
|
import { VitalsController } from './vitals.controller';
|
||||||
|
import { VitalsService } from './vitals.service';
|
||||||
|
|
||||||
|
// The register() gate is the CORE of the maintainer's E1=B decision: the public,
|
||||||
|
// unauthenticated /api/telemetry/vitals endpoint must be OFF by default, so a
|
||||||
|
// self-host deploy has no anonymous disk-fill surface into `client_metrics`. A
|
||||||
|
// regression that inverts the flag (or a truthiness bug where "" / "false"
|
||||||
|
// registers the route) would silently reopen that surface — pin it here.
|
||||||
|
describe('ClientTelemetryModule.register (E1=B gate)', () => {
|
||||||
|
const original = process.env.CLIENT_TELEMETRY_ENABLED;
|
||||||
|
afterEach(() => {
|
||||||
|
if (original === undefined) delete process.env.CLIENT_TELEMETRY_ENABLED;
|
||||||
|
else process.env.CLIENT_TELEMETRY_ENABLED = original;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OFF by default (flag unset) — no controller, no provider (endpoint absent)', () => {
|
||||||
|
delete process.env.CLIENT_TELEMETRY_ENABLED;
|
||||||
|
const mod = ClientTelemetryModule.register();
|
||||||
|
expect(mod.controllers).toEqual([]);
|
||||||
|
expect(mod.providers).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['false', 'False', '0', '', 'yes', '1'])(
|
||||||
|
'stays OFF for non-"true" value %p (no route)',
|
||||||
|
(val) => {
|
||||||
|
process.env.CLIENT_TELEMETRY_ENABLED = val;
|
||||||
|
const mod = ClientTelemetryModule.register();
|
||||||
|
expect(mod.controllers).toEqual([]);
|
||||||
|
expect(mod.providers).toEqual([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('ON only for "true" — registers VitalsController + VitalsService', () => {
|
||||||
|
process.env.CLIENT_TELEMETRY_ENABLED = 'true';
|
||||||
|
const mod = ClientTelemetryModule.register();
|
||||||
|
expect(mod.controllers).toContain(VitalsController);
|
||||||
|
expect(mod.providers).toContain(VitalsService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ON is case-insensitive ("TRUE")', () => {
|
||||||
|
process.env.CLIENT_TELEMETRY_ENABLED = 'TRUE';
|
||||||
|
const mod = ClientTelemetryModule.register();
|
||||||
|
expect(mod.controllers).toContain(VitalsController);
|
||||||
|
expect(mod.providers).toContain(VitalsService);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { DynamicModule, Module } from '@nestjs/common';
|
||||||
|
import { VitalsController } from './vitals.controller';
|
||||||
|
import { VitalsService } from './vitals.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client perf-telemetry (#355): the public /api/telemetry/vitals sink that
|
||||||
|
* persists web-vitals + custom client metrics into `client_metrics`.
|
||||||
|
* Named ClientTelemetryModule to avoid confusion with the unrelated
|
||||||
|
* integrations/telemetry (product usage ping) module.
|
||||||
|
*
|
||||||
|
* GATED OFF BY DEFAULT (maintainer decision E1=B). The public, unauthenticated
|
||||||
|
* endpoint is only registered when CLIENT_TELEMETRY_ENABLED=true — otherwise the
|
||||||
|
* route does NOT exist at all (no anonymous disk-fill surface, and no unbounded
|
||||||
|
* `client_metrics` growth on a self-host deploy without an external pruner). The
|
||||||
|
* client is told the same flag via window.CONFIG and skips sending when off.
|
||||||
|
*/
|
||||||
|
@Module({})
|
||||||
|
export class ClientTelemetryModule {
|
||||||
|
static register(): DynamicModule {
|
||||||
|
// Read process.env directly (not EnvironmentService) so the toggle is
|
||||||
|
// resolved at module-registration time, identical to how the metrics
|
||||||
|
// subsystem reads METRICS_PORT. Absent/anything-but-"true" => OFF.
|
||||||
|
const enabled =
|
||||||
|
(process.env.CLIENT_TELEMETRY_ENABLED ?? '').toLowerCase() === 'true';
|
||||||
|
|
||||||
|
return {
|
||||||
|
module: ClientTelemetryModule,
|
||||||
|
controllers: enabled ? [VitalsController] : [],
|
||||||
|
providers: enabled ? [VitalsService] : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
HttpCode,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { SkipThrottle, Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
||||||
|
import { FastifyRequest } from 'fastify';
|
||||||
|
import { Public } from '../../common/decorators/public.decorator';
|
||||||
|
import {
|
||||||
|
AI_CHAT_THROTTLER,
|
||||||
|
AUTH_THROTTLER,
|
||||||
|
PAGE_TEMPLATE_THROTTLER,
|
||||||
|
PUBLIC_SHARE_AI_THROTTLER,
|
||||||
|
VITALS_THROTTLER,
|
||||||
|
} from '../../integrations/throttle/throttler-names';
|
||||||
|
import { VitalsService } from './vitals.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/telemetry/vitals (#355) — public client perf-metrics sink.
|
||||||
|
*
|
||||||
|
* PUBLIC (browsers post via sendBeacon, no session) but IP-throttled. Always
|
||||||
|
* returns 200 with no body of interest: invalid/foreign/oversized payloads are
|
||||||
|
* silently dropped by the service rather than 400'd, so browsers never retry.
|
||||||
|
*/
|
||||||
|
@Controller('telemetry')
|
||||||
|
export class VitalsController {
|
||||||
|
constructor(private readonly vitalsService: VitalsService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@UseGuards(ThrottlerGuard)
|
||||||
|
// The global ThrottlerGuard applies ALL named throttlers to every route, so
|
||||||
|
// every OTHER bucket must be skipped here — otherwise the strictest of them
|
||||||
|
// (public-share AI at 5/min) would override the intended vitals limit and cap
|
||||||
|
// this route at 5/min instead of 120/min. Skip them all so ONLY the VITALS
|
||||||
|
// bucket below applies.
|
||||||
|
@SkipThrottle({
|
||||||
|
[AUTH_THROTTLER]: true,
|
||||||
|
[AI_CHAT_THROTTLER]: true,
|
||||||
|
[PAGE_TEMPLATE_THROTTLER]: true,
|
||||||
|
[PUBLIC_SHARE_AI_THROTTLER]: true,
|
||||||
|
})
|
||||||
|
@Throttle({ [VITALS_THROTTLER]: { limit: 120, ttl: 60_000 } })
|
||||||
|
@Post('vitals')
|
||||||
|
@HttpCode(200)
|
||||||
|
async vitals(
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Req() req: FastifyRequest,
|
||||||
|
): Promise<{ ok: true }> {
|
||||||
|
// workspaceId is resolved by the workspace-host middleware onto req.raw when
|
||||||
|
// the browser posts from a workspace host; null otherwise. No other PII.
|
||||||
|
const workspaceId =
|
||||||
|
((req.raw as unknown as { workspaceId?: string })?.workspaceId ?? null) ||
|
||||||
|
null;
|
||||||
|
try {
|
||||||
|
await this.vitalsService.ingest(body, workspaceId);
|
||||||
|
} catch {
|
||||||
|
// Never surface storage errors to the browser; telemetry is best-effort.
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { VitalsService } from './vitals.service';
|
||||||
|
import { MAX_ATTR_LENGTH } from './client-metrics.constants';
|
||||||
|
|
||||||
|
// buildRows is pure (no DB access), so a null db is fine here.
|
||||||
|
const svc = new VitalsService(null as any);
|
||||||
|
|
||||||
|
describe('VitalsService.buildRows', () => {
|
||||||
|
const WS = 'ws-uuid';
|
||||||
|
|
||||||
|
it('accepts a valid batch and maps whitelisted fields to rows', () => {
|
||||||
|
const body = {
|
||||||
|
events: [
|
||||||
|
{ name: 'INP', value: 123.4, rating: 'good', route: '/s/:space/p/:slug' },
|
||||||
|
{ name: 'editor_tx_ms', value: 12, route: '/s/:space/p/:slug', docSize: 4096 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const rows = svc.buildRows(body, WS);
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(rows[0]).toEqual({
|
||||||
|
name: 'INP',
|
||||||
|
value: 123.4,
|
||||||
|
rating: 'good',
|
||||||
|
route: '/s/:space/p/:slug',
|
||||||
|
attr: null,
|
||||||
|
docSize: null,
|
||||||
|
workspaceId: WS,
|
||||||
|
});
|
||||||
|
expect(rows[1].name).toBe('editor_tx_ms');
|
||||||
|
expect(rows[1].docSize).toBe(4096);
|
||||||
|
expect(rows[1].workspaceId).toBe(WS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a bare array body', () => {
|
||||||
|
const rows = svc.buildRows([{ name: 'LCP', value: 1 }], WS);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].name).toBe('LCP');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops events with foreign metric names', () => {
|
||||||
|
const rows = svc.buildRows(
|
||||||
|
{ events: [{ name: 'evil_metric', value: 1 }, { name: 'LCP', value: 2 }] },
|
||||||
|
WS,
|
||||||
|
);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].name).toBe('LCP');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops events with a non-numeric or missing value', () => {
|
||||||
|
const rows = svc.buildRows(
|
||||||
|
{
|
||||||
|
events: [
|
||||||
|
{ name: 'CLS', value: 'nan' },
|
||||||
|
{ name: 'CLS' },
|
||||||
|
{ name: 'CLS', value: 0.1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
WS,
|
||||||
|
);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].value).toBe(0.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips foreign fields and only keeps whitelisted columns', () => {
|
||||||
|
const rows = svc.buildRows(
|
||||||
|
{ events: [{ name: 'TTFB', value: 5, secret: 'drop-me', title: 'my page' }] },
|
||||||
|
WS,
|
||||||
|
);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(Object.keys(rows[0]).sort()).toEqual(
|
||||||
|
['attr', 'docSize', 'name', 'rating', 'route', 'value', 'workspaceId'].sort(),
|
||||||
|
);
|
||||||
|
expect((rows[0] as any).secret).toBeUndefined();
|
||||||
|
expect((rows[0] as any).title).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a rating outside the allowed set (-> null)', () => {
|
||||||
|
const rows = svc.buildRows(
|
||||||
|
{ events: [{ name: 'INP', value: 1, rating: 'terrible' }] },
|
||||||
|
WS,
|
||||||
|
);
|
||||||
|
expect(rows[0].rating).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates attr to 120 chars', () => {
|
||||||
|
const longAttr = 'a'.repeat(500);
|
||||||
|
const rows = svc.buildRows(
|
||||||
|
{ events: [{ name: 'INP', value: 1, attr: longAttr }] },
|
||||||
|
WS,
|
||||||
|
);
|
||||||
|
expect(rows[0].attr).toHaveLength(MAX_ATTR_LENGTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps the batch at 50 events', () => {
|
||||||
|
const events = Array.from({ length: 200 }, () => ({ name: 'CLS', value: 1 }));
|
||||||
|
const rows = svc.buildRows({ events }, WS);
|
||||||
|
expect(rows).toHaveLength(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops an oversized (>16KB) payload wholesale', () => {
|
||||||
|
const events = Array.from({ length: 50 }, () => ({
|
||||||
|
name: 'INP',
|
||||||
|
value: 1,
|
||||||
|
attr: 'x'.repeat(400),
|
||||||
|
route: '/s/:space/p/:slug',
|
||||||
|
}));
|
||||||
|
// Serialised body far exceeds 16KB.
|
||||||
|
const rows = svc.buildRows({ events }, WS);
|
||||||
|
expect(rows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] for malformed bodies', () => {
|
||||||
|
expect(svc.buildRows(null, WS)).toEqual([]);
|
||||||
|
expect(svc.buildRows('nope', WS)).toEqual([]);
|
||||||
|
expect(svc.buildRows({ notEvents: 1 }, WS)).toEqual([]);
|
||||||
|
expect(svc.buildRows(42, WS)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries a null workspaceId through', () => {
|
||||||
|
const rows = svc.buildRows({ events: [{ name: 'LCP', value: 1 }] }, null);
|
||||||
|
expect(rows[0].workspaceId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops an out-of-int4-range docSize to null without losing the batch', () => {
|
||||||
|
const rows = svc.buildRows(
|
||||||
|
{
|
||||||
|
events: [
|
||||||
|
// Garbage docSize overflowing int4 must NOT reject the whole batch:
|
||||||
|
// the field is dropped to null and the event is kept.
|
||||||
|
{ name: 'editor_tx_ms', value: 10, docSize: 9_999_999_999 },
|
||||||
|
{ name: 'editor_tx_ms', value: 20, docSize: -5 },
|
||||||
|
{ name: 'editor_tx_ms', value: 30, docSize: 4096 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
WS,
|
||||||
|
);
|
||||||
|
expect(rows).toHaveLength(3);
|
||||||
|
expect(rows[0].docSize).toBeNull();
|
||||||
|
expect(rows[1].docSize).toBeNull();
|
||||||
|
expect(rows[2].docSize).toBe(4096);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a docSize exactly at the int4 max', () => {
|
||||||
|
const rows = svc.buildRows(
|
||||||
|
{ events: [{ name: 'editor_tx_ms', value: 1, docSize: 2147483647 }] },
|
||||||
|
WS,
|
||||||
|
);
|
||||||
|
expect(rows[0].docSize).toBe(2147483647);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
|
import {
|
||||||
|
ClientMetricRow,
|
||||||
|
MAX_BODY_BYTES,
|
||||||
|
MAX_EVENTS_PER_BATCH,
|
||||||
|
sanitizeVitalEvent,
|
||||||
|
} from './client-metrics.constants';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class VitalsService {
|
||||||
|
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a raw request body into the (bounded, whitelisted) rows to persist.
|
||||||
|
* Pure/synchronous so it is unit-testable without a DB. Returns [] for any
|
||||||
|
* malformed / oversized / foreign input — the caller still responds 200.
|
||||||
|
*/
|
||||||
|
buildRows(body: unknown, workspaceId: string | null): ClientMetricRow[] {
|
||||||
|
if (!body || typeof body !== 'object') return [];
|
||||||
|
|
||||||
|
// Defence-in-depth body cap (~16KB): drop oversized batches wholesale.
|
||||||
|
try {
|
||||||
|
if (JSON.stringify(body).length > MAX_BODY_BYTES) return [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept either a bare array or `{ events: [...] }`.
|
||||||
|
const events = Array.isArray(body)
|
||||||
|
? body
|
||||||
|
: Array.isArray((body as { events?: unknown }).events)
|
||||||
|
? ((body as { events: unknown[] }).events as unknown[])
|
||||||
|
: null;
|
||||||
|
if (!events) return [];
|
||||||
|
|
||||||
|
const rows: ClientMetricRow[] = [];
|
||||||
|
for (const event of events) {
|
||||||
|
if (rows.length >= MAX_EVENTS_PER_BATCH) break;
|
||||||
|
const row = sanitizeVitalEvent(event, workspaceId);
|
||||||
|
if (row) rows.push(row);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Batch-insert the sanitised rows in a single statement. No-op on []. */
|
||||||
|
async insertRows(rows: ClientMetricRow[]): Promise<void> {
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
await this.db
|
||||||
|
.insertInto('clientMetrics')
|
||||||
|
.values(
|
||||||
|
rows.map((r) => ({
|
||||||
|
name: r.name,
|
||||||
|
value: r.value,
|
||||||
|
rating: r.rating,
|
||||||
|
route: r.route,
|
||||||
|
attr: r.attr,
|
||||||
|
docSize: r.docSize,
|
||||||
|
workspaceId: r.workspaceId,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
async ingest(body: unknown, workspaceId: string | null): Promise<void> {
|
||||||
|
const rows = this.buildRows(body, workspaceId);
|
||||||
|
await this.insertRows(rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,14 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
aiDictationStreaming: boolean;
|
aiDictationStreaming: boolean;
|
||||||
|
|
||||||
|
// #184: detached/autonomous agent runs (settings.ai.autonomousRuns). When on, a
|
||||||
|
// chat turn becomes a server-side RUN that survives a browser disconnect; only
|
||||||
|
// an explicit /ai-chat/stop ends it. Off by default; single-instance-only in
|
||||||
|
// phase 1 (see AiChatRunService.warnIfMultiInstance / AGENTS.md).
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
autonomousRuns: boolean;
|
||||||
|
|
||||||
// Workspace master toggle that enables/disables the HTML embed block type.
|
// Workspace master toggle that enables/disables the HTML embed block type.
|
||||||
// Persisted at settings.htmlEmbed. ABSENT/false => OFF (default). The block
|
// Persisted at settings.htmlEmbed. ABSENT/false => OFF (default). The block
|
||||||
// itself renders in a sandboxed iframe, so this is a feature switch, not a
|
// itself renders in a sandboxed iframe, so this is a feature switch, not a
|
||||||
|
|||||||
@@ -526,6 +526,20 @@ export class WorkspaceService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof updateWorkspaceDto.autonomousRuns !== 'undefined') {
|
||||||
|
const prev = settingsBefore?.ai?.autonomousRuns ?? false;
|
||||||
|
if (prev !== updateWorkspaceDto.autonomousRuns) {
|
||||||
|
before.autonomousRuns = prev;
|
||||||
|
after.autonomousRuns = updateWorkspaceDto.autonomousRuns;
|
||||||
|
}
|
||||||
|
await this.workspaceRepo.updateAiSettings(
|
||||||
|
workspaceId,
|
||||||
|
'autonomousRuns',
|
||||||
|
updateWorkspaceDto.autonomousRuns,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof updateWorkspaceDto.htmlEmbed !== 'undefined') {
|
if (typeof updateWorkspaceDto.htmlEmbed !== 'undefined') {
|
||||||
const prev = settingsBefore?.htmlEmbed ?? false;
|
const prev = settingsBefore?.htmlEmbed ?? false;
|
||||||
if (prev !== updateWorkspaceDto.htmlEmbed) {
|
if (prev !== updateWorkspaceDto.htmlEmbed) {
|
||||||
@@ -579,6 +593,7 @@ export class WorkspaceService {
|
|||||||
delete updateWorkspaceDto.aiChat;
|
delete updateWorkspaceDto.aiChat;
|
||||||
delete updateWorkspaceDto.aiDictation;
|
delete updateWorkspaceDto.aiDictation;
|
||||||
delete updateWorkspaceDto.aiDictationStreaming;
|
delete updateWorkspaceDto.aiDictationStreaming;
|
||||||
|
delete updateWorkspaceDto.autonomousRuns;
|
||||||
delete updateWorkspaceDto.htmlEmbed;
|
delete updateWorkspaceDto.htmlEmbed;
|
||||||
delete updateWorkspaceDto.trackerHead;
|
delete updateWorkspaceDto.trackerHead;
|
||||||
delete updateWorkspaceDto.aiPublicShareAssistant;
|
delete updateWorkspaceDto.aiPublicShareAssistant;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
|
|||||||
import { TemplateRepo } from '@docmost/db/repos/template/template.repo';
|
import { TemplateRepo } from '@docmost/db/repos/template/template.repo';
|
||||||
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
||||||
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||||
|
import { AiChatRunRepo } from '@docmost/db/repos/ai-chat/ai-chat-run.repo';
|
||||||
import { AiChatPageSnapshotRepo } from '@docmost/db/repos/ai-chat/ai-chat-page-snapshot.repo';
|
import { AiChatPageSnapshotRepo } from '@docmost/db/repos/ai-chat/ai-chat-page-snapshot.repo';
|
||||||
import { AiProviderCredentialsRepo } from '@docmost/db/repos/ai-chat/ai-provider-credentials.repo';
|
import { AiProviderCredentialsRepo } from '@docmost/db/repos/ai-chat/ai-provider-credentials.repo';
|
||||||
import { AiMcpServerRepo } from '@docmost/db/repos/ai-chat/ai-mcp-server.repo';
|
import { AiMcpServerRepo } from '@docmost/db/repos/ai-chat/ai-mcp-server.repo';
|
||||||
@@ -40,6 +41,11 @@ import { PageListener } from '@docmost/db/listeners/page.listener';
|
|||||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||||
import * as postgres from 'postgres';
|
import * as postgres from 'postgres';
|
||||||
import { normalizePostgresUrl } from '../common/helpers';
|
import { normalizePostgresUrl } from '../common/helpers';
|
||||||
|
import {
|
||||||
|
observeDbQuery,
|
||||||
|
isMetricsEnabled,
|
||||||
|
} from '../integrations/metrics/metrics.registry';
|
||||||
|
import { firstSqlToken } from '../integrations/metrics/metrics.constants';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
@@ -67,6 +73,18 @@ import { normalizePostgresUrl } from '../common/helpers';
|
|||||||
}),
|
}),
|
||||||
plugins: [new CamelCasePlugin()],
|
plugins: [new CamelCasePlugin()],
|
||||||
log: (event: LogEvent) => {
|
log: (event: LogEvent) => {
|
||||||
|
// #355 — db_query_duration_seconds, labelled by the leading SQL token
|
||||||
|
// (bounded cardinality). Gated on isMetricsEnabled() so the token work
|
||||||
|
// (regex + Set lookup) is skipped entirely when metrics are OFF — not
|
||||||
|
// just observeDbQuery no-op'd — so a non-metrics deployment pays nothing
|
||||||
|
// per query. Runs independent of the dev-only debug logging below.
|
||||||
|
if (isMetricsEnabled()) {
|
||||||
|
observeDbQuery(
|
||||||
|
firstSqlToken(event.query.sql),
|
||||||
|
event.queryDurationMillis / 1000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (environmentService.getNodeEnv() !== 'development') return;
|
if (environmentService.getNodeEnv() !== 'development') return;
|
||||||
const logger = new Logger(DatabaseModule.name);
|
const logger = new Logger(DatabaseModule.name);
|
||||||
if (process.env.DEBUG_DB?.toLowerCase() === 'true') {
|
if (process.env.DEBUG_DB?.toLowerCase() === 'true') {
|
||||||
@@ -105,6 +123,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
|||||||
TemplateRepo,
|
TemplateRepo,
|
||||||
AiChatRepo,
|
AiChatRepo,
|
||||||
AiChatMessageRepo,
|
AiChatMessageRepo,
|
||||||
|
AiChatRunRepo,
|
||||||
AiChatPageSnapshotRepo,
|
AiChatPageSnapshotRepo,
|
||||||
AiProviderCredentialsRepo,
|
AiProviderCredentialsRepo,
|
||||||
AiMcpServerRepo,
|
AiMcpServerRepo,
|
||||||
@@ -139,6 +158,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
|||||||
TemplateRepo,
|
TemplateRepo,
|
||||||
AiChatRepo,
|
AiChatRepo,
|
||||||
AiChatMessageRepo,
|
AiChatMessageRepo,
|
||||||
|
AiChatRunRepo,
|
||||||
AiChatPageSnapshotRepo,
|
AiChatPageSnapshotRepo,
|
||||||
AiProviderCredentialsRepo,
|
AiProviderCredentialsRepo,
|
||||||
AiMcpServerRepo,
|
AiMcpServerRepo,
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ const migrator = new Migrator({
|
|||||||
path,
|
path,
|
||||||
migrationFolder,
|
migrationFolder,
|
||||||
}),
|
}),
|
||||||
|
// Match the startup auto-migrator (migration.service.ts): a back-dated
|
||||||
|
// migration from a long-lived branch must be applied, not rejected as
|
||||||
|
// "corrupted migrations" (incident #361). See that file for the full rationale.
|
||||||
|
allowUnorderedMigrations: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
run(db, migrator, migrationFolder);
|
run(db, migrator, migrationFolder);
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { type Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #355 — `client_metrics`: raw sink for client-side perf telemetry (web-vitals
|
||||||
|
* + custom editor/page metrics) posted to /api/telemetry/vitals.
|
||||||
|
*
|
||||||
|
* The table/columns/indexes here are a FIXED contract shared with the deployed
|
||||||
|
* Grafana infra (the `grafana_ro` role reads this table; a separate maintenance
|
||||||
|
* container prunes rows >90d and re-GRANTs daily). No app-side retention is
|
||||||
|
* added on purpose. Written as raw SQL to match that contract 1:1 (identity PK,
|
||||||
|
* conditional GRANT).
|
||||||
|
*/
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE client_metrics (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
name text NOT NULL, -- INP|LCP|CLS|TTFB|editor_tx_ms|page_open_ms|longtask_ms
|
||||||
|
value double precision NOT NULL,
|
||||||
|
rating text, -- good|needs-improvement|poor (web-vitals only)
|
||||||
|
route text, -- templated: /s/:space/p/:slug — never raw slugs
|
||||||
|
attr text, -- attribution target, truncated to 120 chars
|
||||||
|
doc_size int, -- editor_tx_ms only
|
||||||
|
workspace_id uuid
|
||||||
|
)
|
||||||
|
`.execute(db);
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX idx_client_metrics_name_created
|
||||||
|
ON client_metrics (name, created_at)
|
||||||
|
`.execute(db);
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX idx_client_metrics_created
|
||||||
|
ON client_metrics (created_at)
|
||||||
|
`.execute(db);
|
||||||
|
|
||||||
|
// The read-only Grafana role only exists in the deployed environment; guard so
|
||||||
|
// the migration still applies cleanly in dev/CI where the role is absent.
|
||||||
|
await sql`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'grafana_ro') THEN
|
||||||
|
GRANT SELECT ON client_metrics TO grafana_ro;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
`.execute(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`DROP TABLE IF EXISTS client_metrics`.execute(db);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { type Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ai_chat_runs` — the agent RUN as a first-class, server-side lifecycle object
|
||||||
|
* (#184 phase 1: autonomous agent runs detached from the browser window).
|
||||||
|
*
|
||||||
|
* Until now an agent turn lived ONLY as long as the HTTP request was open
|
||||||
|
* (`res.hijack()` in ai-chat.controller.ts); a browser disconnect aborted it.
|
||||||
|
* This table makes a turn a persistent object the server owns: it is created
|
||||||
|
* when a run starts (inserted directly as 'running' in phase 1 — 'pending' is
|
||||||
|
* only this column's default + a reserved value, never written by code yet) and
|
||||||
|
* advances to succeeded|failed|aborted, surviving the subscriber (browser) going
|
||||||
|
* away when it settles. The DB is the source of
|
||||||
|
* truth — a later client reconnects/sees the result by reading this row plus the
|
||||||
|
* assistant message it projects (`assistant_message_id`).
|
||||||
|
*
|
||||||
|
* The assistant message row (#183 step-granular durability) is the PROJECTION of
|
||||||
|
* a run's output; this row is the run's LIFECYCLE. They are linked by
|
||||||
|
* `assistant_message_id` (SET NULL if the message is later pruned).
|
||||||
|
*
|
||||||
|
* `status` : 'pending' | 'running' | 'succeeded' | 'failed' | 'aborted'.
|
||||||
|
* `trigger` : 'user' | 'autostart' | 'schedule' | 'api' | 'continue' — only
|
||||||
|
* 'user' is produced in phase 1; the others are reserved for the
|
||||||
|
* autonomy triggers deferred to phase 2 so they need no later
|
||||||
|
* migration.
|
||||||
|
*
|
||||||
|
* ONE ACTIVE RUN PER CHAT is enforced by a partial unique index on `chat_id`
|
||||||
|
* WHERE status IN ('pending','running'): an autonomous run and a user run can
|
||||||
|
* never trample each other on the same chat. Settled runs (succeeded/failed/
|
||||||
|
* aborted) are excluded from the index so a chat can accumulate any number of
|
||||||
|
* historical runs.
|
||||||
|
*/
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.createTable('ai_chat_runs')
|
||||||
|
.ifNotExists()
|
||||||
|
.addColumn('id', 'uuid', (col) =>
|
||||||
|
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||||
|
)
|
||||||
|
.addColumn('chat_id', 'uuid', (col) =>
|
||||||
|
col.references('ai_chats.id').onDelete('cascade').notNull(),
|
||||||
|
)
|
||||||
|
.addColumn('workspace_id', 'uuid', (col) =>
|
||||||
|
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||||
|
)
|
||||||
|
// The human who triggered the run (audit). SET NULL on user deletion so the
|
||||||
|
// run history outlives its author; NULL is also the natural value for a
|
||||||
|
// future system/cron/api trigger with no human actor.
|
||||||
|
.addColumn('created_by', 'uuid', (col) =>
|
||||||
|
col.references('users.id').onDelete('set null'),
|
||||||
|
)
|
||||||
|
// The assistant message this run materializes (the #183 projection). SET NULL
|
||||||
|
// if that message row is later deleted; nullable because the run row is
|
||||||
|
// created a moment BEFORE the assistant row is seeded.
|
||||||
|
.addColumn('assistant_message_id', 'uuid', (col) =>
|
||||||
|
col.references('ai_chat_messages.id').onDelete('set null'),
|
||||||
|
)
|
||||||
|
.addColumn('trigger', 'varchar(20)', (col) =>
|
||||||
|
col.notNull().defaultTo('user'),
|
||||||
|
)
|
||||||
|
.addColumn('status', 'varchar(20)', (col) =>
|
||||||
|
col.notNull().defaultTo('pending'),
|
||||||
|
)
|
||||||
|
// Terminal error message for a failed run (provider/transport cause),
|
||||||
|
// mirroring the assistant message's metadata.error.
|
||||||
|
.addColumn('error', 'text', (col) => col)
|
||||||
|
// Number of agent steps finished so far (kept monotonic with the projection).
|
||||||
|
.addColumn('step_count', 'integer', (col) => col.notNull().defaultTo(0))
|
||||||
|
// Set when an EXPLICIT user stop is requested (distinct from a mere browser
|
||||||
|
// disconnect, which never stops a run). The runner aborts the turn and the
|
||||||
|
// run settles as 'aborted'.
|
||||||
|
.addColumn('stop_requested_at', 'timestamptz', (col) => col)
|
||||||
|
.addColumn('started_at', 'timestamptz', (col) => col)
|
||||||
|
.addColumn('finished_at', 'timestamptz', (col) => col)
|
||||||
|
.addColumn('created_at', 'timestamptz', (col) =>
|
||||||
|
col.notNull().defaultTo(sql`now()`),
|
||||||
|
)
|
||||||
|
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||||
|
col.notNull().defaultTo(sql`now()`),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
// Reconnect / "latest run for this chat" reads hit chat_id first.
|
||||||
|
await db.schema
|
||||||
|
.createIndex('ai_chat_runs_chat_id_idx')
|
||||||
|
.ifNotExists()
|
||||||
|
.on('ai_chat_runs')
|
||||||
|
.column('chat_id')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
// One ACTIVE run per chat (advisory at the DB level): a second pending/running
|
||||||
|
// run on the same chat is rejected, so a user turn and an autonomous turn can
|
||||||
|
// never race on the same chat. Partial so settled runs do not collide.
|
||||||
|
await db.schema
|
||||||
|
.createIndex('ai_chat_runs_one_active_per_chat')
|
||||||
|
.ifNotExists()
|
||||||
|
.on('ai_chat_runs')
|
||||||
|
.column('chat_id')
|
||||||
|
.unique()
|
||||||
|
.where(sql.ref('status'), 'in', sql`('pending','running')`)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema.dropTable('ai_chat_runs').execute();
|
||||||
|
}
|
||||||
@@ -121,6 +121,23 @@ export class AiChatMessageRepo {
|
|||||||
return rows.reverse();
|
return rows.reverse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetch a single message by id + workspace (e.g. a run's projection row for
|
||||||
|
* the #184 reconnect read). Returns undefined when nothing matches. */
|
||||||
|
async findById(
|
||||||
|
id: string,
|
||||||
|
workspaceId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<AiChatMessage | undefined> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
return db
|
||||||
|
.selectFrom('aiChatMessages')
|
||||||
|
.select(this.baseFields)
|
||||||
|
.where('id', '=', id)
|
||||||
|
.where('workspaceId', '=', workspaceId)
|
||||||
|
.where('deletedAt', 'is', null)
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
async insert(
|
async insert(
|
||||||
insertable: InsertableAiChatMessage,
|
insertable: InsertableAiChatMessage,
|
||||||
trx?: KyselyTransaction,
|
trx?: KyselyTransaction,
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { AiChatRunRepo, SWEEP_RUN_STALE_MS } from './ai-chat-run.repo';
|
||||||
|
import type { KyselyDB } from '../../types/kysely.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit coverage for AiChatRunRepo.sweepRunning over a chainable builder mock (no
|
||||||
|
* live DB). The F1 invariant under test (DECISION C): the BOOT sweep is
|
||||||
|
* UNCONDITIONAL — it adds NO `updatedAt <` predicate, so a fresh 'running' run
|
||||||
|
* (updatedAt = now) IS settled rather than skipped by a staleness window. The
|
||||||
|
* window is added ONLY when an explicit `staleMs` is supplied (the future phase-2
|
||||||
|
* multi-instance timer sweep). We assert the EXACT predicates the spec mandates.
|
||||||
|
*/
|
||||||
|
describe('AiChatRunRepo.sweepRunning', () => {
|
||||||
|
type Recorded = {
|
||||||
|
table?: string;
|
||||||
|
set?: Record<string, unknown>;
|
||||||
|
wheres: Array<[string, string, unknown]>;
|
||||||
|
returning?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeDb(swept: Array<{ id: string }>): {
|
||||||
|
db: KyselyDB;
|
||||||
|
rec: Recorded;
|
||||||
|
} {
|
||||||
|
const rec: Recorded = { wheres: [] };
|
||||||
|
const builder: Record<string, unknown> = {};
|
||||||
|
builder.set = (v: Record<string, unknown>) => {
|
||||||
|
rec.set = v;
|
||||||
|
return builder;
|
||||||
|
};
|
||||||
|
builder.where = (col: string, op: string, val: unknown) => {
|
||||||
|
rec.wheres.push([col, op, val]);
|
||||||
|
return builder;
|
||||||
|
};
|
||||||
|
builder.returning = (col: string) => {
|
||||||
|
rec.returning = col;
|
||||||
|
return builder;
|
||||||
|
};
|
||||||
|
builder.execute = () => Promise.resolve(swept);
|
||||||
|
const db = {
|
||||||
|
updateTable: (table: string) => {
|
||||||
|
rec.table = table;
|
||||||
|
return builder;
|
||||||
|
},
|
||||||
|
} as unknown as KyselyDB;
|
||||||
|
return { db, rec };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('F1: the boot sweep (no staleMs) is UNCONDITIONAL — only a status filter, NO updatedAt window', async () => {
|
||||||
|
const { db, rec } = makeDb([{ id: 'r1' }, { id: 'r2' }]);
|
||||||
|
const repo = new AiChatRunRepo(db);
|
||||||
|
|
||||||
|
const swept = await repo.sweepRunning();
|
||||||
|
|
||||||
|
expect(swept).toBe(2);
|
||||||
|
expect(rec.table).toBe('aiChatRuns');
|
||||||
|
// The status filter is always present...
|
||||||
|
expect(rec.wheres).toContainEqual([
|
||||||
|
'status',
|
||||||
|
'in',
|
||||||
|
expect.arrayContaining(['pending', 'running']),
|
||||||
|
]);
|
||||||
|
// ...but a fresh 'running' run (updatedAt = now) must NOT be skipped: no
|
||||||
|
// updatedAt predicate at all on the boot path.
|
||||||
|
expect(rec.wheres.some(([col]) => col === 'updatedAt')).toBe(false);
|
||||||
|
// It flips to 'aborted' and stamps finishedAt.
|
||||||
|
expect(rec.set).toEqual(
|
||||||
|
expect.objectContaining({ status: 'aborted', finishedAt: expect.any(Date) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('phase-2 path: an explicit staleMs reintroduces the updatedAt window', async () => {
|
||||||
|
const { db, rec } = makeDb([]);
|
||||||
|
const repo = new AiChatRunRepo(db);
|
||||||
|
|
||||||
|
await repo.sweepRunning({ staleMs: SWEEP_RUN_STALE_MS });
|
||||||
|
|
||||||
|
const updatedAtWhere = rec.wheres.find(([col]) => col === 'updatedAt');
|
||||||
|
expect(updatedAtWhere).toBeDefined();
|
||||||
|
expect(updatedAtWhere![1]).toBe('<');
|
||||||
|
expect(updatedAtWhere![2]).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { sql } from 'kysely';
|
||||||
|
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
||||||
|
import { dbOrTx } from '../../utils';
|
||||||
|
import {
|
||||||
|
AiChatRun,
|
||||||
|
InsertableAiChatRun,
|
||||||
|
} from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
|
// Statuses that count as "the run is still live" (an autonomous and a user run
|
||||||
|
// must never both be live on one chat — enforced by the partial unique index and
|
||||||
|
// checked here for friendly 409s before the insert races the constraint).
|
||||||
|
export const ACTIVE_RUN_STATUSES = ['pending', 'running'] as const;
|
||||||
|
|
||||||
|
// Crash-recovery sweep recency threshold (mirrors AiChatMessageRepo.sweepStreaming,
|
||||||
|
// #183): when a staleness window is supplied, a 'running'/'pending' run is only
|
||||||
|
// swept to 'aborted' once it has been UNTOUCHED for this long, so a sibling
|
||||||
|
// replica's boot-sweep can never abort a run another replica is actively
|
||||||
|
// executing. The runner bumps `updatedAt` on every step, so a live run never
|
||||||
|
// matches. PHASE 1 is single-process and the boot sweep passes NO window (every
|
||||||
|
// dangling run is settled unconditionally — see sweepRunning / F1). This constant
|
||||||
|
// is the window to reintroduce for the phase-2 multi-instance timer sweep.
|
||||||
|
export const SWEEP_RUN_STALE_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository for `ai_chat_runs` (#184 phase 1): the agent run as a first-class,
|
||||||
|
* server-side lifecycle object detached from the HTTP request. The run row is the
|
||||||
|
* point a client subscribes/reconnects to (by `id` or by chat); the assistant
|
||||||
|
* message it links to (`assistantMessageId`) is the #183 projection of its output.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AiChatRunRepo {
|
||||||
|
private readonly logger = new Logger(AiChatRunRepo.name);
|
||||||
|
|
||||||
|
private baseFields: Array<keyof AiChatRun> = [
|
||||||
|
'id',
|
||||||
|
'chatId',
|
||||||
|
'workspaceId',
|
||||||
|
'createdBy',
|
||||||
|
'assistantMessageId',
|
||||||
|
'trigger',
|
||||||
|
'status',
|
||||||
|
'error',
|
||||||
|
'stepCount',
|
||||||
|
'stopRequestedAt',
|
||||||
|
'startedAt',
|
||||||
|
'finishedAt',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||||
|
|
||||||
|
async insert(
|
||||||
|
insertable: InsertableAiChatRun,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<AiChatRun> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
return db
|
||||||
|
.insertInto('aiChatRuns')
|
||||||
|
.values(insertable)
|
||||||
|
.returning(this.baseFields)
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(
|
||||||
|
id: string,
|
||||||
|
workspaceId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<AiChatRun | undefined> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
return db
|
||||||
|
.selectFrom('aiChatRuns')
|
||||||
|
.select(this.baseFields)
|
||||||
|
.where('id', '=', id)
|
||||||
|
.where('workspaceId', '=', workspaceId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The currently-active (pending|running) run for a chat, if any. At most one
|
||||||
|
* exists thanks to the partial unique index. */
|
||||||
|
async findActiveByChat(
|
||||||
|
chatId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<AiChatRun | undefined> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
return db
|
||||||
|
.selectFrom('aiChatRuns')
|
||||||
|
.select(this.baseFields)
|
||||||
|
.where('chatId', '=', chatId)
|
||||||
|
.where('workspaceId', '=', workspaceId)
|
||||||
|
.where('status', 'in', ACTIVE_RUN_STATUSES as unknown as string[])
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The most-recent run for a chat (active or settled) — the reconnect target. */
|
||||||
|
async findLatestByChat(
|
||||||
|
chatId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<AiChatRun | undefined> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
return db
|
||||||
|
.selectFrom('aiChatRuns')
|
||||||
|
.select(this.baseFields)
|
||||||
|
.where('chatId', '=', chatId)
|
||||||
|
.where('workspaceId', '=', workspaceId)
|
||||||
|
.orderBy('createdAt', 'desc')
|
||||||
|
.orderBy('id', 'desc')
|
||||||
|
.limit(1)
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patch a run by id + workspace; always bumps `updatedAt`. Used for every
|
||||||
|
* lifecycle transition (mark running, link the assistant message, bump
|
||||||
|
* step_count, finalize succeeded/failed/aborted). Returns the updated row or
|
||||||
|
* undefined when nothing matched (e.g. a foreign workspace).
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
workspaceId: string,
|
||||||
|
patch: Partial<{
|
||||||
|
status: string;
|
||||||
|
error: string | null;
|
||||||
|
stepCount: number;
|
||||||
|
assistantMessageId: string | null;
|
||||||
|
stopRequestedAt: Date | null;
|
||||||
|
startedAt: Date | null;
|
||||||
|
finishedAt: Date | null;
|
||||||
|
}>,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<AiChatRun | undefined> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
return db
|
||||||
|
.updateTable('aiChatRuns')
|
||||||
|
.set({ ...(patch as Record<string, unknown>), updatedAt: new Date() })
|
||||||
|
.where('id', '=', id)
|
||||||
|
.where('workspaceId', '=', workspaceId)
|
||||||
|
.returning(this.baseFields)
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark an EXPLICIT stop request on an active run (distinct from a browser
|
||||||
|
* disconnect, which never stops a run). Stamps `stop_requested_at` ONLY while
|
||||||
|
* the run is still active, so a late stop on an already-settled run is a no-op.
|
||||||
|
* Returns the row when a stop was recorded, else undefined (nothing active).
|
||||||
|
*/
|
||||||
|
async markStopRequested(
|
||||||
|
id: string,
|
||||||
|
workspaceId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<AiChatRun | undefined> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
return db
|
||||||
|
.updateTable('aiChatRuns')
|
||||||
|
.set({ stopRequestedAt: new Date(), updatedAt: new Date() })
|
||||||
|
.where('id', '=', id)
|
||||||
|
.where('workspaceId', '=', workspaceId)
|
||||||
|
.where('status', 'in', ACTIVE_RUN_STATUSES as unknown as string[])
|
||||||
|
.returning(this.baseFields)
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crash-recovery sweep (mirrors AiChatMessageRepo.sweepStreaming): flip every
|
||||||
|
* run still left pending/running — a run whose process died before reaching a
|
||||||
|
* terminal status — to 'aborted', stamping `finished_at`. Returns the number
|
||||||
|
* swept. Workspace-wide on purpose (a crash can dangle runs in any workspace).
|
||||||
|
*
|
||||||
|
* F1 (DECISION C): the BOOT sweep is UNCONDITIONAL — it passes no `staleMs`, so
|
||||||
|
* EVERY dangling run is settled regardless of how recently it was touched. On a
|
||||||
|
* fresh single-process boot any pending|running run is definitionally hung (no
|
||||||
|
* runner is alive to own it), so a fast restart (deploy/OOM within minutes of
|
||||||
|
* the last step) no longer leaves a run stuck 'running' forever — which would
|
||||||
|
* make the one-active-run gate 409 every future turn in that chat.
|
||||||
|
*
|
||||||
|
* The optional `staleMs` window is reintroduced ONLY for the future phase-2
|
||||||
|
* multi-instance timer sweep (see {@link SWEEP_RUN_STALE_MS}): there a booting
|
||||||
|
* replica must NOT abort a run another replica is actively executing, so it
|
||||||
|
* sweeps only runs UNTOUCHED past the window. Phase 1 is single-process, so the
|
||||||
|
* boot path supplies no window.
|
||||||
|
*/
|
||||||
|
async sweepRunning(
|
||||||
|
opts: { staleMs?: number } = {},
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<number> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
const now = new Date();
|
||||||
|
let query = db
|
||||||
|
.updateTable('aiChatRuns')
|
||||||
|
.set({
|
||||||
|
status: 'aborted',
|
||||||
|
finishedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
error: sql`coalesce(error, ${'Run interrupted by a server restart.'})`,
|
||||||
|
})
|
||||||
|
.where('status', 'in', ACTIVE_RUN_STATUSES as unknown as string[]);
|
||||||
|
// Multi-instance (phase 2) only: skip runs touched within the window so a
|
||||||
|
// sibling replica's live run is never aborted. Omitted on the phase-1 boot
|
||||||
|
// sweep -> unconditional.
|
||||||
|
if (typeof opts.staleMs === 'number') {
|
||||||
|
const staleBefore = new Date(now.getTime() - opts.staleMs);
|
||||||
|
query = query.where('updatedAt', '<', staleBefore);
|
||||||
|
}
|
||||||
|
const rows = await query.returning('id').execute();
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,16 @@ export class MigrationService {
|
|||||||
path,
|
path,
|
||||||
migrationFolder: path.join(__dirname, '..', 'migrations'),
|
migrationFolder: path.join(__dirname, '..', 'migrations'),
|
||||||
}),
|
}),
|
||||||
|
// A long-lived branch can add a migration whose timestamped filename sorts
|
||||||
|
// BEFORE migrations already applied in prod (e.g. #234's 20260627 landing
|
||||||
|
// after 20260704 was live). With the default (ordered) setting the startup
|
||||||
|
// migrator then sees "corrupted migrations" — the applied set is no longer a
|
||||||
|
// prefix of the sorted list — throws, and the app crash-loops on boot
|
||||||
|
// (incident #361: 502s for ~11 min). allowUnorderedMigrations runs any
|
||||||
|
// not-yet-applied migration regardless of filename order, so a back-dated
|
||||||
|
// migration is applied instead of bricking startup. A CI order-gate still
|
||||||
|
// discourages back-dating; this is the runtime safety net.
|
||||||
|
allowUnorderedMigrations: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { error, results } = await migrator.migrateToLatest();
|
const { error, results } = await migrator.migrateToLatest();
|
||||||
|
|||||||
+43
@@ -156,6 +156,18 @@ export interface Billing {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ClientMetrics {
|
||||||
|
id: Generated<Int8>;
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
rating: string | null;
|
||||||
|
route: string | null;
|
||||||
|
attr: string | null;
|
||||||
|
docSize: number | null;
|
||||||
|
workspaceId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Comments {
|
export interface Comments {
|
||||||
aiChatId: string | null;
|
aiChatId: string | null;
|
||||||
content: Json | null;
|
content: Json | null;
|
||||||
@@ -647,6 +659,35 @@ export interface AiChatMessages {
|
|||||||
deletedAt: Timestamp | null;
|
deletedAt: Timestamp | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The agent RUN as a first-class server-side lifecycle object (#184 phase 1).
|
||||||
|
// Mirrors migration 20260704T130000-ai-chat-runs.ts. A run is created when an
|
||||||
|
// agent turn starts and survives the browser disconnecting; the DB is the source
|
||||||
|
// of truth a later client reconnects to. `assistantMessageId` links to the #183
|
||||||
|
// projection row (the assistant message this run materializes).
|
||||||
|
export interface AiChatRuns {
|
||||||
|
id: Generated<string>;
|
||||||
|
chatId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
// SET NULL on user deletion (the run history outlives its author); also NULL
|
||||||
|
// for a future non-human trigger (cron/api).
|
||||||
|
createdBy: string | null;
|
||||||
|
// The assistant message this run materializes; SET NULL if it is pruned.
|
||||||
|
assistantMessageId: string | null;
|
||||||
|
// 'user' | 'autostart' | 'schedule' | 'api' | 'continue' (only 'user' is
|
||||||
|
// produced in phase 1; the rest are reserved for the deferred autonomy triggers).
|
||||||
|
trigger: Generated<string>;
|
||||||
|
// 'pending' | 'running' | 'succeeded' | 'failed' | 'aborted'.
|
||||||
|
status: Generated<string>;
|
||||||
|
error: string | null;
|
||||||
|
stepCount: Generated<number>;
|
||||||
|
// Set when an EXPLICIT user stop is requested (distinct from a disconnect).
|
||||||
|
stopRequestedAt: Timestamp | null;
|
||||||
|
startedAt: Timestamp | null;
|
||||||
|
finishedAt: Timestamp | null;
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
updatedAt: Generated<Timestamp>;
|
||||||
|
}
|
||||||
|
|
||||||
// Per-(chat,page) snapshot of the open page's Markdown at the END of the agent's
|
// Per-(chat,page) snapshot of the open page's Markdown at the END of the agent's
|
||||||
// previous turn (#274). Mirrors migration 20260702T120000-ai-chat-page-snapshot.ts.
|
// previous turn (#274). Mirrors migration 20260702T120000-ai-chat-page-snapshot.ts.
|
||||||
// The next turn diffs the CURRENT Markdown against `contentMd` to surface edits a
|
// The next turn diffs the CURRENT Markdown against `contentMd` to surface edits a
|
||||||
@@ -683,6 +724,7 @@ export interface DB {
|
|||||||
aiAgentRoles: AiAgentRoles;
|
aiAgentRoles: AiAgentRoles;
|
||||||
aiChats: AiChats;
|
aiChats: AiChats;
|
||||||
aiChatMessages: AiChatMessages;
|
aiChatMessages: AiChatMessages;
|
||||||
|
aiChatRuns: AiChatRuns;
|
||||||
aiChatPageSnapshots: AiChatPageSnapshots;
|
aiChatPageSnapshots: AiChatPageSnapshots;
|
||||||
apiKeys: ApiKeys;
|
apiKeys: ApiKeys;
|
||||||
attachments: Attachments;
|
attachments: Attachments;
|
||||||
@@ -691,6 +733,7 @@ export interface DB {
|
|||||||
authProviders: AuthProviders;
|
authProviders: AuthProviders;
|
||||||
backlinks: Backlinks;
|
backlinks: Backlinks;
|
||||||
billing: Billing;
|
billing: Billing;
|
||||||
|
clientMetrics: ClientMetrics;
|
||||||
comments: Comments;
|
comments: Comments;
|
||||||
favorites: Favorites;
|
favorites: Favorites;
|
||||||
fileTasks: FileTasks;
|
fileTasks: FileTasks;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
AiAgentRoles,
|
AiAgentRoles,
|
||||||
AiChats,
|
AiChats,
|
||||||
AiChatMessages,
|
AiChatMessages,
|
||||||
|
AiChatRuns,
|
||||||
AiChatPageSnapshots,
|
AiChatPageSnapshots,
|
||||||
Attachments,
|
Attachments,
|
||||||
Comments,
|
Comments,
|
||||||
@@ -56,10 +57,12 @@ export type UpdatableAiChat = Updateable<Omit<AiChats, 'id'>>;
|
|||||||
// full-text search. It is omitted from the public type so it never leaks
|
// full-text search. It is omitted from the public type so it never leaks
|
||||||
// into HTTP responses or the chat history fed to the language model.
|
// into HTTP responses or the chat history fed to the language model.
|
||||||
export type AiChatMessage = Omit<Selectable<AiChatMessages>, 'tsv'>;
|
export type AiChatMessage = Omit<Selectable<AiChatMessages>, 'tsv'>;
|
||||||
export type InsertableAiChatMessage = Omit<
|
export type InsertableAiChatMessage = Omit<Insertable<AiChatMessages>, 'tsv'>;
|
||||||
Insertable<AiChatMessages>,
|
|
||||||
'tsv'
|
// AI Chat Run (#184 phase 1): the agent run as a first-class lifecycle object,
|
||||||
>;
|
// detached from the HTTP request / browser window.
|
||||||
|
export type AiChatRun = Selectable<AiChatRuns>;
|
||||||
|
export type InsertableAiChatRun = Insertable<AiChatRuns>;
|
||||||
|
|
||||||
// AI Chat Page Snapshot (#274): per-(chat,page) Markdown snapshot taken at the
|
// AI Chat Page Snapshot (#274): per-(chat,page) Markdown snapshot taken at the
|
||||||
// end of the agent's previous turn, diffed against the current page next turn to
|
// end of the agent's previous turn, diffed against the current page next turn to
|
||||||
@@ -214,11 +217,14 @@ export type UpdatableFavorite = Updateable<Omit<Favorites, 'id'>>;
|
|||||||
// Page Transclusion
|
// Page Transclusion
|
||||||
export type PageTransclusion = Selectable<PageTransclusions>;
|
export type PageTransclusion = Selectable<PageTransclusions>;
|
||||||
export type InsertablePageTransclusion = Insertable<PageTransclusions>;
|
export type InsertablePageTransclusion = Insertable<PageTransclusions>;
|
||||||
export type UpdatablePageTransclusion = Updateable<Omit<PageTransclusions, 'id'>>;
|
export type UpdatablePageTransclusion = Updateable<
|
||||||
|
Omit<PageTransclusions, 'id'>
|
||||||
|
>;
|
||||||
|
|
||||||
// Page Transclusion Reference
|
// Page Transclusion Reference
|
||||||
export type PageTransclusionReference = Selectable<PageTransclusionReferences>;
|
export type PageTransclusionReference = Selectable<PageTransclusionReferences>;
|
||||||
export type InsertablePageTransclusionReference = Insertable<PageTransclusionReferences>;
|
export type InsertablePageTransclusionReference =
|
||||||
|
Insertable<PageTransclusionReferences>;
|
||||||
export type UpdatablePageTransclusionReference = Updateable<
|
export type UpdatablePageTransclusionReference = Updateable<
|
||||||
Omit<PageTransclusionReferences, 'id'>
|
Omit<PageTransclusionReferences, 'id'>
|
||||||
>;
|
>;
|
||||||
@@ -288,7 +294,9 @@ export type UpdatablePagePermission = Updateable<Omit<_PagePermissions, 'id'>>;
|
|||||||
// Page Verification
|
// Page Verification
|
||||||
export type PageVerification = Selectable<_PageVerifications>;
|
export type PageVerification = Selectable<_PageVerifications>;
|
||||||
export type InsertablePageVerification = Insertable<_PageVerifications>;
|
export type InsertablePageVerification = Insertable<_PageVerifications>;
|
||||||
export type UpdatablePageVerification = Updateable<Omit<_PageVerifications, 'id'>>;
|
export type UpdatablePageVerification = Updateable<
|
||||||
|
Omit<_PageVerifications, 'id'>
|
||||||
|
>;
|
||||||
|
|
||||||
// Page Verifier
|
// Page Verifier
|
||||||
export type PageVerifier = Selectable<_PageVerifiers>;
|
export type PageVerifier = Selectable<_PageVerifiers>;
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { streamText, Output } from 'ai';
|
||||||
|
import { MockLanguageModelV3, simulateReadableStream } from 'ai/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression tests for patches/ai@6.0.134.patch (server heap OOM on long
|
||||||
|
* autonomous agent runs, #184).
|
||||||
|
*
|
||||||
|
* Unpatched ai@6.0.134 substitutes the default text() output strategy even
|
||||||
|
* when the caller passes NO `output` option. Its createOutputTransformStream
|
||||||
|
* then accumulates the ENTIRE turn text and, on EVERY text-delta, enqueues a
|
||||||
|
* flat snapshot of all text so far as `partialOutput` (O(n^2) memory). Those
|
||||||
|
* snapshots pile up in the never-consumed leftover tee() branch of
|
||||||
|
* DefaultStreamTextResult.baseStream, which is what OOM'd production during a
|
||||||
|
* ~28k-chunk agent turn. The pnpm patch skips partialOutput production
|
||||||
|
* entirely when no output strategy was requested, while keeping per-delta
|
||||||
|
* streaming granularity.
|
||||||
|
*/
|
||||||
|
describe('ai@6.0.134 pnpm patch: no partialOutput accumulation without an output strategy', () => {
|
||||||
|
const makeModel = () =>
|
||||||
|
new MockLanguageModelV3({
|
||||||
|
doStream: async () => ({
|
||||||
|
stream: simulateReadableStream({
|
||||||
|
chunks: [
|
||||||
|
{ type: 'stream-start' as const, warnings: [] },
|
||||||
|
{ type: 'text-start' as const, id: '1' },
|
||||||
|
{ type: 'text-delta' as const, id: '1', delta: 'Hello' },
|
||||||
|
{ type: 'text-delta' as const, id: '1', delta: ', ' },
|
||||||
|
{ type: 'text-delta' as const, id: '1', delta: 'world!' },
|
||||||
|
{ type: 'text-end' as const, id: '1' },
|
||||||
|
{
|
||||||
|
type: 'finish' as const,
|
||||||
|
finishReason: { unified: 'stop' as const, raw: 'stop' },
|
||||||
|
usage: {
|
||||||
|
inputTokens: {
|
||||||
|
total: 1,
|
||||||
|
noCache: undefined,
|
||||||
|
cacheRead: undefined,
|
||||||
|
cacheWrite: undefined,
|
||||||
|
},
|
||||||
|
outputTokens: { total: 1, text: 1, reasoning: undefined },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves per-delta streaming granularity in textStream', async () => {
|
||||||
|
const result = streamText({ model: makeModel(), prompt: 'hi' });
|
||||||
|
|
||||||
|
const deltas: string[] = [];
|
||||||
|
for await (const delta of result.textStream) {
|
||||||
|
deltas.push(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The patch must NOT coalesce or drop deltas: three model deltas arrive
|
||||||
|
// as three separate textStream chunks.
|
||||||
|
expect(deltas).toEqual(['Hello', ', ', 'world!']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits NO partialOutput values when the caller did not request an output strategy', async () => {
|
||||||
|
const result = streamText({ model: makeModel(), prompt: 'hi' });
|
||||||
|
|
||||||
|
// Fully consume the primary stream first (mirrors production usage).
|
||||||
|
for await (const _ of result.textStream) {
|
||||||
|
// drain
|
||||||
|
}
|
||||||
|
|
||||||
|
const partials: unknown[] = [];
|
||||||
|
for await (const partial of result.experimental_partialOutputStream) {
|
||||||
|
partials.push(partial);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRIPWIRE: on unpatched ai@6.0.134 the default text() output strategy
|
||||||
|
// yields one cumulative partial per text-delta here (['Hello', 'Hello, ',
|
||||||
|
// 'Hello, world!']). An empty stream proves the patch is applied and no
|
||||||
|
// cumulative snapshots are being produced (and thus none can pile up in
|
||||||
|
// the leftover internal tee branch).
|
||||||
|
expect(partials).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves cumulative partialOutput when the caller DOES request an output strategy', async () => {
|
||||||
|
// PRESERVE-BRANCH GUARD: the patch only short-circuits partialOutput when
|
||||||
|
// `output == null`. When an output strategy IS set (here Output.text()),
|
||||||
|
// createOutputTransformStream must fall through to the ORIGINAL code path
|
||||||
|
// and keep publishing cumulative snapshots, so object/text-output consumers
|
||||||
|
// behave byte-identically to unpatched ai. A careless re-port that routed
|
||||||
|
// output-set calls into the skip branch would leave partialOutput empty and
|
||||||
|
// silently break those consumers — this test is the tripwire for that.
|
||||||
|
const result = streamText({
|
||||||
|
model: makeModel(),
|
||||||
|
prompt: 'hi',
|
||||||
|
experimental_output: Output.text(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drain the primary stream fully and accumulate the complete output text.
|
||||||
|
let fullText = '';
|
||||||
|
for await (const delta of result.textStream) {
|
||||||
|
fullText += delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
const partials: string[] = [];
|
||||||
|
for await (const partial of result.experimental_partialOutputStream) {
|
||||||
|
partials.push(partial);
|
||||||
|
}
|
||||||
|
|
||||||
|
// With a strategy set, partialOutput must be PRESERVED (non-empty) and
|
||||||
|
// cumulative: the last emitted partial equals the full accumulated text.
|
||||||
|
expect(partials.length).toBeGreaterThan(0);
|
||||||
|
expect(partials[partials.length - 1]).toBe(fullText);
|
||||||
|
expect(fullText).toBe('Hello, world!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('both installed dist builds (CJS and ESM) carry the patch marker', () => {
|
||||||
|
// Secondary guard: pins the patch to BOTH bundles the SDK ships, since
|
||||||
|
// the NestJS server consumes CJS while other tooling may load ESM.
|
||||||
|
const cjsPath = require.resolve('ai');
|
||||||
|
const mjsPath = cjsPath.replace(/index\.js$/, 'index.mjs');
|
||||||
|
expect(cjsPath).toMatch(/index\.js$/);
|
||||||
|
expect(readFileSync(cjsPath, 'utf8')).toContain('PATCH(docmost');
|
||||||
|
expect(readFileSync(mjsPath, 'utf8')).toContain('PATCH(docmost');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -227,6 +227,22 @@ export class EnvironmentService {
|
|||||||
return compactTree === 'true';
|
return compactTree === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operator toggle for the public client-telemetry sink (#355). DEFAULT OFF:
|
||||||
|
* the unauthenticated POST /api/telemetry/vitals endpoint + client vitals
|
||||||
|
* collection are only wired when this is explicitly true. Kept SEPARATE from
|
||||||
|
* METRICS_PORT (the server Prometheus half) because Grafana reads the
|
||||||
|
* `client_metrics` table directly, independent of the scrape port — and
|
||||||
|
* because `client_metrics` has no app-side retention, so an operator must opt
|
||||||
|
* in and run an external pruner.
|
||||||
|
*/
|
||||||
|
isClientTelemetryEnabled(): boolean {
|
||||||
|
const enabled = this.configService
|
||||||
|
.get<string>('CLIENT_TELEMETRY_ENABLED', 'false')
|
||||||
|
.toLowerCase();
|
||||||
|
return enabled === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
getStripePublishableKey(): string {
|
getStripePublishableKey(): string {
|
||||||
return this.configService.get<string>('STRIPE_PUBLISHABLE_KEY');
|
return this.configService.get<string>('STRIPE_PUBLISHABLE_KEY');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// export.service.ts imports the ESM-only @sindresorhus/slugify (not in jest's
|
||||||
|
// transform allowlist). It is irrelevant to the markdown-serialization path under
|
||||||
|
// test (only used for page-mention link slugs on the DB path), so it is mocked
|
||||||
|
// out to keep the module graph loadable under ts-jest (mirrors the import specs).
|
||||||
|
jest.mock('@sindresorhus/slugify', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: (input: string) => String(input),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { convertProseMirrorToMarkdown } from '@docmost/prosemirror-markdown';
|
||||||
|
import { ExportService } from './export.service';
|
||||||
|
import { ExportFormat } from './dto/export-dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STEP 1 golden test for issue #345: server MARKDOWN export runs DIRECTLY through
|
||||||
|
* the canonical converter (`convertProseMirrorToMarkdown`) — no HTML intermediate
|
||||||
|
* and no `@docmost/editor-ext` markdown layer — so the emitted markdown is in the
|
||||||
|
* canonical package forms and is byte-identical to the git-sync vault body.
|
||||||
|
*
|
||||||
|
* These are the goldens the swap has to satisfy: they assert the CANONICAL
|
||||||
|
* surface (callout `> [!type]`, inline footnote `^[…]`, lossless image
|
||||||
|
* `<!--img …-->`) rather than the old editor-ext forms (`:::type`, `[^id]`,
|
||||||
|
* lossy ``).
|
||||||
|
*
|
||||||
|
* `exportPage(..., singlePage=false)` takes no DB path (no mention rewriting), so
|
||||||
|
* the service is constructed with null collaborators and only the pure
|
||||||
|
* PM -> Markdown path is exercised.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function makeService(): ExportService {
|
||||||
|
return new ExportService(
|
||||||
|
null as any, // pageRepo
|
||||||
|
null as any, // pagePermissionRepo
|
||||||
|
null as any, // db
|
||||||
|
null as any, // storageService
|
||||||
|
null as any, // environmentService
|
||||||
|
null as any, // domainService
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A representative page exercising the node types whose canonical markdown form
|
||||||
|
// changed with the move off the editor-ext layer: callout, inline footnote, and a
|
||||||
|
// lossless image carrying width/align attrs that the old layer dropped.
|
||||||
|
const REPRESENTATIVE_DOC = {
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'Body ' },
|
||||||
|
{ type: 'footnoteReference', attrs: { id: 'fn-1' } },
|
||||||
|
{ type: 'text', text: ' end.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'callout',
|
||||||
|
attrs: { type: 'info', icon: null },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [{ type: 'text', text: 'Heads up' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'image',
|
||||||
|
attrs: {
|
||||||
|
src: '/files/pic.png',
|
||||||
|
alt: 'Pic',
|
||||||
|
width: 320,
|
||||||
|
align: 'left',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'footnotesList',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'footnoteDefinition',
|
||||||
|
attrs: { id: 'fn-1' },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [{ type: 'text', text: 'the note' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ExportService — markdown export via the canonical converter (#345)', () => {
|
||||||
|
it('emits canonical callout, inline footnote and lossless image forms', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
const md = (await service.exportPage(ExportFormat.Markdown, {
|
||||||
|
title: '',
|
||||||
|
content: REPRESENTATIVE_DOC,
|
||||||
|
} as any)) as string;
|
||||||
|
|
||||||
|
// Callout: Obsidian `> [!type]`, NOT the legacy `:::type`.
|
||||||
|
expect(md).toContain('> [!info]');
|
||||||
|
expect(md).not.toContain(':::');
|
||||||
|
|
||||||
|
// Inline footnote: `^[…]`, NOT the reference `[^id]` form.
|
||||||
|
expect(md).toContain('^[the note]');
|
||||||
|
expect(md).not.toMatch(/\[\^/);
|
||||||
|
|
||||||
|
// Lossless image: trailing `<!--img …-->` carrying the dropped attrs.
|
||||||
|
expect(md).toContain('');
|
||||||
|
expect(md).toContain('<!--img');
|
||||||
|
expect(md).toContain('"width":"320"');
|
||||||
|
expect(md).toContain('"align":"left"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('export body is byte-identical to the git-sync vault serializer (export == vault)', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
// A title-less page: exportPage prepends NO heading, so the whole output is
|
||||||
|
// the page BODY — exactly what git-sync serializes (git-sync stores the title
|
||||||
|
// in frontmatter / the filename, never as an in-body H1).
|
||||||
|
const exported = (await service.exportPage(ExportFormat.Markdown, {
|
||||||
|
title: '',
|
||||||
|
content: REPRESENTATIVE_DOC,
|
||||||
|
} as any)) as string;
|
||||||
|
|
||||||
|
// The git-sync vault writer feeds this SAME converter (git-sync
|
||||||
|
// `stabilizePageBody` = convertProseMirrorToMarkdown(content) at the
|
||||||
|
// fixpoint). For an already-stable doc the single pass IS the fixpoint, so
|
||||||
|
// the two are byte-identical by construction — assert it.
|
||||||
|
const vaultBody = convertProseMirrorToMarkdown(REPRESENTATIVE_DOC);
|
||||||
|
expect(exported).toBe(vaultBody);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prepends the page title as an H1 heading (the one documented export/vault delta)', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
const md = (await service.exportPage(ExportFormat.Markdown, {
|
||||||
|
title: 'My Page',
|
||||||
|
content: { type: 'doc', content: [] },
|
||||||
|
} as any)) as string;
|
||||||
|
|
||||||
|
// Export makes standalone files, so it prepends the title as an H1. This is
|
||||||
|
// the ONE deliberate difference from the vault body (which carries the title
|
||||||
|
// in frontmatter). The body below the heading still serializes canonically.
|
||||||
|
expect(md.startsWith('# My Page')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,7 +37,7 @@ import {
|
|||||||
getAttachmentIds,
|
getAttachmentIds,
|
||||||
getProsemirrorContent,
|
getProsemirrorContent,
|
||||||
} from '../../common/helpers/prosemirror/utils';
|
} from '../../common/helpers/prosemirror/utils';
|
||||||
import { htmlToMarkdown } from '@docmost/editor-ext';
|
import { convertProseMirrorToMarkdown } from '@docmost/prosemirror-markdown';
|
||||||
|
|
||||||
type AllowedAttachment = { id: string; fileName: string; filePath: string };
|
type AllowedAttachment = { id: string; fileName: string; filePath: string };
|
||||||
|
|
||||||
@@ -79,9 +79,8 @@ export class ExportService {
|
|||||||
prosemirrorJson.content.unshift(titleNode);
|
prosemirrorJson.content.unshift(titleNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageHtml = jsonToHtml(prosemirrorJson);
|
|
||||||
|
|
||||||
if (format === ExportFormat.HTML) {
|
if (format === ExportFormat.HTML) {
|
||||||
|
const pageHtml = jsonToHtml(prosemirrorJson);
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -92,11 +91,14 @@ export class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (format === ExportFormat.Markdown) {
|
if (format === ExportFormat.Markdown) {
|
||||||
const newPageHtml = pageHtml.replace(
|
// Direct ProseMirror JSON -> Markdown via the canonical converter
|
||||||
/<colgroup[^>]*>[\s\S]*?<\/colgroup>/gim,
|
// (`@docmost/prosemirror-markdown`). This is the SAME serializer the
|
||||||
'',
|
// git-sync vault writer feeds (see git-sync `stabilizePageBody`), so an
|
||||||
);
|
// exported page body is byte-identical to its vault representation — no
|
||||||
return htmlToMarkdown(newPageHtml);
|
// HTML intermediate, no second markdown layer, no format drift (issue
|
||||||
|
// #345). The old `<colgroup>` scrub is gone with the HTML step: the
|
||||||
|
// converter emits GFM tables directly and never produces `<colgroup>`.
|
||||||
|
return convertProseMirrorToMarkdown(prosemirrorJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|||||||
+99
-32
@@ -17,6 +17,22 @@ jest.mock('image-dimensions', () => ({
|
|||||||
__esModule: true,
|
__esModule: true,
|
||||||
imageDimensionsFromData: () => undefined,
|
imageDimensionsFromData: () => undefined,
|
||||||
}));
|
}));
|
||||||
|
// FileImportTaskService -> PageService -> collaboration.gateway ->
|
||||||
|
// metrics.registry imports `prom-client`, which is not resolvable in this
|
||||||
|
// workspace's node_modules (types-only stub, no runtime entry). Metrics are
|
||||||
|
// disabled on this path, so a virtual no-op mock keeps the module graph loadable.
|
||||||
|
jest.mock(
|
||||||
|
'prom-client',
|
||||||
|
() => ({
|
||||||
|
collectDefaultMetrics: () => undefined,
|
||||||
|
Registry: class {},
|
||||||
|
Histogram: class {},
|
||||||
|
Gauge: class {},
|
||||||
|
Counter: class {},
|
||||||
|
Summary: class {},
|
||||||
|
}),
|
||||||
|
{ virtual: true },
|
||||||
|
);
|
||||||
|
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
@@ -26,14 +42,17 @@ import { ImportService } from './import.service';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Binding test for issue #228 / review #5: FileImportTaskService.processGenericImport
|
* Binding test for issue #228 / review #5: FileImportTaskService.processGenericImport
|
||||||
* is a NON-editor write path (markdownToHtml -> processHTML -> JSON, never runs
|
* is a NON-editor write path, so a zip-imported `.md` page ends up with canonical
|
||||||
* footnoteSyncPlugin), so it canonicalizes footnotes before persisting. This pins
|
* footnotes before persisting: ordered by first reference, reused refs deduped,
|
||||||
* that binding — the same one import.service has a spec for — which previously had
|
* orphan definitions dropped.
|
||||||
* NO spec at all.
|
|
||||||
*
|
*
|
||||||
* The markdown -> HTML -> ProseMirror conversion is REAL (a real ImportService,
|
* Since #345 the `.md` parse runs `normalizeForeignMarkdown` ->
|
||||||
* its createYdoc stubbed); the filesystem is a real temp dir with one .md file;
|
* `markdownToProseMirror` -> `jsonToHtml` (feeding the shared HTML attachment /
|
||||||
* the DB transaction is stubbed to capture the persisted page content.
|
* link pipeline) -> `processHTML` -> `canonicalizeFootnotes`. The parser assigns
|
||||||
|
* fresh `fn-*` ids, so we assert by definition BODY order rather than the source
|
||||||
|
* labels. The conversion is REAL (a real ImportService, its createYdoc stubbed);
|
||||||
|
* the filesystem is a real temp dir with one .md file; the DB transaction is
|
||||||
|
* stubbed to capture the persisted page content.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Out-of-order references (c, a, b), a REUSED reference ([^a] twice), and an
|
// Out-of-order references (c, a, b), a REUSED reference ([^a] twice), and an
|
||||||
@@ -49,13 +68,14 @@ const MARKDOWN = [
|
|||||||
'[^z]: orphan note',
|
'[^z]: orphan note',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
function footnoteListIds(content: any): string[] {
|
/** Definition body texts of the (single) footnotesList, in list order. */
|
||||||
|
function footnoteListBodies(content: any): string[] {
|
||||||
const list = (content?.content ?? []).find(
|
const list = (content?.content ?? []).find(
|
||||||
(n: any) => n.type === 'footnotesList',
|
(n: any) => n.type === 'footnotesList',
|
||||||
);
|
);
|
||||||
return (list?.content ?? [])
|
return (list?.content ?? [])
|
||||||
.filter((n: any) => n.type === 'footnoteDefinition')
|
.filter((n: any) => n.type === 'footnoteDefinition')
|
||||||
.map((n: any) => n.attrs?.id);
|
.map((n: any) => n.content?.[0]?.content?.[0]?.text);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A permissive chainable stub for the spaces lookup (selectFrom(...).select(...)
|
// A permissive chainable stub for the spaces lookup (selectFrom(...).select(...)
|
||||||
@@ -71,12 +91,17 @@ function chainable(result: any): any {
|
|||||||
return proxy;
|
return proxy;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('FileImportTaskService.processGenericImport — footnote canonicalization (#228)', () => {
|
/**
|
||||||
it('orders footnotes by first reference, dedupes reuse, and drops orphans on zip import', async () => {
|
* Run one markdown file through the REAL zip-import pipeline
|
||||||
|
* (`processGenericImport` -> `markdownToProseMirror` -> `jsonToHtml` ->
|
||||||
|
* `processHTML`/`htmlToJson`) and return the persisted page `content`. This is
|
||||||
|
* the server-specific PM->HTML->PM hop that the package's own PM<->MD tests do
|
||||||
|
* NOT cover.
|
||||||
|
*/
|
||||||
|
async function runZipImport(markdown: string): Promise<any> {
|
||||||
const extractDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fit-canon-'));
|
const extractDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fit-canon-'));
|
||||||
await fs.writeFile(path.join(extractDir, 'note.md'), MARKDOWN, 'utf-8');
|
await fs.writeFile(path.join(extractDir, 'note.md'), markdown, 'utf-8');
|
||||||
|
|
||||||
// Real ImportService for the html -> JSON conversion; stub the yjs encode.
|
|
||||||
const importService = new ImportService(
|
const importService = new ImportService(
|
||||||
{} as any,
|
{} as any,
|
||||||
{} as any,
|
{} as any,
|
||||||
@@ -104,21 +129,15 @@ describe('FileImportTaskService.processGenericImport — footnote canonicalizati
|
|||||||
const importAttachmentService = {
|
const importAttachmentService = {
|
||||||
processAttachments: async ({ html }: any) => html,
|
processAttachments: async ({ html }: any) => html,
|
||||||
};
|
};
|
||||||
const backlinkRepo = { insertBacklink: jest.fn() };
|
|
||||||
const eventEmitter = { emit: jest.fn() };
|
|
||||||
const auditService = { logBatchWithContext: jest.fn() };
|
|
||||||
|
|
||||||
const pageService = { nextPagePosition: async () => 'a0' };
|
|
||||||
|
|
||||||
const service = new FileImportTaskService(
|
const service = new FileImportTaskService(
|
||||||
{} as any, // storageService
|
{} as any, // storageService
|
||||||
importService as any,
|
importService as any,
|
||||||
pageService as any,
|
{ nextPagePosition: async () => 'a0' } as any,
|
||||||
backlinkRepo as any,
|
{ insertBacklink: jest.fn() } as any,
|
||||||
db,
|
db,
|
||||||
importAttachmentService as any,
|
importAttachmentService as any,
|
||||||
eventEmitter as any,
|
{ emit: jest.fn() } as any,
|
||||||
auditService as any,
|
{ logBatchWithContext: jest.fn() } as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
const fileTask: any = {
|
const fileTask: any = {
|
||||||
@@ -131,20 +150,68 @@ describe('FileImportTaskService.processGenericImport — footnote canonicalizati
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await service.processGenericImport({ extractDir, fileTask });
|
await service.processGenericImport({ extractDir, fileTask });
|
||||||
|
|
||||||
expect(captured).toBeTruthy();
|
expect(captured).toBeTruthy();
|
||||||
const content = captured.content;
|
return captured.content;
|
||||||
// Reference order is c, a, b (NOT the markdown definition order a, b, c).
|
} finally {
|
||||||
expect(footnoteListIds(content)).toEqual(['c', 'a', 'b']);
|
await fs.rm(extractDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the first node of a given type anywhere in a PM content tree. */
|
||||||
|
function findFirst(node: any, type: string): any {
|
||||||
|
if (!node || typeof node !== 'object') return null;
|
||||||
|
if (node.type === type) return node;
|
||||||
|
for (const child of node.content ?? []) {
|
||||||
|
const hit = findFirst(child, type);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FileImportTaskService.processGenericImport — footnote canonicalization (#228)', () => {
|
||||||
|
it('orders footnotes by first reference, dedupes reuse, and drops orphans on zip import', async () => {
|
||||||
|
const content = await runZipImport(MARKDOWN);
|
||||||
|
// Definitions ordered by FIRST REFERENCE (C, A, B), NOT the markdown
|
||||||
|
// definition order (A, B, C). Ids are the parser's fresh `fn-*`, so pin
|
||||||
|
// the BODIES.
|
||||||
|
expect(footnoteListBodies(content)).toEqual(['note C', 'note A', 'note B']);
|
||||||
// Orphan [^z] dropped; reused [^a] collapses to one definition; one list.
|
// Orphan [^z] dropped; reused [^a] collapses to one definition; one list.
|
||||||
expect(footnoteListIds(content)).not.toContain('z');
|
expect(footnoteListBodies(content)).not.toContain('orphan note');
|
||||||
const lists = (content.content ?? []).filter(
|
const lists = (content.content ?? []).filter(
|
||||||
(n: any) => n.type === 'footnotesList',
|
(n: any) => n.type === 'footnotesList',
|
||||||
);
|
);
|
||||||
expect(lists).toHaveLength(1);
|
expect(lists).toHaveLength(1);
|
||||||
expect(footnoteListIds(content).filter((id) => id === 'a')).toHaveLength(1);
|
expect(
|
||||||
} finally {
|
footnoteListBodies(content).filter((b) => b === 'note A'),
|
||||||
await fs.rm(extractDir, { recursive: true, force: true });
|
).toHaveLength(1);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
// #345 F4: the zip path routes markdown through jsonToHtml -> processHTML ->
|
||||||
|
// htmlToJson (the shared HTML attachment pipeline). #345's headline is LOSSLESS
|
||||||
|
// image width/align via the `<!--img {...}-->` comment; a callout carries its
|
||||||
|
// `type`. This asserts those survive the PM->HTML->PM hop — the one hop the
|
||||||
|
// package's PM<->MD suite does not exercise.
|
||||||
|
it('preserves image width/align and callout type through the PM->HTML->PM hop', async () => {
|
||||||
|
const md = [
|
||||||
|
'# Doc',
|
||||||
|
'',
|
||||||
|
' <!--img {"width":"320","align":"left"}-->',
|
||||||
|
'',
|
||||||
|
':::warning',
|
||||||
|
'Careful now.',
|
||||||
|
':::',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const content = await runZipImport(md);
|
||||||
|
|
||||||
|
const image = findFirst(content, 'image');
|
||||||
|
expect(image).toBeTruthy();
|
||||||
|
// The lossless sizing/alignment must survive the HTML hop.
|
||||||
|
expect(String(image.attrs?.width)).toBe('320');
|
||||||
|
expect(image.attrs?.align).toBe('left');
|
||||||
|
|
||||||
|
const callout = findFirst(content, 'callout');
|
||||||
|
expect(callout).toBeTruthy();
|
||||||
|
expect(callout.attrs?.type).toBe('warning');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { jsonToText } from '../../../collaboration/collaboration.util';
|
import {
|
||||||
|
jsonToHtml,
|
||||||
|
jsonToText,
|
||||||
|
} from '../../../collaboration/collaboration.util';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
import {
|
import {
|
||||||
@@ -18,9 +21,11 @@ import { generateSlugId } from '../../../common/helpers';
|
|||||||
import { v7 } from 'uuid';
|
import { v7 } from 'uuid';
|
||||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||||
import { FileTask, InsertablePage } from '@docmost/db/types/entity.types';
|
import { FileTask, InsertablePage } from '@docmost/db/types/entity.types';
|
||||||
import { markdownToHtml, canonicalizeFootnotes } from '@docmost/editor-ext';
|
import { canonicalizeFootnotes } from '@docmost/editor-ext';
|
||||||
|
import { markdownToProseMirror } from '@docmost/prosemirror-markdown';
|
||||||
import { getProsemirrorContent } from '../../../common/helpers/prosemirror/utils';
|
import { getProsemirrorContent } from '../../../common/helpers/prosemirror/utils';
|
||||||
import { formatImportHtml } from '../utils/import-formatter';
|
import { formatImportHtml } from '../utils/import-formatter';
|
||||||
|
import { normalizeForeignMarkdown } from '../utils/foreign-markdown';
|
||||||
import {
|
import {
|
||||||
buildAttachmentCandidates,
|
buildAttachmentCandidates,
|
||||||
collectMarkdownAndHtmlFiles,
|
collectMarkdownAndHtmlFiles,
|
||||||
@@ -461,7 +466,18 @@ export class FileImportTaskService {
|
|||||||
content = await fs.readFile(absPath, 'utf-8');
|
content = await fs.readFile(absPath, 'utf-8');
|
||||||
|
|
||||||
if (page.fileExtension.toLowerCase() === '.md') {
|
if (page.fileExtension.toLowerCase() === '.md') {
|
||||||
content = await markdownToHtml(content);
|
// Parse markdown with the single canonical converter
|
||||||
|
// (`@docmost/prosemirror-markdown`), after normalizing foreign
|
||||||
|
// reference footnotes, then serialize to HTML so the shared HTML
|
||||||
|
// pipeline below (processAttachments + formatImportHtml +
|
||||||
|
// processHTML) keeps handling `.md` and `.html` imports
|
||||||
|
// uniformly. The markdown PARSE no longer goes through the
|
||||||
|
// editor-ext markdown layer (issue #345) — the drift source is
|
||||||
|
// gone. The PM -> HTML -> PM hop that follows is lossless
|
||||||
|
// plumbing for attachment/link resolution, NOT a second parse.
|
||||||
|
content = jsonToHtml(
|
||||||
|
await markdownToProseMirror(normalizeForeignMarkdown(content)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.code === 'ENOENT') {
|
if (err?.code === 'ENOENT') {
|
||||||
@@ -500,10 +516,12 @@ export class FileImportTaskService {
|
|||||||
this.importService.extractTitleAndRemoveHeading(pmState);
|
this.importService.extractTitleAndRemoveHeading(pmState);
|
||||||
|
|
||||||
// Canonicalize footnote topology on this non-editor write path
|
// Canonicalize footnote topology on this non-editor write path
|
||||||
// (markdownToHtml/processHTML never runs footnoteSyncPlugin), so a
|
// (the HTML pipeline's processHTML never runs footnoteSyncPlugin), so
|
||||||
// zip-imported page's footnotes are reference-ordered, deduped, and
|
// a zip-imported page's footnotes are reference-ordered, deduped, and
|
||||||
// orphan-free like the editor's invariant (issue #228). Pure +
|
// orphan-free like the editor's invariant (issue #228). Pure +
|
||||||
// idempotent + shape-safe; a footnote-free doc is unchanged.
|
// idempotent + shape-safe; a footnote-free doc is unchanged. (For a
|
||||||
|
// `.md` file the package parser already yields canonical footnotes,
|
||||||
|
// so this is a no-op there.)
|
||||||
// (Future consolidation, architecture B: like import.service, this
|
// (Future consolidation, architecture B: like import.service, this
|
||||||
// path persists directly rather than via PageService — a shared
|
// path persists directly rather than via PageService — a shared
|
||||||
// "prepare JSON for persist" helper would centralize this call.)
|
// "prepare JSON for persist" helper would centralize this call.)
|
||||||
|
|||||||
+27
-31
@@ -12,13 +12,19 @@ import { canonicalizeFootnotes } from '@docmost/editor-ext';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Integration-ish test for the USER-FACING markdown import path
|
* Integration-ish test for the USER-FACING markdown import path
|
||||||
* (`ImportService.importPage`). It exercises the REAL markdown -> HTML -> JSON
|
* (`ImportService.importPage`). It exercises the REAL markdown -> ProseMirror
|
||||||
* conversion and asserts that the stored page content has its footnotes
|
* conversion and asserts the stored page's footnotes are canonical: ordered by
|
||||||
* canonicalized — the gap that issue #228 fixes: the import path builds
|
* FIRST REFERENCE (not markdown definition order), reused references deduped to a
|
||||||
* ProseMirror JSON directly (never running the editor's footnoteSyncPlugin), so
|
* single definition, and orphan definitions dropped.
|
||||||
* before this wiring the stored footnotes kept the markdown's physical
|
*
|
||||||
* definition order (out of order vs. references), retained orphan definitions,
|
* Since #345 the markdown parse runs through the canonical package
|
||||||
* and did not collapse reused references.
|
* (`normalizeForeignMarkdown` -> `markdownToProseMirror`), which owns this
|
||||||
|
* canonicalization: the input's GFM `[^id]` reference footnotes are normalized to
|
||||||
|
* inline `^[…]`, and the parser assigns fresh sequential ids (`fn-*`) in
|
||||||
|
* reference order while merging identical bodies — so we assert by definition
|
||||||
|
* BODY order, not by the source labels. `canonicalizeFootnotes` remains wired as
|
||||||
|
* an idempotent safety net (issue #228) and is a no-op on this already-canonical
|
||||||
|
* output.
|
||||||
*
|
*
|
||||||
* The DB/ydoc side-effects are stubbed: `getNewPagePosition` (DB query) and
|
* The DB/ydoc side-effects are stubbed: `getNewPagePosition` (DB query) and
|
||||||
* `createYdoc` (Yjs encode) are spied, and `pageRepo.insertPage` captures the
|
* `createYdoc` (Yjs encode) are spied, and `pageRepo.insertPage` captures the
|
||||||
@@ -67,24 +73,14 @@ function makeService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** List the footnote-definition ids of the (single) footnotesList, in order. */
|
/** List the footnote-definition ids of the (single) footnotesList, in order. */
|
||||||
function footnoteListIds(content: any): string[] {
|
/** Definition body texts of the (single) footnotesList, in list order. */
|
||||||
|
function footnoteListBodies(content: any): string[] {
|
||||||
const list = (content.content ?? []).find(
|
const list = (content.content ?? []).find(
|
||||||
(n: any) => n.type === 'footnotesList',
|
(n: any) => n.type === 'footnotesList',
|
||||||
);
|
);
|
||||||
if (!list) return [];
|
return (list?.content ?? [])
|
||||||
return (list.content ?? [])
|
|
||||||
.filter((n: any) => n.type === 'footnoteDefinition')
|
.filter((n: any) => n.type === 'footnoteDefinition')
|
||||||
.map((n: any) => n.attrs?.id);
|
.map((n: any) => n.content?.[0]?.content?.[0]?.text);
|
||||||
}
|
|
||||||
|
|
||||||
function definitionText(content: any, id: string): string | undefined {
|
|
||||||
const list = (content.content ?? []).find(
|
|
||||||
(n: any) => n.type === 'footnotesList',
|
|
||||||
);
|
|
||||||
const def = (list?.content ?? []).find(
|
|
||||||
(n: any) => n.type === 'footnoteDefinition' && n.attrs?.id === id,
|
|
||||||
);
|
|
||||||
return def?.content?.[0]?.content?.[0]?.text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ImportService.importPage — footnote canonicalization (#228)', () => {
|
describe('ImportService.importPage — footnote canonicalization (#228)', () => {
|
||||||
@@ -101,23 +97,23 @@ describe('ImportService.importPage — footnote canonicalization (#228)', () =>
|
|||||||
const content = getCaptured().content;
|
const content = getCaptured().content;
|
||||||
expect(content).toBeTruthy();
|
expect(content).toBeTruthy();
|
||||||
|
|
||||||
// Reference order is c, a, b (NOT the markdown definition order a, b, c).
|
// Definitions ordered by FIRST REFERENCE (C, A, B) — NOT the markdown
|
||||||
expect(footnoteListIds(content)).toEqual(['c', 'a', 'b']);
|
// definition order (A, B, C) — with the orphan [^z] dropped and the reused
|
||||||
|
// [^a] collapsed to a single definition. (Ids are the parser's fresh `fn-*`,
|
||||||
// Definitions preserved and attached to the right ids.
|
// so we pin the BODIES.)
|
||||||
expect(definitionText(content, 'c')).toBe('note C');
|
expect(footnoteListBodies(content)).toEqual(['note C', 'note A', 'note B']);
|
||||||
expect(definitionText(content, 'a')).toBe('note A');
|
|
||||||
expect(definitionText(content, 'b')).toBe('note B');
|
|
||||||
|
|
||||||
// Orphan definition [^z] is dropped.
|
// Orphan definition [^z] is dropped.
|
||||||
expect(footnoteListIds(content)).not.toContain('z');
|
expect(footnoteListBodies(content)).not.toContain('orphan note');
|
||||||
|
|
||||||
// Reused [^a] yields exactly ONE definition, and exactly one list.
|
// Reused [^a] yields exactly ONE definition, and exactly one list.
|
||||||
const lists = (content.content ?? []).filter(
|
const lists = (content.content ?? []).filter(
|
||||||
(n: any) => n.type === 'footnotesList',
|
(n: any) => n.type === 'footnotesList',
|
||||||
);
|
);
|
||||||
expect(lists).toHaveLength(1);
|
expect(lists).toHaveLength(1);
|
||||||
expect(footnoteListIds(content).filter((id) => id === 'a')).toHaveLength(1);
|
expect(
|
||||||
|
footnoteListBodies(content).filter((b) => b === 'note A'),
|
||||||
|
).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is idempotent: canonicalizing the stored output again is a no-op', async () => {
|
it('is idempotent: canonicalizing the stored output again is a no-op', async () => {
|
||||||
@@ -134,6 +130,6 @@ describe('ImportService.importPage — footnote canonicalization (#228)', () =>
|
|||||||
// time must not change it (safe to wire into every write path).
|
// time must not change it (safe to wire into every write path).
|
||||||
const second = canonicalizeFootnotes(stored);
|
const second = canonicalizeFootnotes(stored);
|
||||||
expect(second).toEqual(stored);
|
expect(second).toEqual(stored);
|
||||||
expect(footnoteListIds(second)).toEqual(['c', 'a', 'b']);
|
expect(footnoteListBodies(second)).toEqual(['note C', 'note A', 'note B']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ import {
|
|||||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
import { markdownToHtml, canonicalizeFootnotes } from '@docmost/editor-ext';
|
import { canonicalizeFootnotes } from '@docmost/editor-ext';
|
||||||
|
import { markdownToProseMirror } from '@docmost/prosemirror-markdown';
|
||||||
|
import { normalizeForeignMarkdown } from '../utils/foreign-markdown';
|
||||||
import {
|
import {
|
||||||
FileTaskStatus,
|
FileTaskStatus,
|
||||||
FileTaskType,
|
FileTaskType,
|
||||||
@@ -85,11 +87,13 @@ export class ImportService {
|
|||||||
|
|
||||||
const extracted = this.extractTitleAndRemoveHeading(prosemirrorState);
|
const extracted = this.extractTitleAndRemoveHeading(prosemirrorState);
|
||||||
const title = extracted.title;
|
const title = extracted.title;
|
||||||
// Imported markdown/HTML is built via markdownToHtml -> htmlToJson, which
|
// The markdown path now canonicalizes footnotes itself (the package parser),
|
||||||
// never runs the editor's footnoteSyncPlugin, so the footnote topology keeps
|
// but the HTML path (processHTML -> htmlToJson) does NOT run the editor's
|
||||||
// the source's PHYSICAL definition order (out of order vs. references),
|
// footnoteSyncPlugin, so an imported HTML doc can keep its source's PHYSICAL
|
||||||
// retains orphan definitions, and is not deduped. Canonicalize before
|
// definition order (out of order vs. references), retain orphan definitions,
|
||||||
// persisting so the stored page matches the editor's invariant (issue #228).
|
// and not be deduped. Canonicalize before persisting so the stored page
|
||||||
|
// matches the editor's invariant (issue #228); it is an idempotent no-op on
|
||||||
|
// the already-canonical markdown output.
|
||||||
// Pure + idempotent + shape-safe: a doc with no footnotes is unchanged.
|
// Pure + idempotent + shape-safe: a doc with no footnotes is unchanged.
|
||||||
// (Future consolidation, architecture B: this import path persists directly
|
// (Future consolidation, architecture B: this import path persists directly
|
||||||
// via pageRepo.insertPage rather than through PageService.createPage, so the
|
// via pageRepo.insertPage rather than through PageService.createPage, so the
|
||||||
@@ -133,12 +137,15 @@ export class ImportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async processMarkdown(markdownInput: string): Promise<any> {
|
async processMarkdown(markdownInput: string): Promise<any> {
|
||||||
try {
|
// Canonical markdown -> ProseMirror JSON directly via
|
||||||
const html = await markdownToHtml(markdownInput);
|
// `@docmost/prosemirror-markdown` (issue #345) — no HTML intermediate and no
|
||||||
return this.processHTML(html);
|
// second editor-ext markdown layer. Foreign markdown surfaces the strict
|
||||||
} catch (err) {
|
// canonical parser does not accept (GFM `[^id]` reference footnotes) are
|
||||||
throw err;
|
// rewritten to the canonical inline form by `normalizeForeignMarkdown` first.
|
||||||
}
|
// The HTML-cleanup pass (`normalizeImportHtml`) is intentionally skipped here:
|
||||||
|
// it targets foreign *HTML* (Notion/XWiki), which only ever arrives on the
|
||||||
|
// `.html` path (`processHTML`), never as canonical markdown.
|
||||||
|
return markdownToProseMirror(normalizeForeignMarkdown(markdownInput));
|
||||||
}
|
}
|
||||||
|
|
||||||
async processHTML(htmlInput: string): Promise<any> {
|
async processHTML(htmlInput: string): Promise<any> {
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import {
|
||||||
|
convertProseMirrorToMarkdown,
|
||||||
|
markdownToProseMirror,
|
||||||
|
} from '@docmost/prosemirror-markdown';
|
||||||
|
import { normalizeForeignMarkdown } from './foreign-markdown';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STEP 2 goldens for issue #345: the foreign-markdown normalizer that runs at the
|
||||||
|
* import boundary BEFORE the strict canonical parser (`markdownToProseMirror`).
|
||||||
|
*
|
||||||
|
* Two layers:
|
||||||
|
* 1. PURE string→string cases pinning the normalizer's own behavior (GFM
|
||||||
|
* reference footnotes → inline `^[…]`).
|
||||||
|
* 2. END-TO-END acceptance: for a foreign corpus, `normalizeForeignMarkdown`
|
||||||
|
* then `markdownToProseMirror` then `convertProseMirrorToMarkdown` must leave
|
||||||
|
* NO literal `[^id]` / `:::` garbage in the document and must re-export in the
|
||||||
|
* canonical forms.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('normalizeForeignMarkdown — GFM reference footnotes', () => {
|
||||||
|
it('inlines a single-line reference footnote and drops its definition', () => {
|
||||||
|
const out = normalizeForeignMarkdown(
|
||||||
|
'A note[^1] here.\n\n[^1]: The definition.',
|
||||||
|
);
|
||||||
|
expect(out).toBe('A note^[The definition.] here.\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inlines every reference to a reused id (downstream dedups)', () => {
|
||||||
|
const out = normalizeForeignMarkdown(
|
||||||
|
'X[^a] and Y[^a].\n\n[^a]: shared.',
|
||||||
|
);
|
||||||
|
expect(out).toBe('X^[shared.] and Y^[shared.].\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joins indented continuation lines of a definition with a space', () => {
|
||||||
|
const out = normalizeForeignMarkdown(
|
||||||
|
'See[^n].\n\n[^n]: line one\n line two',
|
||||||
|
);
|
||||||
|
expect(out).toBe('See^[line one line two].\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never rewrites a reference inside a fenced code block', () => {
|
||||||
|
const out = normalizeForeignMarkdown(
|
||||||
|
'```\ncode[^1] here\n```\n\n[^1]: def.',
|
||||||
|
);
|
||||||
|
expect(out).toContain('code[^1] here');
|
||||||
|
// The (now orphaned) definition line is still removed.
|
||||||
|
expect(out).not.toContain('[^1]: def.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never rewrites a reference inside an INLINE-code span (backticks)', () => {
|
||||||
|
// The `[^1]` inside backticks is literal code and must survive verbatim;
|
||||||
|
// the one outside is rewritten. (Bug #1: only fenced blocks were protected.)
|
||||||
|
const out = normalizeForeignMarkdown(
|
||||||
|
'Use `arr[^1]` in code but note[^1] in prose.\n\n[^1]: def.',
|
||||||
|
);
|
||||||
|
expect(out).toBe('Use `arr[^1]` in code but note^[def.] in prose.\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes brackets in a body so an unbalanced ] cannot truncate the footnote', () => {
|
||||||
|
// A foreign definition body with a stray `]` would, unescaped, close the
|
||||||
|
// canonical `^[...]` early and leak the tail as text (bug #2). The body's
|
||||||
|
// brackets are backslash-escaped so the footnote stays whole.
|
||||||
|
const out = normalizeForeignMarkdown(
|
||||||
|
'Ref[^1] here.\n\n[^1]: see item ] and [more] later',
|
||||||
|
);
|
||||||
|
expect(out).toBe('Ref^[see item \\] and \\[more\\] later] here.\n');
|
||||||
|
// The tokenizer must see exactly one unescaped closing bracket (our own).
|
||||||
|
expect(out.match(/(?<!\\)\]/g)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a reference with no matching definition literal (no body to inline)', () => {
|
||||||
|
const out = normalizeForeignMarkdown('Dangling[^x] ref.');
|
||||||
|
expect(out).toBe('Dangling[^x] ref.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the input unchanged when there are no reference footnotes', () => {
|
||||||
|
const md = '# Title\n\nJust text with `inline code` and a [link](/x).';
|
||||||
|
expect(normalizeForeignMarkdown(md)).toBe(md);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT touch callout surfaces — the canonical parser handles them', () => {
|
||||||
|
const callouts = ':::info\nHi\n:::\n\n> [!warning]\n> Careful';
|
||||||
|
expect(normalizeForeignMarkdown(callouts)).toBe(callouts);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips a leading YAML front-matter block (Obsidian/Hugo/git-sync files)', () => {
|
||||||
|
const out = normalizeForeignMarkdown(
|
||||||
|
'---\ntitle: My Page\ntags: [a, b]\n---\n\n# Heading\n\nBody.',
|
||||||
|
);
|
||||||
|
expect(out).toBe('# Heading\n\nBody.');
|
||||||
|
// The front-matter must not leak into the body as a setext heading.
|
||||||
|
expect(out).not.toContain('title: My Page');
|
||||||
|
expect(out).not.toContain('---');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not strip a horizontal rule that is not leading front-matter', () => {
|
||||||
|
const md = 'Intro paragraph.\n\n---\n\nAfter the rule.';
|
||||||
|
expect(normalizeForeignMarkdown(md)).toBe(md);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is linear on a document with thousands of definitions (no quadratic blowup)', () => {
|
||||||
|
// F2(a): the pass-2 rewrite must be O(text), not O(text × defs). Build a
|
||||||
|
// pathological doc (many defs + many plain text lines) and assert it
|
||||||
|
// completes well under a second — a quadratic implementation took ~14s.
|
||||||
|
const N = 4000;
|
||||||
|
const refs = Array.from({ length: N }, (_, i) => `line ${i} plain text`).join('\n');
|
||||||
|
const defs = Array.from({ length: N }, (_, i) => `[^n${i}]: def ${i}`).join('\n');
|
||||||
|
const doc = `start[^n0] and[^n${N - 1}] end\n\n${refs}\n\n${defs}`;
|
||||||
|
const t0 = Date.now();
|
||||||
|
const out = normalizeForeignMarkdown(doc);
|
||||||
|
const elapsed = Date.now() - t0;
|
||||||
|
expect(elapsed).toBeLessThan(2000);
|
||||||
|
// Sanity: the two real references were still inlined.
|
||||||
|
expect(out).toContain('^[def 0]');
|
||||||
|
expect(out).toContain(`^[def ${N - 1}]`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is bounded on a long unclosed backtick run (no inline-split ReDoS)', () => {
|
||||||
|
// F2(b): a huge unterminated backtick run must not cause quadratic
|
||||||
|
// backtracking in the inline-code split. Oversized lines skip the split
|
||||||
|
// entirely (left untouched), so this returns promptly.
|
||||||
|
const line = 'x' + '`'.repeat(200000);
|
||||||
|
const doc = `${line}\n\n[^1]: def`;
|
||||||
|
const t0 = Date.now();
|
||||||
|
normalizeForeignMarkdown(doc);
|
||||||
|
expect(Date.now() - t0).toBeLessThan(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not crash or slow down on thousands of prefix-chain definition ids', () => {
|
||||||
|
// F7: the rewrite must use a FIXED generic scanner, not an alternation built
|
||||||
|
// from the ids. A `(a|aa|aaa|…)` alternation over prefix-chain ids blows the
|
||||||
|
// V8 regex compiler (FATAL RegExpCompiler Allocation failed — uncatchable,
|
||||||
|
// kills the process). A fixed scanner has no id-dependent compilation cost.
|
||||||
|
const N = 4000;
|
||||||
|
const ids = Array.from({ length: N }, (_, i) => 'a'.repeat(i + 1));
|
||||||
|
const defs = ids.map((id) => `[^${id}]: body ${id.length}`).join('\n');
|
||||||
|
const doc = `ref[^${ids[0]}] and[^${ids[N - 1]}] end\n\n${defs}`;
|
||||||
|
const t0 = Date.now();
|
||||||
|
const out = normalizeForeignMarkdown(doc);
|
||||||
|
expect(Date.now() - t0).toBeLessThan(2000);
|
||||||
|
// Prefix disambiguation is correct: [^a] and [^aaaa...] inline their OWN body.
|
||||||
|
expect(out).toContain('^[body 1]');
|
||||||
|
expect(out).toContain(`^[body ${N}]`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips a CRLF (Windows) front-matter block, not just LF', () => {
|
||||||
|
// F9: the line-anchored regex needs LF after the opening `---`, so a Windows
|
||||||
|
// file (`---\r\n…`) would slip past the strip and leak the front-matter into
|
||||||
|
// the body. normalizeForeignMarkdown normalizes CRLF -> LF first.
|
||||||
|
const out = normalizeForeignMarkdown(
|
||||||
|
'---\r\ntitle: Foo\r\ntags: [a]\r\n---\r\n\r\n# Heading\r\n\r\nBody.',
|
||||||
|
);
|
||||||
|
expect(out).toBe('# Heading\n\nBody.');
|
||||||
|
expect(out).not.toContain('title: Foo');
|
||||||
|
expect(out).not.toContain('---');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips front-matter whose value contains a triple-dash (line-anchored)', () => {
|
||||||
|
// F8: the block must close only on a `\n---` LINE, not the first inline
|
||||||
|
// `---`. A value like `title: Q1 --- Q2` must not truncate the front-matter
|
||||||
|
// and leak the rest (author/closing ---) into the body.
|
||||||
|
const out = normalizeForeignMarkdown(
|
||||||
|
'---\ntitle: Q1 --- Q2 results\nauthor: bob\n---\n\nReal body.',
|
||||||
|
);
|
||||||
|
expect(out).toBe('Real body.');
|
||||||
|
expect(out).not.toContain('author: bob');
|
||||||
|
expect(out).not.toContain('Q2 results');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('foreign markdown import acceptance (normalizer + canonical parser)', () => {
|
||||||
|
const FOREIGN = [
|
||||||
|
'# Doc',
|
||||||
|
'',
|
||||||
|
'Body refs [^c] and [^a] and [^b] and again [^a].',
|
||||||
|
'',
|
||||||
|
':::info',
|
||||||
|
'A legacy callout.',
|
||||||
|
':::',
|
||||||
|
'',
|
||||||
|
'| h1 | h2 |',
|
||||||
|
'| --- | --- |',
|
||||||
|
'| 1 | 2 |',
|
||||||
|
'',
|
||||||
|
'[^a]: note A',
|
||||||
|
'[^b]: note B',
|
||||||
|
'[^c]: note C',
|
||||||
|
'[^z]: orphan note',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
it('leaves no literal [^id] or ::: in the imported doc and re-exports canonically', async () => {
|
||||||
|
const normalized = normalizeForeignMarkdown(FOREIGN);
|
||||||
|
const doc = await markdownToProseMirror(normalized);
|
||||||
|
const reexport = convertProseMirrorToMarkdown(doc);
|
||||||
|
|
||||||
|
// No foreign garbage leaks into the document.
|
||||||
|
expect(reexport).not.toMatch(/\[\^/); // no reference footnote refs/defs
|
||||||
|
expect(reexport).not.toContain(':::'); // no legacy callout fences
|
||||||
|
|
||||||
|
// Canonical forms are present.
|
||||||
|
expect(reexport).toContain('^[note C]');
|
||||||
|
expect(reexport).toContain('> [!info]');
|
||||||
|
expect(reexport).toContain('| h1 | h2 |');
|
||||||
|
|
||||||
|
// Footnotes: ordered by first reference (C, A, B), reused [^a] deduped to one,
|
||||||
|
// orphan [^z] dropped (it had no reference after normalization).
|
||||||
|
const list = doc.content.find((n: any) => n.type === 'footnotesList');
|
||||||
|
const bodies = list.content.map(
|
||||||
|
(d: any) => d.content[0].content[0].text,
|
||||||
|
);
|
||||||
|
expect(bodies).toEqual(['note C', 'note A', 'note B']);
|
||||||
|
expect(bodies).not.toContain('orphan note');
|
||||||
|
expect(
|
||||||
|
doc.content.filter((n: any) => n.type === 'footnotesList'),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* Foreign-markdown normalizer — an input-liberal / output-canonical adapter that
|
||||||
|
* runs at the IMPORT boundary, BEFORE the canonical parser
|
||||||
|
* (`markdownToProseMirror` from `@docmost/prosemirror-markdown`).
|
||||||
|
*
|
||||||
|
* The canonical parser is deliberately STRICT: it only understands Docmost's
|
||||||
|
* canonical markdown surface (Obsidian-style `> [!type]` callouts, Pandoc/Obsidian
|
||||||
|
* inline footnotes `^[body]`, lossless ` <!--img {...}-->` images, …).
|
||||||
|
* Import, however, ingests FOREIGN files (GitHub/GFM, Notion, old Docmost
|
||||||
|
* exports). Those use surfaces the canonical parser does not accept, most notably
|
||||||
|
* GitHub-flavoured *reference* footnotes:
|
||||||
|
*
|
||||||
|
* Text with a note[^1] and another[^long].
|
||||||
|
*
|
||||||
|
* [^1]: The first definition.
|
||||||
|
* [^long]: A second one.
|
||||||
|
*
|
||||||
|
* Left untouched, the parser does NOT recognise `[^id]` (it only parses `^[body]`),
|
||||||
|
* so the reference leaks as literal text — and worse, the trailing `[^id]: def`
|
||||||
|
* line is a valid CommonMark *link-reference definition*, so `[^id]` is silently
|
||||||
|
* rendered as a bogus link. This normalizer rewrites reference footnotes into the
|
||||||
|
* canonical inline form so the parser materialises real footnote nodes.
|
||||||
|
*
|
||||||
|
* This is a TEXT pre-pass, NOT a second parser fork: it does not re-implement any
|
||||||
|
* converter logic. Callout surfaces (`:::type` and `> [!type]`) are intentionally
|
||||||
|
* NOT touched here — the canonical parser already accepts BOTH natively (its
|
||||||
|
* `preprocessCallouts` pass), so normalizing them would be redundant and would
|
||||||
|
* only risk degrading the parser's nesting/code-fence-aware handling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Matches a fenced code block delimiter (``` or ~~~), capturing the marker run. */
|
||||||
|
const CODE_FENCE_RE = /^(\s*)(`{3,}|~{3,})/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches a GFM footnote DEFINITION line: `[^id]: body`. The id is any run of
|
||||||
|
* non-`]` characters; the body is the remainder of the line (possibly empty).
|
||||||
|
*/
|
||||||
|
const FOOTNOTE_DEF_RE = /^\[\^([^\]]+)\]:[ \t]?(.*)$/;
|
||||||
|
|
||||||
|
/** True when a line is a code-fence delimiter that toggles fenced-code state. */
|
||||||
|
function fenceMarker(line: string): string | null {
|
||||||
|
const m = line.match(CODE_FENCE_RE);
|
||||||
|
return m ? m[2] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a line is indented (leading space/tab) and not blank — a continuation. */
|
||||||
|
function isIndentedContinuation(line: string): boolean {
|
||||||
|
return /^[ \t]+\S/.test(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backslash-escape any square bracket in a footnote body before it is wrapped in
|
||||||
|
* `^[...]`. The canonical inline-footnote tokenizer scans the body with bracket
|
||||||
|
* balancing and closes on the first UNMATCHED `]`, so an unbalanced bracket in a
|
||||||
|
* foreign definition (e.g. `[^1]: see item ] later`) would otherwise truncate the
|
||||||
|
* footnote and leak the tail as literal text. Escaping every `[`/`]` makes the
|
||||||
|
* body an inert run of characters — the tokenizer then closes only on our own
|
||||||
|
* closing `]`. (A balanced `[link](url)` inside a body still round-trips because
|
||||||
|
* the escaped form renders the literal brackets, which is the safe reading for a
|
||||||
|
* footnote body; the alternative — brittle balance tracking — risks worse.)
|
||||||
|
*/
|
||||||
|
function escapeFootnoteBody(body: string): string {
|
||||||
|
return body.replace(/[[\]]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite every `[^id]` reference on a line to its `^[body]` form, but ONLY in the
|
||||||
|
* text OUTSIDE inline-code spans. A `[^id]` inside backticks is literal code
|
||||||
|
* content and must be preserved verbatim (a footnote ref never lives inside code).
|
||||||
|
* We split the line on inline-code spans (paired backtick runs) and rewrite only
|
||||||
|
* the non-code segments.
|
||||||
|
*/
|
||||||
|
// Above this length a single line is not split into inline-code spans (see
|
||||||
|
// below). A genuine markdown line carrying a footnote reference is never tens of
|
||||||
|
// KB; the cap only bypasses the inline-code protection for pathological lines.
|
||||||
|
const INLINE_SPLIT_MAX_LINE = 8192;
|
||||||
|
|
||||||
|
function rewriteRefsOutsideInlineCode(
|
||||||
|
line: string,
|
||||||
|
replace: (text: string) => string,
|
||||||
|
): string {
|
||||||
|
// The inline-code split alternation `(`+)(?:(?!\1)[\s\S])*\1` backtracks
|
||||||
|
// quadratically on a long UNCLOSED backtick run (its middle can consume the
|
||||||
|
// rest of the line, then fail to find a closing run and retry from each
|
||||||
|
// position). On an untrusted import this is a request-thread ReDoS. A real
|
||||||
|
// footnote line is short, so for an oversized line we skip the inline-code
|
||||||
|
// protection entirely and leave the line UNTOUCHED (rewriting it wholesale
|
||||||
|
// could corrupt a `[^id]` that legitimately lives inside inline code). This is
|
||||||
|
// a conservative bypass: an over-8KB line simply does not get its reference
|
||||||
|
// footnotes inlined — acceptable for a pathological input.
|
||||||
|
if (line.length > INLINE_SPLIT_MAX_LINE) return line;
|
||||||
|
|
||||||
|
// Alternation: an inline-code span (one or more backticks, then anything up to
|
||||||
|
// the SAME run of backticks) OR a run of non-backtick text. Unterminated
|
||||||
|
// backticks fall through as ordinary text (matched by the second branch on the
|
||||||
|
// leftover), so a stray backtick never swallows the rest of the line.
|
||||||
|
const parts = line.match(/(`+)(?:(?!\1)[\s\S])*\1|[^`]+|`+/g);
|
||||||
|
if (!parts) return line;
|
||||||
|
return parts
|
||||||
|
.map((seg) => (seg.startsWith('`') ? seg : replace(seg)))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert GFM reference footnotes (`[^id]` + `[^id]: def`) into canonical inline
|
||||||
|
* footnotes (`^[def]`).
|
||||||
|
*
|
||||||
|
* - Definitions are collected first (a leading `[^id]: text` line plus any
|
||||||
|
* immediately-following indented continuation lines, joined with a space) and
|
||||||
|
* removed from the output.
|
||||||
|
* - Each in-text reference `[^id]` for which a definition was found is replaced by
|
||||||
|
* `^[def]`. References with no matching definition are left literal (there is no
|
||||||
|
* body to inline; the parser fails them open the same way).
|
||||||
|
* - Code is respected on both passes: `[^id]` inside a fenced ``` / ~~~ block is
|
||||||
|
* never rewritten and a `[^id]:` line inside a fence is never a definition; and
|
||||||
|
* on the rewrite pass a `[^id]` inside an INLINE-code span (backticks) is left
|
||||||
|
* literal too.
|
||||||
|
* - The inlined body is bracket-escaped so an unbalanced `[`/`]` in a foreign
|
||||||
|
* definition cannot truncate the resulting `^[...]` footnote.
|
||||||
|
*
|
||||||
|
* Deduplication / reference-ordering / orphan-dropping of the resulting footnotes
|
||||||
|
* is handled downstream by the canonical parser (`assembleFootnotes`); this pass
|
||||||
|
* only changes the surface syntax.
|
||||||
|
*/
|
||||||
|
function convertReferenceFootnotes(markdown: string): string {
|
||||||
|
const lines = markdown.split('\n');
|
||||||
|
|
||||||
|
// Pass 1: collect definitions and mark their lines for removal.
|
||||||
|
const defs = new Map<string, string>();
|
||||||
|
const dropped = new Array<boolean>(lines.length).fill(false);
|
||||||
|
let inFence = false;
|
||||||
|
let fence = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
const marker = fenceMarker(line);
|
||||||
|
if (inFence) {
|
||||||
|
if (marker && marker[0] === fence[0] && marker.length >= fence.length) {
|
||||||
|
inFence = false;
|
||||||
|
fence = '';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (marker) {
|
||||||
|
inFence = true;
|
||||||
|
fence = marker;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const def = line.match(FOOTNOTE_DEF_RE);
|
||||||
|
if (!def) continue;
|
||||||
|
|
||||||
|
const id = def[1];
|
||||||
|
const body: string[] = [def[2].trim()];
|
||||||
|
dropped[i] = true;
|
||||||
|
|
||||||
|
// Consume immediately-following indented continuation lines (GFM lazy
|
||||||
|
// continuation is not supported by design — keep it simple and predictable).
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < lines.length && isIndentedContinuation(lines[j])) {
|
||||||
|
body.push(lines[j].trim());
|
||||||
|
dropped[j] = true;
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
i = j - 1;
|
||||||
|
|
||||||
|
// Last definition wins for a duplicated id (matches CommonMark link-ref
|
||||||
|
// semantics closely enough for a foreign-input adapter).
|
||||||
|
defs.set(id, body.filter((s) => s.length > 0).join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defs.size === 0) {
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ONE fixed, generic scanner regex — NOT one built from the definition ids.
|
||||||
|
// It matches ANY `[^id]` shape, and the replacer decides per match via a map
|
||||||
|
// lookup whether that id is a real definition (replace) or not (leave as-is).
|
||||||
|
// This is genuinely O(total text) with no per-document regex compilation.
|
||||||
|
//
|
||||||
|
// Do NOT rebuild this as an alternation over `[...defs.keys()]`: a giant
|
||||||
|
// `(id1|id2|...)` alternation over thousands of ids can blow the V8 regex
|
||||||
|
// compiler's stack — a fatal, UNCATCHABLE "RegExpCompiler Allocation failed"
|
||||||
|
// on prefix-chain ids (`a`, `aa`, `aaa`, ...) that kills the whole process
|
||||||
|
// (worse than the earlier per-def thread-hang). A fixed scanner has no
|
||||||
|
// id-dependent compilation cost and cannot blow up.
|
||||||
|
const refRe = /\[\^([^\]]+)\]/g;
|
||||||
|
const rewriteSegment = (segment: string): string =>
|
||||||
|
segment.replace(refRe, (whole, id: string) => {
|
||||||
|
const body = defs.get(id);
|
||||||
|
// Only real definitions are inlined; an unknown id is left literal (same as
|
||||||
|
// the old per-def loop, which simply never matched it).
|
||||||
|
return body === undefined ? whole : `^[${escapeFootnoteBody(body)}]`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pass 2: rewrite in-text references, skipping fenced code and dropped lines.
|
||||||
|
const out: string[] = [];
|
||||||
|
inFence = false;
|
||||||
|
fence = '';
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (dropped[i]) continue;
|
||||||
|
let line = lines[i];
|
||||||
|
|
||||||
|
const marker = fenceMarker(line);
|
||||||
|
if (inFence) {
|
||||||
|
out.push(line);
|
||||||
|
if (marker && marker[0] === fence[0] && marker.length >= fence.length) {
|
||||||
|
inFence = false;
|
||||||
|
fence = '';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (marker) {
|
||||||
|
inFence = true;
|
||||||
|
fence = marker;
|
||||||
|
out.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
line = rewriteRefsOutsideInlineCode(line, rewriteSegment);
|
||||||
|
out.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip a single leading YAML front-matter block (`---\n…\n---`). Foreign files
|
||||||
|
* from Obsidian / Hugo / Jekyll / Notion — and Docmost's OWN git-sync page files
|
||||||
|
* — open with front-matter that the canonical parser does not consume, so
|
||||||
|
* without this it leaks into the body (and `title: Foo` above the closing `---`
|
||||||
|
* renders as a setext `<h2>` that `extractTitleAndRemoveHeading` can hijack as
|
||||||
|
* the page title). It is a no-op for front-matter-free input.
|
||||||
|
*
|
||||||
|
* LINE-ANCHORED (the same shape the canonical parser uses in
|
||||||
|
* prosemirror-markdown/page-file.ts): the block opens only on `---\n` at the
|
||||||
|
* very start and closes only on a `\n---` line. The retired `markdownToHtml`
|
||||||
|
* strip closed on the FIRST `---` ANYWHERE (an unanchored close), so a value
|
||||||
|
* containing a triple-dash (e.g. `title: Q1 --- Q2`) truncated the front-matter
|
||||||
|
* and leaked the rest into the body. An optional leading BOM is tolerated.
|
||||||
|
*/
|
||||||
|
const YAML_FRONT_MATTER_RE = /^\uFEFF?---\n[\s\S]*?\n---\n?/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a foreign markdown string into Docmost's canonical markdown surface
|
||||||
|
* so the strict canonical parser accepts it losslessly: normalize line endings,
|
||||||
|
* strip a leading YAML front-matter block, then rewrite GFM reference footnotes
|
||||||
|
* into inline footnotes. Add further fixture-driven foreign-surface cases here as
|
||||||
|
* they are found.
|
||||||
|
*/
|
||||||
|
export function normalizeForeignMarkdown(markdown: string): string {
|
||||||
|
if (!markdown) return markdown;
|
||||||
|
// Normalize CRLF -> LF FIRST. The line-anchored front-matter regex requires a
|
||||||
|
// bare `\n` after the opening `---`, and convertReferenceFootnotes splits on
|
||||||
|
// `\n`; a Windows/CRLF foreign file (`---\r\n…`) would otherwise slip past the
|
||||||
|
// front-matter strip and leak into the body. The canonical parser
|
||||||
|
// (page-file.ts parsePageFile) normalizes the same way before its FRONTMATTER_RE.
|
||||||
|
const src = markdown.replace(/\r\n/g, '\n');
|
||||||
|
const withoutFrontMatter = src.replace(YAML_FRONT_MATTER_RE, '').trimStart();
|
||||||
|
return convertReferenceFootnotes(withoutFrontMatter);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { isStreamingResponse } from './metrics.constants';
|
||||||
|
import { observeHttp } from './metrics.registry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the BOUNDED route label for an HTTP response.
|
||||||
|
*
|
||||||
|
* HARD REQUIREMENT (#355): use the ROUTE TEMPLATE (`/pages/:id`), NEVER the raw
|
||||||
|
* URL (`/pages/abc-123`), so label cardinality stays finite. Fastify exposes the
|
||||||
|
* matched template on `req.routeOptions.url`. On 404s (no route matched) that is
|
||||||
|
* missing → collapse to the literal `unknown`.
|
||||||
|
*/
|
||||||
|
export function resolveRouteLabel(req: FastifyRequest): string {
|
||||||
|
const url = req.routeOptions?.url;
|
||||||
|
return typeof url === 'string' && url.length > 0 ? url : 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fastify onResponse handler that records http_request_duration_seconds.
|
||||||
|
* No-op when metrics are disabled (the hook is only registered when enabled,
|
||||||
|
* but the observe helpers are also guarded). Never throws into the response
|
||||||
|
* pipeline — telemetry must not break request handling.
|
||||||
|
*/
|
||||||
|
export function recordHttpResponse(
|
||||||
|
req: FastifyRequest,
|
||||||
|
reply: FastifyReply,
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
const route = resolveRouteLabel(req);
|
||||||
|
|
||||||
|
// Exclude SSE/streaming responses: onResponse fires at connection close for
|
||||||
|
// those, so it would record the stream lifetime and poison p95/p99.
|
||||||
|
const contentType = reply.getHeader('content-type');
|
||||||
|
if (isStreamingResponse(contentType, route)) return;
|
||||||
|
|
||||||
|
observeHttp(
|
||||||
|
req.method,
|
||||||
|
route,
|
||||||
|
reply.statusCode,
|
||||||
|
// Fastify measures elapsed time in ms; the metric is in seconds.
|
||||||
|
reply.elapsedTime / 1000,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Swallow: a telemetry failure must never affect the served response.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
OnModuleDestroy,
|
||||||
|
OnModuleInit,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
|
import { Queue, QueueEvents } from 'bullmq';
|
||||||
|
import { QueueName } from '../queue/constants';
|
||||||
|
import { EnvironmentService } from '../environment/environment.service';
|
||||||
|
import { parseRedisUrl } from '../../common/helpers';
|
||||||
|
import {
|
||||||
|
isMetricsEnabled,
|
||||||
|
observeJobDuration,
|
||||||
|
setQueueDepth,
|
||||||
|
} from './metrics.registry';
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 15_000;
|
||||||
|
// Cap the in-flight start-time map so a job that never emits completed/failed
|
||||||
|
// (worker crash) cannot leak memory unbounded. Well above realistic concurrency.
|
||||||
|
const MAX_INFLIGHT = 10_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BullMQ instrumentation for #355:
|
||||||
|
* - `bullmq_queue_depth{queue}`: polled from getJobCounts() every 15s.
|
||||||
|
* - `bullmq_job_duration_seconds{queue}`: wall-clock time between a job going
|
||||||
|
* `active` and `completed`/`failed`, observed via per-queue QueueEvents.
|
||||||
|
*
|
||||||
|
* Queue names are a FINITE list (the QueueName enum), so labels are bounded — no
|
||||||
|
* job ids ever enter a label. Everything is gated on METRICS_PORT: when metrics
|
||||||
|
* are off, onModuleInit does nothing (no interval, no QueueEvents connections).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class MetricsBullService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(MetricsBullService.name);
|
||||||
|
private readonly queues: { label: string; queue: Queue }[];
|
||||||
|
private timer: NodeJS.Timeout | null = null;
|
||||||
|
private queueEvents: QueueEvents[] = [];
|
||||||
|
// jobId -> start timestamp (ms). Bounded by MAX_INFLIGHT.
|
||||||
|
private readonly inflight = new Map<string, number>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly environmentService: EnvironmentService,
|
||||||
|
@InjectQueue(QueueName.EMAIL_QUEUE) emailQueue: Queue,
|
||||||
|
@InjectQueue(QueueName.ATTACHMENT_QUEUE) attachmentQueue: Queue,
|
||||||
|
@InjectQueue(QueueName.GENERAL_QUEUE) generalQueue: Queue,
|
||||||
|
@InjectQueue(QueueName.BILLING_QUEUE) billingQueue: Queue,
|
||||||
|
@InjectQueue(QueueName.FILE_TASK_QUEUE) fileTaskQueue: Queue,
|
||||||
|
@InjectQueue(QueueName.SEARCH_QUEUE) searchQueue: Queue,
|
||||||
|
@InjectQueue(QueueName.AI_QUEUE) aiQueue: Queue,
|
||||||
|
@InjectQueue(QueueName.HISTORY_QUEUE) historyQueue: Queue,
|
||||||
|
@InjectQueue(QueueName.NOTIFICATION_QUEUE) notificationQueue: Queue,
|
||||||
|
@InjectQueue(QueueName.AUDIT_QUEUE) auditQueue: Queue,
|
||||||
|
) {
|
||||||
|
this.queues = [
|
||||||
|
{ label: 'email', queue: emailQueue },
|
||||||
|
{ label: 'attachment', queue: attachmentQueue },
|
||||||
|
{ label: 'general', queue: generalQueue },
|
||||||
|
{ label: 'billing', queue: billingQueue },
|
||||||
|
{ label: 'file-task', queue: fileTaskQueue },
|
||||||
|
{ label: 'search', queue: searchQueue },
|
||||||
|
{ label: 'ai', queue: aiQueue },
|
||||||
|
{ label: 'history', queue: historyQueue },
|
||||||
|
{ label: 'notification', queue: notificationQueue },
|
||||||
|
{ label: 'audit', queue: auditQueue },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleInit(): void {
|
||||||
|
if (!isMetricsEnabled()) return;
|
||||||
|
|
||||||
|
// Poll queue depth.
|
||||||
|
this.timer = setInterval(() => {
|
||||||
|
void this.pollDepths();
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
// Do not keep the event loop alive solely for polling.
|
||||||
|
this.timer.unref?.();
|
||||||
|
void this.pollDepths();
|
||||||
|
|
||||||
|
// Wire per-queue job-duration events.
|
||||||
|
const redisConfig = parseRedisUrl(this.environmentService.getRedisUrl());
|
||||||
|
const connection = {
|
||||||
|
host: redisConfig.host,
|
||||||
|
port: redisConfig.port,
|
||||||
|
password: redisConfig.password,
|
||||||
|
db: redisConfig.db,
|
||||||
|
family: redisConfig.family,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const { label, queue } of this.queues) {
|
||||||
|
const events = new QueueEvents(queue.name, { connection });
|
||||||
|
events.on('active', ({ jobId }) => {
|
||||||
|
if (this.inflight.size >= MAX_INFLIGHT) {
|
||||||
|
// Drop the oldest tracked start to keep the map bounded.
|
||||||
|
const oldest = this.inflight.keys().next().value;
|
||||||
|
if (oldest !== undefined) this.inflight.delete(oldest);
|
||||||
|
}
|
||||||
|
this.inflight.set(jobId, Date.now());
|
||||||
|
});
|
||||||
|
const finalize = ({ jobId }: { jobId: string }) => {
|
||||||
|
const start = this.inflight.get(jobId);
|
||||||
|
if (start === undefined) return;
|
||||||
|
this.inflight.delete(jobId);
|
||||||
|
observeJobDuration(label, (Date.now() - start) / 1000);
|
||||||
|
};
|
||||||
|
events.on('completed', finalize);
|
||||||
|
events.on('failed', finalize);
|
||||||
|
events.on('error', (err) => {
|
||||||
|
this.logger.debug(`QueueEvents error (${label}): ${err?.message}`);
|
||||||
|
});
|
||||||
|
this.queueEvents.push(events);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pollDepths(): Promise<void> {
|
||||||
|
for (const { label, queue } of this.queues) {
|
||||||
|
try {
|
||||||
|
const counts = await queue.getJobCounts();
|
||||||
|
// "Depth" = jobs not yet finished (backlog + in-flight).
|
||||||
|
const depth =
|
||||||
|
(counts.waiting ?? 0) +
|
||||||
|
(counts.active ?? 0) +
|
||||||
|
(counts.delayed ?? 0) +
|
||||||
|
(counts.prioritized ?? 0) +
|
||||||
|
(counts.paused ?? 0);
|
||||||
|
setQueueDepth(label, depth);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Failed to read job counts for ${label}: ${(err as Error)?.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
if (this.timer) {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
await Promise.all(
|
||||||
|
this.queueEvents.map((e) => e.close().catch(() => undefined)),
|
||||||
|
);
|
||||||
|
this.queueEvents = [];
|
||||||
|
this.inflight.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { closeMetricsServer } from './metrics.server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ties the bare node:http metrics scrape server (started in main.ts after the
|
||||||
|
* Fastify app is up, outside the DI container) into Nest's shutdown lifecycle.
|
||||||
|
* With `app.enableShutdownHooks()`, onModuleDestroy fires on SIGTERM/SIGINT and
|
||||||
|
* closes the listener so it is not left dangling (jest/e2e never exits, and a
|
||||||
|
* prod restart doesn't leak the port). No-op when metrics are disabled.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class MetricsServerLifecycle implements OnModuleDestroy {
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
await closeMetricsServer();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Perf-metrics contract (#355). These names/labels are FIXED by the already
|
||||||
|
* deployed scrape+dashboard infra (VictoriaMetrics scraping docmost:9464,
|
||||||
|
* Grafana dashboards, alerts). Do NOT rename them.
|
||||||
|
*/
|
||||||
|
export const METRIC_HTTP_REQUEST_DURATION = 'http_request_duration_seconds';
|
||||||
|
export const METRIC_DB_QUERY_DURATION = 'db_query_duration_seconds';
|
||||||
|
export const METRIC_BULLMQ_QUEUE_DEPTH = 'bullmq_queue_depth';
|
||||||
|
export const METRIC_BULLMQ_JOB_DURATION = 'bullmq_job_duration_seconds';
|
||||||
|
export const METRIC_COLLAB_STORE_DURATION = 'collab_store_duration_seconds';
|
||||||
|
|
||||||
|
// Histogram buckets (seconds). Chosen to give useful p50/p95/p99 resolution
|
||||||
|
// for typical web/DB latencies without exploding series cardinality.
|
||||||
|
export const HTTP_BUCKETS = [
|
||||||
|
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10,
|
||||||
|
];
|
||||||
|
export const DB_BUCKETS = [
|
||||||
|
0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5,
|
||||||
|
];
|
||||||
|
export const COLLAB_BUCKETS = [
|
||||||
|
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5,
|
||||||
|
];
|
||||||
|
export const JOB_BUCKETS = [
|
||||||
|
0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the first SQL token (select/insert/update/delete/...) from a query,
|
||||||
|
* lower-cased, to use as a BOUNDED label for db_query_duration_seconds. Using
|
||||||
|
* the full query text would blow up label cardinality; the leading keyword is a
|
||||||
|
* finite set. Unknown/empty queries collapse to `other`.
|
||||||
|
*/
|
||||||
|
// The bounded set of SQL leading keywords used as db_query_duration_seconds
|
||||||
|
// labels. Module-const so it is built ONCE, not per query (this runs on every DB
|
||||||
|
// query when metrics are enabled).
|
||||||
|
const KNOWN_SQL_TOKENS = new Set([
|
||||||
|
'select',
|
||||||
|
'insert',
|
||||||
|
'update',
|
||||||
|
'delete',
|
||||||
|
'with',
|
||||||
|
'begin',
|
||||||
|
'commit',
|
||||||
|
'rollback',
|
||||||
|
'alter',
|
||||||
|
'create',
|
||||||
|
'drop',
|
||||||
|
'truncate',
|
||||||
|
'explain',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function firstSqlToken(sql: string | undefined): string {
|
||||||
|
if (!sql) return 'other';
|
||||||
|
// Skip leading whitespace / comments and grab the first word.
|
||||||
|
const match = /^[\s(]*([a-zA-Z]+)/.exec(sql);
|
||||||
|
if (!match) return 'other';
|
||||||
|
const token = match[1].toLowerCase();
|
||||||
|
return KNOWN_SQL_TOKENS.has(token) ? token : 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an HTTP response must be EXCLUDED from http_request_duration_seconds.
|
||||||
|
*
|
||||||
|
* SSE/streaming responses (the AI-chat `text/event-stream`) keep the connection
|
||||||
|
* open for the whole conversation, so Fastify's onResponse fires only when the
|
||||||
|
* client disconnects — recording the connection lifetime, not a response time,
|
||||||
|
* which would poison p95/p99. We skip by content-type (authoritative) with a
|
||||||
|
* route-suffix fallback for the two known stream endpoints.
|
||||||
|
*/
|
||||||
|
export function isStreamingResponse(
|
||||||
|
contentType: unknown,
|
||||||
|
route: string | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (
|
||||||
|
typeof contentType === 'string' &&
|
||||||
|
contentType.toLowerCase().includes('text/event-stream')
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Fallback: the AI-chat stream routes (/api/ai-chat/stream,
|
||||||
|
// /api/shares/ai/stream) both end in `/stream`.
|
||||||
|
if (route && route.endsWith('/stream')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MetricsBullService } from './metrics-bull.service';
|
||||||
|
import { MetricsServerLifecycle } from './metrics-server.lifecycle';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires the BullMQ collectors (#355). The queues are provided by the @Global
|
||||||
|
* QueueModule (which exports BullModule), so no re-registration is needed here.
|
||||||
|
* The HTTP histogram, DB-query and collab-store collectors live in module-level
|
||||||
|
* singletons (metrics.registry) and are wired directly at their call sites.
|
||||||
|
* MetricsServerLifecycle closes the scrape server on shutdown.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
providers: [MetricsBullService, MetricsServerLifecycle],
|
||||||
|
})
|
||||||
|
export class MetricsModule {}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import {
|
||||||
|
collectDefaultMetrics,
|
||||||
|
Histogram,
|
||||||
|
Gauge,
|
||||||
|
Registry,
|
||||||
|
} from 'prom-client';
|
||||||
|
import {
|
||||||
|
COLLAB_BUCKETS,
|
||||||
|
DB_BUCKETS,
|
||||||
|
HTTP_BUCKETS,
|
||||||
|
JOB_BUCKETS,
|
||||||
|
METRIC_BULLMQ_JOB_DURATION,
|
||||||
|
METRIC_BULLMQ_QUEUE_DEPTH,
|
||||||
|
METRIC_COLLAB_STORE_DURATION,
|
||||||
|
METRIC_DB_QUERY_DURATION,
|
||||||
|
METRIC_HTTP_REQUEST_DURATION,
|
||||||
|
} from './metrics.constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process-wide perf-metrics registry (#355).
|
||||||
|
*
|
||||||
|
* This is a plain module singleton (NOT a Nest provider) because the collectors
|
||||||
|
* are cross-cutting: the Kysely `log` callback (built in a DI factory), the
|
||||||
|
* Fastify onResponse hook (main.ts, before the Nest container hands out
|
||||||
|
* providers) and the collab persistence extension all need the SAME instruments
|
||||||
|
* without threading DI through them.
|
||||||
|
*
|
||||||
|
* HARD CONTRACT: when `METRICS_PORT` is unset the whole subsystem is OFF — the
|
||||||
|
* registry is never created, `collectDefaultMetrics` never runs, and every
|
||||||
|
* observe/set helper is a cheap no-op. Nothing is exposed on :3000.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Decided once at process start. Deliberately read here (not via
|
||||||
|
// EnvironmentService) so the toggle is identical for the DI and non-DI callers.
|
||||||
|
const enabled = Boolean(process.env.METRICS_PORT);
|
||||||
|
|
||||||
|
let registry: Registry | null = null;
|
||||||
|
let httpHist: Histogram<'method' | 'route' | 'status'> | null = null;
|
||||||
|
let dbHist: Histogram<'op'> | null = null;
|
||||||
|
let queueDepthGauge: Gauge<'queue'> | null = null;
|
||||||
|
let jobHist: Histogram<'queue'> | null = null;
|
||||||
|
let collabHist: Histogram | null = null;
|
||||||
|
|
||||||
|
function init(): void {
|
||||||
|
if (registry || !enabled) return;
|
||||||
|
|
||||||
|
registry = new Registry();
|
||||||
|
|
||||||
|
// Node/runtime metrics: gives nodejs_eventloop_lag_p99_seconds, GC, heap, etc.
|
||||||
|
collectDefaultMetrics({ register: registry });
|
||||||
|
|
||||||
|
httpHist = new Histogram({
|
||||||
|
name: METRIC_HTTP_REQUEST_DURATION,
|
||||||
|
help: 'HTTP request duration in seconds, by method, route template and status',
|
||||||
|
labelNames: ['method', 'route', 'status'],
|
||||||
|
buckets: HTTP_BUCKETS,
|
||||||
|
registers: [registry],
|
||||||
|
});
|
||||||
|
|
||||||
|
dbHist = new Histogram({
|
||||||
|
name: METRIC_DB_QUERY_DURATION,
|
||||||
|
help: 'Database query duration in seconds, by leading SQL keyword',
|
||||||
|
labelNames: ['op'],
|
||||||
|
buckets: DB_BUCKETS,
|
||||||
|
registers: [registry],
|
||||||
|
});
|
||||||
|
|
||||||
|
queueDepthGauge = new Gauge({
|
||||||
|
name: METRIC_BULLMQ_QUEUE_DEPTH,
|
||||||
|
help: 'Number of not-yet-finished BullMQ jobs per queue',
|
||||||
|
labelNames: ['queue'],
|
||||||
|
registers: [registry],
|
||||||
|
});
|
||||||
|
|
||||||
|
jobHist = new Histogram({
|
||||||
|
name: METRIC_BULLMQ_JOB_DURATION,
|
||||||
|
help: 'BullMQ job processing duration in seconds, per queue',
|
||||||
|
labelNames: ['queue'],
|
||||||
|
buckets: JOB_BUCKETS,
|
||||||
|
registers: [registry],
|
||||||
|
});
|
||||||
|
|
||||||
|
collabHist = new Histogram({
|
||||||
|
name: METRIC_COLLAB_STORE_DURATION,
|
||||||
|
help: 'Collaboration onStoreDocument duration in seconds',
|
||||||
|
buckets: COLLAB_BUCKETS,
|
||||||
|
registers: [registry],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs once when this module is first imported. Safe to call again (idempotent).
|
||||||
|
init();
|
||||||
|
|
||||||
|
export function isMetricsEnabled(): boolean {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The prom-client registry, or null when metrics are disabled. */
|
||||||
|
export function getMetricsRegistry(): Registry | null {
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function observeHttp(
|
||||||
|
method: string,
|
||||||
|
route: string,
|
||||||
|
status: number,
|
||||||
|
seconds: number,
|
||||||
|
): void {
|
||||||
|
httpHist?.observe({ method, route, status }, seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function observeDbQuery(op: string, seconds: number): void {
|
||||||
|
dbHist?.observe({ op }, seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setQueueDepth(queue: string, depth: number): void {
|
||||||
|
queueDepthGauge?.set({ queue }, depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function observeJobDuration(queue: string, seconds: number): void {
|
||||||
|
jobHist?.observe({ queue }, seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function observeCollabStore(seconds: number): void {
|
||||||
|
collabHist?.observe(seconds);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { createServer, Server } from 'node:http';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { getMetricsRegistry, isMetricsEnabled } from './metrics.registry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the Prometheus scrape endpoint on a SEPARATE port, taken from
|
||||||
|
* `METRICS_PORT`. There is NO default port: when `METRICS_PORT` is unset the
|
||||||
|
* whole metrics subsystem is OFF and this returns null. This is a bare node:http
|
||||||
|
* server, NOT part of the Fastify app, so `/metrics` never exists on the public
|
||||||
|
* :3000 listener.
|
||||||
|
*
|
||||||
|
* Returns the http.Server (so callers can close it on shutdown) or null when
|
||||||
|
* metrics are disabled. The reference is also kept module-side so the Nest
|
||||||
|
* lifecycle (see MetricsModule) can close it on application shutdown without
|
||||||
|
* threading the handle back through the non-DI bootstrap.
|
||||||
|
*/
|
||||||
|
let metricsServer: Server | null = null;
|
||||||
|
|
||||||
|
export function startMetricsServer(): Server | null {
|
||||||
|
if (!isMetricsEnabled()) return null;
|
||||||
|
|
||||||
|
const logger = new Logger('MetricsServer');
|
||||||
|
const register = getMetricsRegistry();
|
||||||
|
if (!register) return null;
|
||||||
|
|
||||||
|
const port = Number(process.env.METRICS_PORT);
|
||||||
|
if (!Number.isInteger(port) || port <= 0) {
|
||||||
|
logger.warn(
|
||||||
|
`Invalid METRICS_PORT="${process.env.METRICS_PORT}", metrics endpoint not started`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createServer(async (req, res) => {
|
||||||
|
if (req.method === 'GET' && req.url === '/metrics') {
|
||||||
|
try {
|
||||||
|
const body = await register.metrics();
|
||||||
|
res.setHeader('Content-Type', register.contentType);
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.end(body);
|
||||||
|
} catch (err) {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.end(String((err as Error)?.message ?? 'error'));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bind on all interfaces: the scraper (VictoriaMetrics) reaches this from
|
||||||
|
// another container as docmost:9464. The port is not published to the host.
|
||||||
|
server.listen(port, '0.0.0.0', () => {
|
||||||
|
logger.log(`Metrics endpoint listening on :${port}/metrics`);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on('error', (err) => {
|
||||||
|
logger.error(`Metrics server error: ${err?.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
metricsServer = server;
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the metrics scrape server if one is running. Idempotent and safe to call
|
||||||
|
* when metrics are disabled (no server was ever started). Wired into Nest's
|
||||||
|
* shutdown lifecycle so the listener is not left dangling on shutdown.
|
||||||
|
*/
|
||||||
|
export function closeMetricsServer(): Promise<void> {
|
||||||
|
const server = metricsServer;
|
||||||
|
metricsServer = null;
|
||||||
|
if (!server) return Promise.resolve();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
server.close(() => resolve());
|
||||||
|
// server.close() stops accepting NEW connections but its callback does not
|
||||||
|
// fire until existing keep-alive sockets drain. The scraper (VictoriaMetrics/
|
||||||
|
// vmagent) holds an idle HTTP keep-alive socket, so without this the callback
|
||||||
|
// — and thus shutdown — would hang until the scraper disconnects or the
|
||||||
|
// orchestrator escalates to SIGKILL on the kill-grace window. Force-close idle
|
||||||
|
// keep-alive sockets so close() completes immediately, and unref so this
|
||||||
|
// server never keeps the event loop alive on its own.
|
||||||
|
server.closeIdleConnections();
|
||||||
|
server.unref();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { FastifyRequest } from 'fastify';
|
||||||
|
import { resolveRouteLabel } from './http-metrics.hook';
|
||||||
|
import { firstSqlToken, isStreamingResponse } from './metrics.constants';
|
||||||
|
|
||||||
|
describe('resolveRouteLabel (histogram route label)', () => {
|
||||||
|
it('uses the ROUTE TEMPLATE, never the raw URL', () => {
|
||||||
|
// routeOptions.url is the matched template; url is the raw path with the id.
|
||||||
|
const req = {
|
||||||
|
url: '/api/pages/abc-123-def',
|
||||||
|
routeOptions: { url: '/api/pages/:id' },
|
||||||
|
} as unknown as FastifyRequest;
|
||||||
|
expect(resolveRouteLabel(req)).toBe('/api/pages/:id');
|
||||||
|
expect(resolveRouteLabel(req)).not.toContain('abc-123-def');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to "unknown" on a 404 (no matched route template)', () => {
|
||||||
|
const req = {
|
||||||
|
url: '/totally/unmatched/path',
|
||||||
|
routeOptions: {},
|
||||||
|
} as unknown as FastifyRequest;
|
||||||
|
expect(resolveRouteLabel(req)).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to "unknown" when routeOptions is missing', () => {
|
||||||
|
const req = { url: '/x' } as unknown as FastifyRequest;
|
||||||
|
expect(resolveRouteLabel(req)).toBe('unknown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isStreamingResponse (SSE exclusion)', () => {
|
||||||
|
it('excludes text/event-stream responses by content-type', () => {
|
||||||
|
expect(isStreamingResponse('text/event-stream', '/api/ai-chat/stream')).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(isStreamingResponse('text/event-stream; charset=utf-8', '/x')).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes known /stream routes by suffix as a fallback', () => {
|
||||||
|
expect(isStreamingResponse('application/json', '/api/ai-chat/stream')).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(isStreamingResponse(undefined, '/api/shares/ai/stream')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not exclude ordinary JSON responses', () => {
|
||||||
|
expect(isStreamingResponse('application/json', '/api/pages/:id')).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(isStreamingResponse(undefined, '/api/pages/:id')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('firstSqlToken (bounded db label)', () => {
|
||||||
|
it('returns the lower-cased leading keyword', () => {
|
||||||
|
expect(firstSqlToken('SELECT * FROM pages')).toBe('select');
|
||||||
|
expect(firstSqlToken(' insert into x values (1)')).toBe('insert');
|
||||||
|
expect(firstSqlToken('UPDATE pages SET a=1')).toBe('update');
|
||||||
|
expect(firstSqlToken('delete from pages')).toBe('delete');
|
||||||
|
expect(firstSqlToken('(SELECT 1)')).toBe('select');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses unknown/empty queries to "other"', () => {
|
||||||
|
expect(firstSqlToken('')).toBe('other');
|
||||||
|
expect(firstSqlToken(undefined)).toBe('other');
|
||||||
|
expect(firstSqlToken('123 not sql')).toBe('other');
|
||||||
|
expect(firstSqlToken('vacuum analyze')).toBe('other');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -50,6 +50,10 @@ export class StaticModule implements OnModuleInit {
|
|||||||
: undefined,
|
: undefined,
|
||||||
POSTHOG_HOST: this.environmentService.getPostHogHost(),
|
POSTHOG_HOST: this.environmentService.getPostHogHost(),
|
||||||
POSTHOG_KEY: this.environmentService.getPostHogKey(),
|
POSTHOG_KEY: this.environmentService.getPostHogKey(),
|
||||||
|
// #355 — mirrors the server-side CLIENT_TELEMETRY_ENABLED gate so the
|
||||||
|
// client only collects/sends vitals when the operator opts in.
|
||||||
|
CLIENT_TELEMETRY_ENABLED:
|
||||||
|
this.environmentService.isClientTelemetryEnabled(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;
|
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
AI_CHAT_THROTTLER,
|
AI_CHAT_THROTTLER,
|
||||||
PAGE_TEMPLATE_THROTTLER,
|
PAGE_TEMPLATE_THROTTLER,
|
||||||
PUBLIC_SHARE_AI_THROTTLER,
|
PUBLIC_SHARE_AI_THROTTLER,
|
||||||
|
VITALS_THROTTLER,
|
||||||
} from './throttler-names';
|
} from './throttler-names';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -29,6 +30,8 @@ import {
|
|||||||
{ name: PAGE_TEMPLATE_THROTTLER, ttl: 60_000, limit: 30 },
|
{ name: PAGE_TEMPLATE_THROTTLER, ttl: 60_000, limit: 30 },
|
||||||
// Anonymous public-share assistant: ~5 req/min per IP.
|
// Anonymous public-share assistant: ~5 req/min per IP.
|
||||||
{ name: PUBLIC_SHARE_AI_THROTTLER, ttl: 60_000, limit: 5 },
|
{ name: PUBLIC_SHARE_AI_THROTTLER, ttl: 60_000, limit: 5 },
|
||||||
|
// Anonymous client perf-telemetry sink: 120 batched posts/min per IP.
|
||||||
|
{ name: VITALS_THROTTLER, ttl: 60_000, limit: 120 },
|
||||||
],
|
],
|
||||||
errorMessage: 'Too many requests',
|
errorMessage: 'Too many requests',
|
||||||
// Pass ioredis options (not a pre-built Redis instance) so
|
// Pass ioredis options (not a pre-built Redis instance) so
|
||||||
|
|||||||
@@ -6,3 +6,7 @@ export const PAGE_TEMPLATE_THROTTLER = 'page-template';
|
|||||||
// ThrottlerGuard tracker) to bound anonymous abuse — the workspace owner pays
|
// ThrottlerGuard tracker) to bound anonymous abuse — the workspace owner pays
|
||||||
// for the tokens.
|
// for the tokens.
|
||||||
export const PUBLIC_SHARE_AI_THROTTLER = 'public-share-ai';
|
export const PUBLIC_SHARE_AI_THROTTLER = 'public-share-ai';
|
||||||
|
// IP-keyed throttler for the anonymous client perf-telemetry sink
|
||||||
|
// (POST /api/telemetry/vitals). Browsers batch metrics, so the limit is
|
||||||
|
// generous; it only exists to bound abuse of the public, unauthenticated route.
|
||||||
|
export const VITALS_THROTTLER = 'vitals';
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import { EnvironmentService } from './integrations/environment/environment.servi
|
|||||||
import { SANDBOX_API_PATH } from './integrations/sandbox/sandbox.constants';
|
import { SANDBOX_API_PATH } from './integrations/sandbox/sandbox.constants';
|
||||||
import { resolveFrameHeader } from './common/helpers';
|
import { resolveFrameHeader } from './common/helpers';
|
||||||
import { resolveTrustProxy } from './integrations/environment/trust-proxy.util';
|
import { resolveTrustProxy } from './integrations/environment/trust-proxy.util';
|
||||||
|
import { isMetricsEnabled } from './integrations/metrics/metrics.registry';
|
||||||
|
import { recordHttpResponse } from './integrations/metrics/http-metrics.hook';
|
||||||
|
import { startMetricsServer } from './integrations/metrics/metrics.server';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestFastifyApplication>(
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
@@ -91,6 +94,19 @@ async function bootstrap() {
|
|||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #355 — HTTP request-duration histogram. Registered ONLY when METRICS_PORT is
|
||||||
|
// set (otherwise no collector runs at all). Uses the bounded route template
|
||||||
|
// label and excludes SSE/streaming responses (see recordHttpResponse).
|
||||||
|
if (isMetricsEnabled()) {
|
||||||
|
app
|
||||||
|
.getHttpAdapter()
|
||||||
|
.getInstance()
|
||||||
|
.addHook('onResponse', (req, reply, done) => {
|
||||||
|
recordHttpResponse(req, reply);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
app
|
app
|
||||||
.getHttpAdapter()
|
.getHttpAdapter()
|
||||||
.getInstance()
|
.getInstance()
|
||||||
@@ -127,6 +143,9 @@ async function bootstrap() {
|
|||||||
'/api/workspace/create',
|
'/api/workspace/create',
|
||||||
'/api/workspace/joined',
|
'/api/workspace/joined',
|
||||||
'/api/workspace/find-by-email',
|
'/api/workspace/find-by-email',
|
||||||
|
// Public client perf-telemetry sink: browsers post it without a
|
||||||
|
// resolved workspace host, so the workspace-resolution gate must not 404 it.
|
||||||
|
'/api/telemetry/vitals',
|
||||||
// Anonymous in-RAM blob sandbox: a remote consumer fetches blobs by an
|
// Anonymous in-RAM blob sandbox: a remote consumer fetches blobs by an
|
||||||
// unguessable UUID without any workspace host context, so the
|
// unguessable UUID without any workspace host context, so the
|
||||||
// workspace-resolution gate must not apply.
|
// workspace-resolution gate must not apply.
|
||||||
@@ -175,6 +194,11 @@ async function bootstrap() {
|
|||||||
`Listening on http://127.0.0.1:${port} / ${process.env.APP_URL}`,
|
`Listening on http://127.0.0.1:${port} / ${process.env.APP_URL}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #355 — Prometheus scrape endpoint on a SEPARATE port (METRICS_PORT),
|
||||||
|
// started after the app is up. No default port: a no-op when METRICS_PORT is
|
||||||
|
// unset. Closed on shutdown by MetricsServerLifecycle (MetricsModule).
|
||||||
|
startMetricsServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import { Kysely } from 'kysely';
|
||||||
|
import {
|
||||||
|
AiChatRunRepo,
|
||||||
|
SWEEP_RUN_STALE_MS,
|
||||||
|
} from '@docmost/db/repos/ai-chat/ai-chat-run.repo';
|
||||||
|
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||||
|
import { AiChatRunService } from '../../src/core/ai-chat/ai-chat-run.service';
|
||||||
|
import {
|
||||||
|
getTestDb,
|
||||||
|
destroyTestDb,
|
||||||
|
createWorkspace,
|
||||||
|
createUser,
|
||||||
|
createChat,
|
||||||
|
} from './db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration coverage for the #184 phase-1 durable agent run: real SQL against
|
||||||
|
* docmost_test. Proves the core invariant primitives — a run is a first-class
|
||||||
|
* lifecycle row, at most one is active per chat, a detached run's progress
|
||||||
|
* survives with NO subscriber, an explicit stop settles it as aborted, a
|
||||||
|
* reconnect read returns the persisted state, and a crash sweep recovers
|
||||||
|
* dangling runs.
|
||||||
|
*/
|
||||||
|
describe('AiChatRun durable lifecycle [integration]', () => {
|
||||||
|
let db: Kysely<any>;
|
||||||
|
let runRepo: AiChatRunRepo;
|
||||||
|
let messageRepo: AiChatMessageRepo;
|
||||||
|
let service: AiChatRunService;
|
||||||
|
let workspaceId: string;
|
||||||
|
let otherWorkspaceId: string;
|
||||||
|
let userId: string;
|
||||||
|
let chatId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = getTestDb();
|
||||||
|
runRepo = new AiChatRunRepo(db as any);
|
||||||
|
messageRepo = new AiChatMessageRepo(db as any);
|
||||||
|
// Boot-sweep isn't triggered here; the isCloud stub is all the service needs
|
||||||
|
// for these direct-call integration cases (F7).
|
||||||
|
service = new AiChatRunService(runRepo, { isCloud: () => false } as never);
|
||||||
|
workspaceId = (await createWorkspace(db)).id;
|
||||||
|
otherWorkspaceId = (await createWorkspace(db)).id;
|
||||||
|
userId = (await createUser(db, workspaceId)).id;
|
||||||
|
chatId = (await createChat(db, { workspaceId, creatorId: userId })).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await destroyTestDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Each test that creates an active run settles it (or uses its own chat) so the
|
||||||
|
// partial unique index does not bleed across tests.
|
||||||
|
|
||||||
|
it('insert + findById round-trips a run row, defaulting status/trigger', async () => {
|
||||||
|
const run = await runRepo.insert({
|
||||||
|
chatId,
|
||||||
|
workspaceId,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
expect(run.status).toBe('pending');
|
||||||
|
expect(run.trigger).toBe('user');
|
||||||
|
expect(run.stepCount).toBe(0);
|
||||||
|
|
||||||
|
const found = await runRepo.findById(run.id, workspaceId);
|
||||||
|
expect(found!.id).toBe(run.id);
|
||||||
|
// Workspace-scoped: a foreign workspace sees nothing.
|
||||||
|
expect(await runRepo.findById(run.id, otherWorkspaceId)).toBeUndefined();
|
||||||
|
|
||||||
|
// settle so it does not occupy the active slot
|
||||||
|
await runRepo.update(run.id, workspaceId, {
|
||||||
|
status: 'succeeded',
|
||||||
|
finishedAt: new Date(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces ONE ACTIVE run per chat (partial unique index rejects a second)', async () => {
|
||||||
|
const activeChat = (
|
||||||
|
await createChat(db, { workspaceId, creatorId: userId })
|
||||||
|
).id;
|
||||||
|
const first = await runRepo.insert({
|
||||||
|
chatId: activeChat,
|
||||||
|
workspaceId,
|
||||||
|
createdBy: userId,
|
||||||
|
status: 'running',
|
||||||
|
});
|
||||||
|
// A second pending/running run on the SAME chat must be rejected by the DB.
|
||||||
|
await expect(
|
||||||
|
runRepo.insert({
|
||||||
|
chatId: activeChat,
|
||||||
|
workspaceId,
|
||||||
|
createdBy: userId,
|
||||||
|
status: 'running',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
// findActiveByChat returns exactly the one active run.
|
||||||
|
const active = await runRepo.findActiveByChat(activeChat, workspaceId);
|
||||||
|
expect(active!.id).toBe(first.id);
|
||||||
|
|
||||||
|
// Once it settles, the slot frees and a new run may start.
|
||||||
|
await runRepo.update(first.id, workspaceId, {
|
||||||
|
status: 'succeeded',
|
||||||
|
finishedAt: new Date(),
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await runRepo.findActiveByChat(activeChat, workspaceId),
|
||||||
|
).toBeUndefined();
|
||||||
|
const second = await runRepo.insert({
|
||||||
|
chatId: activeChat,
|
||||||
|
workspaceId,
|
||||||
|
createdBy: userId,
|
||||||
|
status: 'running',
|
||||||
|
});
|
||||||
|
expect(second.id).not.toBe(first.id);
|
||||||
|
await runRepo.update(second.id, workspaceId, {
|
||||||
|
status: 'aborted',
|
||||||
|
finishedAt: new Date(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DETACHED run: persists + finalizes succeeded with NO subscriber, reconnect returns state', async () => {
|
||||||
|
// A dedicated chat so the active-run slot is clean.
|
||||||
|
const runChat = (
|
||||||
|
await createChat(db, { workspaceId, creatorId: userId })
|
||||||
|
).id;
|
||||||
|
|
||||||
|
// beginRun = the runner starts the turn (registers an in-memory controller).
|
||||||
|
const handle = await service.beginRun({
|
||||||
|
chatId: runChat,
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
expect(handle.signal.aborted).toBe(false);
|
||||||
|
expect(service.isLocallyActive(handle.runId)).toBe(true);
|
||||||
|
|
||||||
|
// The assistant projection row (#183) is seeded + linked.
|
||||||
|
const seeded = await messageRepo.insert({
|
||||||
|
chatId: runChat,
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
status: 'streaming',
|
||||||
|
metadata: { parts: [] } as never,
|
||||||
|
});
|
||||||
|
await service.linkAssistantMessage(handle.runId, workspaceId, seeded.id);
|
||||||
|
|
||||||
|
// Progress is persisted as steps finish — NO HTTP socket involved here at all.
|
||||||
|
await service.recordStep(handle.runId, workspaceId, 1);
|
||||||
|
await messageRepo.update(seeded.id, workspaceId, {
|
||||||
|
content: 'partial work',
|
||||||
|
metadata: { parts: [{ type: 'text', text: 'partial work' }] },
|
||||||
|
});
|
||||||
|
|
||||||
|
// The turn completes; finalize the projection then the run.
|
||||||
|
await messageRepo.update(seeded.id, workspaceId, {
|
||||||
|
content: 'final answer',
|
||||||
|
status: 'completed',
|
||||||
|
});
|
||||||
|
await service.finalizeRun(handle.runId, workspaceId, 'completed');
|
||||||
|
|
||||||
|
expect(service.isLocallyActive(handle.runId)).toBe(false);
|
||||||
|
|
||||||
|
// Reconnect: the latest run for the chat + its projected message, from the DB.
|
||||||
|
const run = await service.getLatestForChat(runChat, workspaceId);
|
||||||
|
expect(run!.status).toBe('succeeded');
|
||||||
|
expect(run!.stepCount).toBe(1);
|
||||||
|
expect(run!.assistantMessageId).toBe(seeded.id);
|
||||||
|
expect(run!.finishedAt).toBeTruthy();
|
||||||
|
const message = await messageRepo.findById(seeded.id, workspaceId);
|
||||||
|
expect(message!.status).toBe('completed');
|
||||||
|
expect(message!.content).toBe('final answer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('EXPLICIT stop aborts the run signal, marks the row, and settles as aborted', async () => {
|
||||||
|
const runChat = (
|
||||||
|
await createChat(db, { workspaceId, creatorId: userId })
|
||||||
|
).id;
|
||||||
|
const handle = await service.beginRun({
|
||||||
|
chatId: runChat,
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// User presses Stop.
|
||||||
|
const stopped = await service.requestStop(handle.runId, workspaceId);
|
||||||
|
expect(stopped).toBe(true);
|
||||||
|
expect(handle.signal.aborted).toBe(true);
|
||||||
|
|
||||||
|
// The row carries the stop request (distinct from a disconnect, which would
|
||||||
|
// leave stop_requested_at NULL).
|
||||||
|
const afterStop = await runRepo.findById(handle.runId, workspaceId);
|
||||||
|
expect(afterStop!.stopRequestedAt).toBeTruthy();
|
||||||
|
|
||||||
|
// The terminal callback (onAbort) settles the run.
|
||||||
|
await service.finalizeRun(handle.runId, workspaceId, 'aborted');
|
||||||
|
const run = await service.getLatestForChat(runChat, workspaceId);
|
||||||
|
expect(run!.status).toBe('aborted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('markStopRequested is a no-op on an already-settled run (returns undefined)', async () => {
|
||||||
|
const runChat = (
|
||||||
|
await createChat(db, { workspaceId, creatorId: userId })
|
||||||
|
).id;
|
||||||
|
const run = await runRepo.insert({
|
||||||
|
chatId: runChat,
|
||||||
|
workspaceId,
|
||||||
|
createdBy: userId,
|
||||||
|
status: 'running',
|
||||||
|
});
|
||||||
|
await runRepo.update(run.id, workspaceId, {
|
||||||
|
status: 'succeeded',
|
||||||
|
finishedAt: new Date(),
|
||||||
|
});
|
||||||
|
const marked = await runRepo.markStopRequested(run.id, workspaceId);
|
||||||
|
expect(marked).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sweepRunning aborts STALE dangling runs but not fresh or settled ones', async () => {
|
||||||
|
const sweepChat1 = (
|
||||||
|
await createChat(db, { workspaceId, creatorId: userId })
|
||||||
|
).id;
|
||||||
|
const sweepChat2 = (
|
||||||
|
await createChat(db, { workspaceId, creatorId: userId })
|
||||||
|
).id;
|
||||||
|
const sweepChat3 = (
|
||||||
|
await createChat(db, { workspaceId, creatorId: userId })
|
||||||
|
).id;
|
||||||
|
|
||||||
|
const stale = await runRepo.insert({
|
||||||
|
chatId: sweepChat1,
|
||||||
|
workspaceId,
|
||||||
|
createdBy: userId,
|
||||||
|
status: 'running',
|
||||||
|
});
|
||||||
|
const fresh = await runRepo.insert({
|
||||||
|
chatId: sweepChat2,
|
||||||
|
workspaceId,
|
||||||
|
createdBy: userId,
|
||||||
|
status: 'running',
|
||||||
|
});
|
||||||
|
const settled = await runRepo.insert({
|
||||||
|
chatId: sweepChat3,
|
||||||
|
workspaceId,
|
||||||
|
createdBy: userId,
|
||||||
|
status: 'running',
|
||||||
|
});
|
||||||
|
await runRepo.update(settled.id, workspaceId, {
|
||||||
|
status: 'succeeded',
|
||||||
|
finishedAt: new Date(),
|
||||||
|
});
|
||||||
|
// Backdate the stale run's updatedAt past the 10-minute staleness window.
|
||||||
|
await db
|
||||||
|
.updateTable('aiChatRuns')
|
||||||
|
.set({ updatedAt: new Date(Date.now() - 20 * 60 * 1000) })
|
||||||
|
.where('id', '=', stale.id)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
// WINDOWED sweep (phase-2 multi-instance timer path): only runs older than the
|
||||||
|
// staleness window are aborted, so a sibling replica's fresh run survives. The
|
||||||
|
// no-arg boot sweep (variant C) is unconditional — covered separately below.
|
||||||
|
const swept = await runRepo.sweepRunning({ staleMs: SWEEP_RUN_STALE_MS });
|
||||||
|
expect(swept).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
expect((await runRepo.findById(stale.id, workspaceId))!.status).toBe(
|
||||||
|
'aborted',
|
||||||
|
);
|
||||||
|
// Fresh (recently-updated) running run survives the WINDOWED sweep — a sibling
|
||||||
|
// replica may still be executing it.
|
||||||
|
expect((await runRepo.findById(fresh.id, workspaceId))!.status).toBe(
|
||||||
|
'running',
|
||||||
|
);
|
||||||
|
expect((await runRepo.findById(settled.id, workspaceId))!.status).toBe(
|
||||||
|
'succeeded',
|
||||||
|
);
|
||||||
|
|
||||||
|
// cleanup active fresh run
|
||||||
|
await runRepo.update(fresh.id, workspaceId, {
|
||||||
|
status: 'aborted',
|
||||||
|
finishedAt: new Date(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sweepRunning() with NO args (boot sweep / variant C) aborts even a FRESH running run', async () => {
|
||||||
|
// F1/DECISION C at the SQL level: the unconditional boot sweep has NO
|
||||||
|
// staleness window, so a run updated just now (a fast restart) is settled too
|
||||||
|
// — otherwise it would stay 'running' forever and 409 every future turn.
|
||||||
|
const bootChat = (
|
||||||
|
await createChat(db, { workspaceId, creatorId: userId })
|
||||||
|
).id;
|
||||||
|
const fresh = await runRepo.insert({
|
||||||
|
chatId: bootChat,
|
||||||
|
workspaceId,
|
||||||
|
createdBy: userId,
|
||||||
|
status: 'running',
|
||||||
|
});
|
||||||
|
// updatedAt = now (fresh, untouched). The no-arg sweep settles it anyway.
|
||||||
|
const swept = await runRepo.sweepRunning();
|
||||||
|
expect(swept).toBeGreaterThanOrEqual(1);
|
||||||
|
expect((await runRepo.findById(fresh.id, workspaceId))!.status).toBe(
|
||||||
|
'aborted',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Jest stub for @tiptap/react.
|
||||||
|
//
|
||||||
|
// The server export/import code paths transitively import editor-ext, whose node
|
||||||
|
// extensions import from `@tiptap/react`. The real module re-exports all of
|
||||||
|
// `@tiptap/core` (headless, safe under node) AND adds React view helpers
|
||||||
|
// (`ReactNodeViewRenderer`, …) that eagerly pull in react-dom — which throws
|
||||||
|
// `navigator is not defined` under jest's node environment.
|
||||||
|
//
|
||||||
|
// So this stub DELEGATES to the real `@tiptap/core` (keeping `mergeAttributes`,
|
||||||
|
// `Node`, `Mark`, `nodeInputRule`, … working — they are used by
|
||||||
|
// `jsonToHtml`/`htmlToJson` on the server) and overrides ONLY the React view
|
||||||
|
// helpers with no-ops. Those helpers are referenced solely inside `addNodeView()`
|
||||||
|
// — code that runs only in a live browser editor, never on the server; if any
|
||||||
|
// were actually invoked here it would (correctly) surface as a test failure.
|
||||||
|
const core = require('@tiptap/core');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
...core,
|
||||||
|
ReactNodeViewRenderer: () => () => ({}),
|
||||||
|
NodeViewWrapper: () => null,
|
||||||
|
NodeViewContent: () => null,
|
||||||
|
ReactRenderer: class {},
|
||||||
|
};
|
||||||
@@ -131,5 +131,14 @@ const { Client } = require("pg");
|
|||||||
7. **Migrations don't auto-run in dev** — run `migration:latest` after every pull
|
7. **Migrations don't auto-run in dev** — run `migration:latest` after every pull
|
||||||
or branch switch.
|
or branch switch.
|
||||||
|
|
||||||
|
8. **Automation (Playwright): type into the BODY editor, not the title.** A page has
|
||||||
|
two `.ProseMirror` editors — `[aria-label='Page title']` (non-collab) and
|
||||||
|
`[aria-label='Page content']` (the collab body). `document.querySelector('.ProseMirror')`
|
||||||
|
returns the TITLE editor, so typing there never changes body content and `mod+S`
|
||||||
|
versions nothing. Target `[aria-label='Page content']`, confirm it's collab-bound
|
||||||
|
(`el.editor.extensionManager.extensions.some(e=>e.name==='collaboration')`), and
|
||||||
|
wait ~10-12s for the store debounce before asserting `pages.content` changed. Full
|
||||||
|
testing methodology + traps: **[how-to-test.md](how-to-test.md)**.
|
||||||
|
|
||||||
See also the **Commands** and **Architecture → Two server processes** sections in
|
See also the **Commands** and **Architecture → Two server processes** sections in
|
||||||
[`AGENTS.md`](../AGENTS.md).
|
[`AGENTS.md`](../AGENTS.md).
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# How to test the application (browser E2E + out-of-band)
|
||||||
|
|
||||||
|
How to actually verify a feature end-to-end against a running stand — driving the
|
||||||
|
**real app in a browser** and confirming results **out-of-band** in the DB/git, not
|
||||||
|
through the same API you're supposed to be testing. Written from real false-positives
|
||||||
|
that wasted hours (see **Traps** — read them before you write a test).
|
||||||
|
|
||||||
|
Prereq: a running stand — see **[dev-stand.md](dev-stand.md)**. Automation uses
|
||||||
|
Playwright (`pip install playwright && python -m playwright install chromium`).
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
1. **Drive the behaviour under test through the browser.** The stand exists so you
|
||||||
|
exercise the real UI + realtime-collab + server path. Using `POST /api/pages/*` to
|
||||||
|
perform the action you're validating tests the API, not the app — an e2e suite can
|
||||||
|
do that. API calls are fine ONLY for one-time setup/fixtures, never for the
|
||||||
|
interaction you're asserting on.
|
||||||
|
2. **Evidence before claim.** Nothing "passes" without an artifact: a DB row, a git
|
||||||
|
diff, a screenshot looked at as an image. If you can't show it, you didn't verify it.
|
||||||
|
3. **Verify out-of-band.** Judge results from a source independent of the UI: `psql`
|
||||||
|
against the DB, a fresh `git clone` of a synced repo, a hard reload. Optimistic UI
|
||||||
|
lies about persistence.
|
||||||
|
4. **Disconfirm by default.** For each feature, actively try to prove it's broken
|
||||||
|
before concluding it works. Reload after every create/edit/save.
|
||||||
|
5. **Recon actuatability FIRST.** Before building editor tests, confirm the
|
||||||
|
interaction even works in your harness (does a typed edit reach the DB?). Skipping
|
||||||
|
this is how you ship a pile of tests that all silently exercised the wrong thing.
|
||||||
|
|
||||||
|
## The editor: two ProseMirror instances (READ THIS)
|
||||||
|
|
||||||
|
A page has **two** `.ProseMirror` editors:
|
||||||
|
|
||||||
|
| index | selector | role | collab? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0 | `[aria-label='Page title']` | title field | **NO** (16 exts, no `collaboration`) |
|
||||||
|
| 1 | `[aria-label='Page content']` | body | **YES** (95 exts, has `collaboration`) |
|
||||||
|
|
||||||
|
`document.querySelector('.ProseMirror')` returns the **title** editor (first match).
|
||||||
|
Type there and you edit the title only — body page content never changes, so `mod+S`
|
||||||
|
"versions" unchanged content and every content test silently no-ops.
|
||||||
|
|
||||||
|
**Always target the body editor** and confirm it's collab-bound before typing:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const el = document.querySelector("[aria-label='Page content']");
|
||||||
|
el.editor.extensionManager.extensions.some(e => e.name === 'collaboration'); // must be true
|
||||||
|
```
|
||||||
|
|
||||||
|
Body edits emit ~20 `/collab` websocket frames while typing and land in
|
||||||
|
`pages.content` after the **hocuspocus store debounce (~10s)** — so **wait ~12s**
|
||||||
|
before asserting persistence (checking at 6–8s is a false negative). `mod+S` (the
|
||||||
|
`save-version` stateless message) flushes immediately, so a version created right
|
||||||
|
after a settled body edit holds the typed text.
|
||||||
|
|
||||||
|
## A known-good browser flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. goto /s/<space-slug> # the "Create page" button lives in the space sidebar, not /home
|
||||||
|
2. click button[aria-label='Create page'] # fully UI-driven page creation
|
||||||
|
3. type into [aria-label='Page title'] # optional title
|
||||||
|
4. click [aria-label='Page content'] → type body text
|
||||||
|
5. wait ~12s (store debounce)
|
||||||
|
6. assert pages.content changed (psql) # out-of-band
|
||||||
|
7. mod+S / menu Save → assert page_history row (psql)
|
||||||
|
8. reload / fresh context → re-assert (persistence round-trip)
|
||||||
|
```
|
||||||
|
|
||||||
|
Auth: log in ONCE, save `storage_state.json`, reuse it across pages/agents (re-login
|
||||||
|
per run trips shared rate-limits). Cookie-based session authorizes both REST and the
|
||||||
|
collab websocket.
|
||||||
|
|
||||||
|
## Judging out-of-band
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# page content / history
|
||||||
|
docker exec <db> psql -U docmost -d docmost -tAc \
|
||||||
|
"select coalesce(kind,'null'), content::text from page_history where page_id='<id>' order by created_at;"
|
||||||
|
# git-sync round-trip: clone the space repo and diff against what you pushed
|
||||||
|
git clone http://<user>:<pass>@127.0.0.1:3000/git/<spaceId>.git /tmp/x
|
||||||
|
```
|
||||||
|
|
||||||
|
`page_history.content` is full JSON — parse it, don't truncate the snippet, or a
|
||||||
|
marker check misses. For sync/async features (autosave, git-sync, idle-flush) use an
|
||||||
|
active probe: write a unique marker, wait past the debounce/poll window, re-read
|
||||||
|
out-of-band, ≥2 iterations — never conclude "broken" from a single snapshot.
|
||||||
|
|
||||||
|
## Traps (each of these produced a false result in a real run)
|
||||||
|
|
||||||
|
- **Wrong editor.** Typed into `.ProseMirror` (= title). Edits never touched body
|
||||||
|
content. → target `[aria-label='Page content']`.
|
||||||
|
- **Checked persistence too early.** Store debounce ~10s; a 6–8s check reads stale.
|
||||||
|
- **Truncated the DB snapshot** below where the test marker sits → false "content
|
||||||
|
missing".
|
||||||
|
- **API-seeded the content under test**, then "verified" the feature — that validated
|
||||||
|
the API, not the app.
|
||||||
|
- **Reused a fixed marker on a non-rebooted stand** → title/row collisions inflate
|
||||||
|
counts (`count==2`). Use a unique per-run marker (timestamp).
|
||||||
|
- **Idle/async read once** and called it "permanently broken" — it was mid-debounce.
|
||||||
|
- **Concluded env-limitation without a cross-build control.** If unsure whether a
|
||||||
|
failure is your harness or the product, run the SAME harness against a known-good
|
||||||
|
build; a divergence localizes it.
|
||||||
|
|
||||||
|
## Scope note
|
||||||
|
|
||||||
|
Some paths genuinely need a human in a real browser (rich drag-drop, native file
|
||||||
|
pickers, clipboard, and anything the harness can't actuate). Label those UNTESTED in
|
||||||
|
the report — "handled gracefully" is not "works". Keep four states distinct:
|
||||||
|
verified-working, defect, untested, env-limitation.
|
||||||
+2
-1
@@ -96,7 +96,8 @@
|
|||||||
"pnpm": {
|
"pnpm": {
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"scimmy@1.3.5": "patches/scimmy@1.3.5.patch",
|
"scimmy@1.3.5": "patches/scimmy@1.3.5.patch",
|
||||||
"yjs@13.6.30": "patches/yjs@13.6.30.patch"
|
"yjs@13.6.30": "patches/yjs@13.6.30.patch",
|
||||||
|
"ai@6.0.134": "patches/ai@6.0.134.patch"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"prosemirror-changeset": "2.4.0",
|
"prosemirror-changeset": "2.4.0",
|
||||||
|
|||||||
+69
-341
@@ -118,56 +118,19 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
|
|||||||
// transport exposes a `tree:true` mode that returns the full nested hierarchy;
|
// transport exposes a `tree:true` mode that returns the full nested hierarchy;
|
||||||
// the in-app copy keeps the same tree option but is worded for the in-app agent.
|
// the in-app copy keeps the same tree option but is worded for the in-app agent.
|
||||||
// Kept per-layer so each side can tune its own guidance.
|
// Kept per-layer so each side can tune its own guidance.
|
||||||
server.registerTool(
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294). This
|
||||||
"list_pages",
|
// transport keeps applying its own defaults (limit=50, tree=false) in execute.
|
||||||
{
|
registerShared(SHARED_TOOL_SPECS.listPages, async ({ spaceId, limit, tree }) => {
|
||||||
description:
|
|
||||||
"List most recent pages in a space ordered by updatedAt (descending). " +
|
|
||||||
"Returns a bounded list (default 50, max 100) — use search for lookups " +
|
|
||||||
"in large spaces. Pass tree:true (with spaceId) to instead get the " +
|
|
||||||
"space's full page hierarchy as a nested tree.",
|
|
||||||
inputSchema: {
|
|
||||||
spaceId: z.string().optional(),
|
|
||||||
limit: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.min(1)
|
|
||||||
.max(100)
|
|
||||||
.optional()
|
|
||||||
.describe("Max pages to return (default 50, max 100)"),
|
|
||||||
tree: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"When true, return the space's full page hierarchy as a nested tree (each node has a children array) instead of the recent-by-updatedAt flat list. Requires spaceId; ignores limit.",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ spaceId, limit, tree }) => {
|
|
||||||
const result = await docmostClient.listPages(spaceId, limit ?? 50, tree ?? false);
|
const result = await docmostClient.listPages(spaceId, limit ?? 50, tree ?? false);
|
||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// Tool: get_page
|
// Tool: get_page
|
||||||
server.registerTool(
|
// Schema + description now live in the shared registry (#294).
|
||||||
"get_page",
|
registerShared(SHARED_TOOL_SPECS.getPage, async ({ pageId }) => {
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Get page details with content converted to Markdown. The conversion is " +
|
|
||||||
"LOSSY (block ids, exact table/callout structure are approximated); for a " +
|
|
||||||
"lossless representation use get_page_json. Inline <span data-comment-id> " +
|
|
||||||
"tags in the markdown are comment highlight anchors (also present for " +
|
|
||||||
"RESOLVED threads) — treat them as markup, not page text.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId }) => {
|
|
||||||
const page = await docmostClient.getPage(pageId);
|
const page = await docmostClient.getPage(pageId);
|
||||||
return jsonContent(page);
|
return jsonContent(page);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// Tool: get_page_json
|
// Tool: get_page_json
|
||||||
registerShared(SHARED_TOOL_SPECS.getPageJson, async ({ pageId }) => {
|
registerShared(SHARED_TOOL_SPECS.getPageJson, async ({ pageId }) => {
|
||||||
@@ -201,6 +164,10 @@ registerShared(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: table_get
|
// Tool: table_get
|
||||||
|
// NOT in the shared registry: the MCP tool name `table_get` is noun-first while
|
||||||
|
// the in-app key is `getTable` (verb-first), breaking the snake_case(inAppKey)
|
||||||
|
// convention the shared registry enforces (shared-tool-specs.contract.spec.ts).
|
||||||
|
// Renaming the public MCP tool would break external clients, so it stays inline.
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"table_get",
|
"table_get",
|
||||||
{
|
{
|
||||||
@@ -223,25 +190,10 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: table_insert_row
|
// Tool: table_insert_row
|
||||||
// NOT in the shared registry: this transport names the table argument `table`,
|
// Schema + description now live in the shared registry (#294); the `table`
|
||||||
// while the in-app tool names it `tableRef` (ai-chat-tools.service.ts). Sharing
|
// parameter name is the canonical one (the in-app layer was unified to it).
|
||||||
// one buildShape would rename a public MCP parameter, so the table row/cell
|
registerShared(
|
||||||
// tools stay per-transport by design.
|
SHARED_TOOL_SPECS.tableInsertRow,
|
||||||
server.registerTool(
|
|
||||||
"table_insert_row",
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Insert a row of plain-text cells into a table. `table` = `#<index>` or " +
|
|
||||||
"a block id inside it. `cells` = text per column (padded to the table's " +
|
|
||||||
"column count; error if more cells than columns). `index` = 0-based " +
|
|
||||||
"insert position (0 inserts before the header); omit to append at the end.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
table: z.string().min(1),
|
|
||||||
cells: z.array(z.string()),
|
|
||||||
index: z.number().int().optional(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, table, cells, index }) => {
|
async ({ pageId, table, cells, index }) => {
|
||||||
const result = await docmostClient.tableInsertRow(
|
const result = await docmostClient.tableInsertRow(
|
||||||
pageId,
|
pageId,
|
||||||
@@ -254,22 +206,9 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: table_delete_row
|
// Tool: table_delete_row
|
||||||
// NOT shared — same `table` (here) vs `tableRef` (in-app) parameter-name
|
// Schema + description now live in the shared registry (#294).
|
||||||
// divergence as table_insert_row.
|
registerShared(
|
||||||
server.registerTool(
|
SHARED_TOOL_SPECS.tableDeleteRow,
|
||||||
"table_delete_row",
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Delete the row at 0-based `index` from a table (`table` = `#<index>` or " +
|
|
||||||
"a block id inside it). Refuses to delete the table's only row. An " +
|
|
||||||
"out-of-range `index` throws. Deleting `index` 0 removes the header row, " +
|
|
||||||
"and the next row becomes the new header.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
table: z.string().min(1),
|
|
||||||
index: z.number().int(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, table, index }) => {
|
async ({ pageId, table, index }) => {
|
||||||
const result = await docmostClient.tableDeleteRow(pageId, table, index);
|
const result = await docmostClient.tableDeleteRow(pageId, table, index);
|
||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
@@ -277,24 +216,9 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: table_update_cell
|
// Tool: table_update_cell
|
||||||
// NOT shared — same `table` (here) vs `tableRef` (in-app) parameter-name
|
// Schema + description now live in the shared registry (#294).
|
||||||
// divergence as table_insert_row.
|
registerShared(
|
||||||
server.registerTool(
|
SHARED_TOOL_SPECS.tableUpdateCell,
|
||||||
"table_update_cell",
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Set the plain-text content of cell [row,col] (0-based) in a table " +
|
|
||||||
"(`table` = `#<index>` or a block id inside it). Replaces the cell's " +
|
|
||||||
"content with a single text paragraph; for rich formatting use patch_node " +
|
|
||||||
"on the cell's paragraph id from table_get.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
table: z.string().min(1),
|
|
||||||
row: z.number().int(),
|
|
||||||
col: z.number().int(),
|
|
||||||
text: z.string(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, table, row, col, text }) => {
|
async ({ pageId, table, row, col, text }) => {
|
||||||
const result = await docmostClient.tableUpdateCell(
|
const result = await docmostClient.tableUpdateCell(
|
||||||
pageId,
|
pageId,
|
||||||
@@ -308,22 +232,9 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: create_page
|
// Tool: create_page
|
||||||
server.registerTool(
|
// Schema + description now live in the shared registry (#294).
|
||||||
"create_page",
|
registerShared(
|
||||||
{
|
SHARED_TOOL_SPECS.createPage,
|
||||||
description:
|
|
||||||
"Create a new page from Markdown in a space. Pass parentPageId to nest " +
|
|
||||||
"it under a parent; omit it to create at the space root.",
|
|
||||||
inputSchema: {
|
|
||||||
title: z.string().min(1).describe("Title of the page"),
|
|
||||||
content: z.string().min(1).describe("Markdown content"),
|
|
||||||
spaceId: z.string().min(1),
|
|
||||||
parentPageId: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("Optional parent page ID to nest under"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ title, content, spaceId, parentPageId }) => {
|
async ({ title, content, spaceId, parentPageId }) => {
|
||||||
const result = await docmostClient.createPage(
|
const result = await docmostClient.createPage(
|
||||||
title,
|
title,
|
||||||
@@ -336,32 +247,11 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: update_page_json
|
// Tool: update_page_json
|
||||||
server.registerTool(
|
// Schema + description now live in the shared registry (#294). The execute body
|
||||||
"update_page_json",
|
// keeps this transport's content normalization (parse a JSON-string content,
|
||||||
{
|
// pass undefined/null through for a title-only/no-op update).
|
||||||
description:
|
registerShared(
|
||||||
"Replace a page's content with a raw ProseMirror JSON document " +
|
SHARED_TOOL_SPECS.updatePageJson,
|
||||||
"(lossless write: preserves the block ids, callouts, tables and " +
|
|
||||||
"attributes you pass in). Typical flow: get_page_json -> modify the " +
|
|
||||||
"JSON -> update_page_json. Keep existing node ids intact so heading " +
|
|
||||||
"anchors and history stay stable. Minimal full-doc example: " +
|
|
||||||
'{"type":"doc","content":[{"type":"paragraph","content":' +
|
|
||||||
'[{"type":"text","text":"Hi"}]}]}. `content` may be a JSON object or a ' +
|
|
||||||
"JSON string (both accepted), and is OPTIONAL: omit it to update only " +
|
|
||||||
"the title (though prefer rename_page for a title-only change). " +
|
|
||||||
"Supplying neither content nor title is an error.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1).describe("ID of the page to update"),
|
|
||||||
content: z
|
|
||||||
.any()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'ProseMirror document {"type":"doc","content":[...]} (JSON object or ' +
|
|
||||||
"JSON string). Omit to rename only.",
|
|
||||||
),
|
|
||||||
title: z.string().optional().describe("Optional new title"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, content, title }) => {
|
async ({ pageId, content, title }) => {
|
||||||
// Only parse/validate the document when it was actually supplied; when it
|
// Only parse/validate the document when it was actually supplied; when it
|
||||||
// is omitted, pass it straight through so the client performs a title-only
|
// is omitted, pass it straight through so the client performs a title-only
|
||||||
@@ -379,26 +269,11 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: export_page_markdown
|
// Tool: export_page_markdown
|
||||||
server.registerTool(
|
// Schema + description now live in the shared registry (#294).
|
||||||
"export_page_markdown",
|
registerShared(SHARED_TOOL_SPECS.exportPageMarkdown, async ({ pageId }) => {
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Export a page to a single self-contained, lossless Docmost-flavoured " +
|
|
||||||
"Markdown file (custom extensions): YAML-free meta header, body with " +
|
|
||||||
"inline comment anchors and diagrams, and a trailing comments-thread " +
|
|
||||||
"block. Designed for a download -> edit body -> import_page_markdown " +
|
|
||||||
"round-trip that preserves everything, including comment highlights. " +
|
|
||||||
"Comment THREADS are preserved in the file but are not re-pushed to the " +
|
|
||||||
"server on import.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId }) => {
|
|
||||||
const md = await docmostClient.exportPageMarkdown(pageId);
|
const md = await docmostClient.exportPageMarkdown(pageId);
|
||||||
return { content: [{ type: "text" as const, text: md }] };
|
return { content: [{ type: "text" as const, text: md }] };
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// Tool: import_page_markdown
|
// Tool: import_page_markdown
|
||||||
registerShared(
|
registerShared(
|
||||||
@@ -422,22 +297,11 @@ registerShared(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: rename_page
|
// Tool: rename_page
|
||||||
server.registerTool(
|
// Schema + description now live in the shared registry (#294).
|
||||||
"rename_page",
|
registerShared(SHARED_TOOL_SPECS.renamePage, async ({ pageId, title }) => {
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Rename a page (change its title only) without touching or resending " +
|
|
||||||
"its content.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1).describe("ID of the page to rename"),
|
|
||||||
title: z.string().min(1).describe("New title"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, title }) => {
|
|
||||||
const result = await docmostClient.renamePage(pageId, title);
|
const result = await docmostClient.renamePage(pageId, title);
|
||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// Tool: edit_page_text
|
// Tool: edit_page_text
|
||||||
registerShared(SHARED_TOOL_SPECS.editPageText, async ({ pageId, edits }) => {
|
registerShared(SHARED_TOOL_SPECS.editPageText, async ({ pageId, edits }) => {
|
||||||
@@ -516,6 +380,10 @@ registerShared(SHARED_TOOL_SPECS.deleteNode, async ({ pageId, nodeId }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Tool: insert_image
|
// Tool: insert_image
|
||||||
|
// MCP-only by design (NOT in the shared registry): the in-app AI-chat agent
|
||||||
|
// exposes no image tools (insert/replace), so there is no second layer to unify
|
||||||
|
// — a SHARED_TOOL_SPECS entry's tier/catalogLine are in-app metadata and the
|
||||||
|
// catalog-partition test forbids a spec without a live in-app tool (#294).
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"insert_image",
|
"insert_image",
|
||||||
{
|
{
|
||||||
@@ -561,6 +429,7 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: replace_image
|
// Tool: replace_image
|
||||||
|
// MCP-only by design (see insert_image): no in-app equivalent, stays inline.
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"replace_image",
|
"replace_image",
|
||||||
{
|
{
|
||||||
@@ -603,25 +472,10 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: share_page
|
// Tool: share_page
|
||||||
// INTENTIONAL per-transport divergence (not shared): the in-app copy adds a
|
// Schema + description now live in the shared registry (#294). The execute body
|
||||||
// security-confirmation framing ("only share when the user explicitly asked,
|
// keeps this transport's own `searchIndexing ?? true` default.
|
||||||
// since this exposes the page to anyone with the link") tuned for the in-app
|
registerShared(
|
||||||
// agent; this transport keeps the plain public-URL wording.
|
SHARED_TOOL_SPECS.sharePage,
|
||||||
server.registerTool(
|
|
||||||
"share_page",
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Make a page publicly accessible (idempotent) and return its public " +
|
|
||||||
"URL. The URL format is <app>/share/<key>/p/<slugId>. This exposes the " +
|
|
||||||
"page content to ANYONE with the URL — do it only when explicitly asked.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1).describe("ID of the page to share"),
|
|
||||||
searchIndexing: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.describe("Allow search engines to index the page (default true)"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, searchIndexing }) => {
|
async ({ pageId, searchIndexing }) => {
|
||||||
const result = await docmostClient.sharePage(pageId, searchIndexing ?? true);
|
const result = await docmostClient.sharePage(pageId, searchIndexing ?? true);
|
||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
@@ -641,29 +495,11 @@ registerShared(SHARED_TOOL_SPECS.listShares, async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Tool: move_page
|
// Tool: move_page
|
||||||
server.registerTool(
|
// Schema + description now live in the shared registry (#294). The execute body
|
||||||
"move_page",
|
// keeps this transport's cycle guard, its 'null'/'' -> null string coercion, and
|
||||||
{
|
// its positive-confirmation check on the move response.
|
||||||
description:
|
registerShared(
|
||||||
"Move a page under a new parent (nesting) or to the space root.",
|
SHARED_TOOL_SPECS.movePage,
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
parentPageId: z
|
|
||||||
.string()
|
|
||||||
.nullable()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"Target parent page ID. Pass 'null' or empty string to move to root.",
|
|
||||||
),
|
|
||||||
position: z
|
|
||||||
.string()
|
|
||||||
.min(5)
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"fractional-index position key; min 5 chars; omit to append at the end.",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, parentPageId, position }) => {
|
async ({ pageId, parentPageId, position }) => {
|
||||||
const finalParentId =
|
const finalParentId =
|
||||||
parentPageId === "" || parentPageId === "null" ? null : parentPageId;
|
parentPageId === "" || parentPageId === "null" ? null : parentPageId;
|
||||||
@@ -698,49 +534,22 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: delete_page
|
// Tool: delete_page
|
||||||
server.registerTool(
|
// Schema + description now live in the shared registry (#294). The shared schema
|
||||||
"delete_page",
|
// exposes ONLY pageId, so no permanent/force-delete flag can reach the client.
|
||||||
{
|
registerShared(SHARED_TOOL_SPECS.deletePage, async ({ pageId }) => {
|
||||||
description:
|
|
||||||
"Delete a single page by ID. SOFT delete only: the page is moved to " +
|
|
||||||
"trash and can be restored; nothing is permanently deleted.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId }) => {
|
|
||||||
await docmostClient.deletePage(pageId);
|
await docmostClient.deletePage(pageId);
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{ type: "text" as const, text: `Successfully deleted page ${pageId}` },
|
{ type: "text" as const, text: `Successfully deleted page ${pageId}` },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// --- Comment tools (ported from upstream PR #3 by Max Nikitin) ---
|
// --- Comment tools (ported from upstream PR #3 by Max Nikitin) ---
|
||||||
|
|
||||||
// Tool: list_comments
|
// Tool: list_comments
|
||||||
server.registerTool(
|
registerShared(
|
||||||
"list_comments",
|
SHARED_TOOL_SPECS.listComments,
|
||||||
{
|
|
||||||
description:
|
|
||||||
"List comments on a page in one call (pagination is handled " +
|
|
||||||
"internally). By DEFAULT only ACTIVE threads are returned; resolved " +
|
|
||||||
"threads (a resolved top-level comment and all its replies) are hidden " +
|
|
||||||
"and their count reported as `resolvedThreadsHidden` so you can re-query " +
|
|
||||||
"with `includeResolved: true` to see everything. Returns " +
|
|
||||||
"`{ items, resolvedThreadsHidden }`. Content is returned as Markdown.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().describe("ID of the page"),
|
|
||||||
includeResolved: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"default only active threads; true — include resolved",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, includeResolved }) => {
|
async ({ pageId, includeResolved }) => {
|
||||||
const comments = await docmostClient.listComments(pageId, includeResolved);
|
const comments = await docmostClient.listComments(pageId, includeResolved);
|
||||||
return jsonContent(comments);
|
return jsonContent(comments);
|
||||||
@@ -748,55 +557,11 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: create_comment
|
// Tool: create_comment
|
||||||
// INTENTIONAL per-transport divergence (not shared): the in-app copy tunes the
|
// Schema + description now live in the shared registry (#294). The execute body
|
||||||
// guidance for the in-app agent (e.g. "retry with a corrected EXACT selection"
|
// keeps this transport's own guards (require a selection for a top-level
|
||||||
// and "Reversible via the comment UI"); this transport keeps its own wording.
|
// comment; reject suggestedText on a reply / without a selection).
|
||||||
server.registerTool(
|
registerShared(
|
||||||
"create_comment",
|
SHARED_TOOL_SPECS.createComment,
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Create a new comment on a page. The comment is ALWAYS inline and is " +
|
|
||||||
"anchored to (highlights) its `selection` text — there are no page-level " +
|
|
||||||
"comments. Content is provided as Markdown and automatically converted. " +
|
|
||||||
"A top-level comment REQUIRES an exact `selection`; if the selection " +
|
|
||||||
"cannot be found in the page the call fails (no orphan comment is left). " +
|
|
||||||
"Replies (parentCommentId set) inherit the parent's anchor and take no " +
|
|
||||||
"selection. You may also attach a `suggestedText` proposing a replacement " +
|
|
||||||
"for the `selection`; a human applies (or rejects) it from the UI. When " +
|
|
||||||
"`suggestedText` is set the `selection` MUST occur exactly once in the " +
|
|
||||||
"page — expand it with surrounding context if it is ambiguous.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().describe("ID of the page to comment on"),
|
|
||||||
content: z.string().min(1).describe("Comment content in Markdown format"),
|
|
||||||
selection: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
// Enforce the documented 250-char cap to match the description above.
|
|
||||||
.max(250)
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"EXACT contiguous text from a single paragraph/block to anchor the " +
|
|
||||||
"comment on (<=250 chars). Required for a top-level comment; omit " +
|
|
||||||
"only when replying via parentCommentId.",
|
|
||||||
),
|
|
||||||
parentCommentId: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("Parent comment ID to create a reply (max 2 nesting levels)"),
|
|
||||||
suggestedText: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.max(2000)
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"Optional proposed replacement (PLAIN TEXT) for the `selection`, " +
|
|
||||||
"applied by a human via the UI (never auto-applied). REQUIRES a " +
|
|
||||||
"`selection`; NOT allowed on a reply. When set, the `selection` must " +
|
|
||||||
"be UNIQUE in the page — expand it with surrounding context (still " +
|
|
||||||
"<=250 chars) if it occurs more than once, or the call is refused.",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, content, selection, parentCommentId, suggestedText }) => {
|
async ({ pageId, content, selection, parentCommentId, suggestedText }) => {
|
||||||
if (!parentCommentId && (!selection || !selection.trim())) {
|
if (!parentCommentId && (!selection || !selection.trim())) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -872,28 +637,9 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: resolve_comment
|
// Tool: resolve_comment
|
||||||
server.registerTool(
|
// Schema + description now live in the shared registry (#294).
|
||||||
"resolve_comment",
|
registerShared(
|
||||||
{
|
SHARED_TOOL_SPECS.resolveComment,
|
||||||
description:
|
|
||||||
"Resolve (close) or reopen a comment thread. Only top-level comments can " +
|
|
||||||
"be resolved — the server rejects resolving a reply. Reversible: pass " +
|
|
||||||
"resolved=false to reopen. Resolving keeps the thread and its replies " +
|
|
||||||
"(unlike delete_comment, which permanently removes them).",
|
|
||||||
inputSchema: {
|
|
||||||
commentId: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.describe("ID of the top-level comment thread to resolve or reopen"),
|
|
||||||
resolved: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.default(true)
|
|
||||||
.describe(
|
|
||||||
"true (default) marks the thread resolved/closed; false reopens it",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ commentId, resolved }) => {
|
async ({ commentId, resolved }) => {
|
||||||
const result = await docmostClient.resolveComment(commentId, resolved);
|
const result = await docmostClient.resolveComment(commentId, resolved);
|
||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
@@ -901,30 +647,10 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: check_new_comments
|
// Tool: check_new_comments
|
||||||
server.registerTool(
|
// Schema + description now live in the shared registry (#294). The execute body
|
||||||
"check_new_comments",
|
// keeps this transport's own guard rejecting an unparseable `since` timestamp.
|
||||||
{
|
registerShared(
|
||||||
description:
|
SHARED_TOOL_SPECS.checkNewComments,
|
||||||
"Check for new comments across pages in a space since a given timestamp. " +
|
|
||||||
"Optionally scope to a page subtree (folder). Returns only comments " +
|
|
||||||
"created after the specified time.",
|
|
||||||
inputSchema: {
|
|
||||||
spaceId: z.string().describe("Space ID to check for new comments"),
|
|
||||||
since: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.describe(
|
|
||||||
"ISO 8601 timestamp — only return comments created after this time (e.g. '2026-03-10T00:00:00Z')",
|
|
||||||
),
|
|
||||||
parentPageId: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"Optional root page ID to scope the check to a subtree (folder). " +
|
|
||||||
"Only pages under this parent will be checked.",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ spaceId, since, parentPageId }) => {
|
async ({ spaceId, since, parentPageId }) => {
|
||||||
// Reject an unparseable timestamp up front: otherwise the comparison
|
// Reject an unparseable timestamp up front: otherwise the comparison
|
||||||
// against NaN silently treats every comment as "not new" and the tool
|
// against NaN silently treats every comment as "not new" and the tool
|
||||||
@@ -1053,6 +779,8 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: insert_footnote
|
// Tool: insert_footnote
|
||||||
|
// MCP-only by design (see insert_image): the in-app AI-chat agent exposes no
|
||||||
|
// footnote tool, so there is no second layer to unify — stays inline (#294).
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"insert_footnote",
|
"insert_footnote",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -316,6 +316,34 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
|
|
||||||
// --- share management ---
|
// --- share management ---
|
||||||
|
|
||||||
|
// Unified from the per-layer inline definitions (#294). Both layers already
|
||||||
|
// carried the "only share when explicitly asked" security framing (the
|
||||||
|
// "per-transport divergence" note on the old inline copies was stale), so
|
||||||
|
// there was no real behavioral divergence to preserve — only wording drift.
|
||||||
|
sharePage: {
|
||||||
|
mcpName: 'share_page',
|
||||||
|
inAppKey: 'sharePage',
|
||||||
|
// CANONICAL: merges the MCP copy's URL-format + idempotency detail with the
|
||||||
|
// in-app copy's reversibility note; keeps the security framing both had.
|
||||||
|
description:
|
||||||
|
'Make a page PUBLICLY accessible (idempotent) and return its public URL ' +
|
||||||
|
'(format: <app>/share/<key>/p/<slugId>). This exposes the page content ' +
|
||||||
|
'to ANYONE with the URL — only share when the user explicitly asked. ' +
|
||||||
|
'Reversible: unshare it later to revoke the public URL.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'sharePage — make a page publicly accessible and return its URL.',
|
||||||
|
// Reconciled: MCP's stricter .min(1) on pageId kept; field descriptions from
|
||||||
|
// the in-app copy. The MCP execute keeps its own `searchIndexing ?? true`
|
||||||
|
// default (a per-layer concern, not part of the shared schema).
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1).describe('The id of the page to share.'),
|
||||||
|
searchIndexing: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe('Allow public search engines to index it (default true).'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
unsharePage: {
|
unsharePage: {
|
||||||
mcpName: 'unshare_page',
|
mcpName: 'unshare_page',
|
||||||
inAppKey: 'unsharePage',
|
inAppKey: 'unsharePage',
|
||||||
@@ -509,4 +537,470 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
pageId: z.string().min(1),
|
pageId: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- page tools (unified from the per-layer inline definitions, #294) ---
|
||||||
|
//
|
||||||
|
// Descriptions merge both layers (the MCP copy's richer structural notes + the
|
||||||
|
// in-app copy's "Reversible via history/trash" framing where it added one).
|
||||||
|
// Field constraints keep the MCP copy's stricter .min(1) EXCEPT where the
|
||||||
|
// in-app layer deliberately allowed a looser value (documented per field).
|
||||||
|
|
||||||
|
getPage: {
|
||||||
|
mcpName: 'get_page',
|
||||||
|
inAppKey: 'getPage',
|
||||||
|
description:
|
||||||
|
'Fetch a single page as Markdown by its id. Returns the page title and ' +
|
||||||
|
'its Markdown content. The Markdown conversion is LOSSY (block ids, exact ' +
|
||||||
|
'table/callout structure are approximated); for a lossless representation ' +
|
||||||
|
'use the lossless page-JSON read tool. Inline <span data-comment-id> tags in the markdown ' +
|
||||||
|
'are comment highlight anchors (also present for RESOLVED threads) — ' +
|
||||||
|
'treat them as markup, not page text.',
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: 'getPage — fetch a page as Markdown by its id.',
|
||||||
|
// Reconciled: MCP's stricter .min(1) kept; in-app's more-informative
|
||||||
|
// "(or slugId)" describe kept.
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1).describe('The id (or slugId) of the page.'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
listPages: {
|
||||||
|
mcpName: 'list_pages',
|
||||||
|
inAppKey: 'listPages',
|
||||||
|
description:
|
||||||
|
'List the most recent pages (ordered by updatedAt, descending), ' +
|
||||||
|
'optionally scoped to a single space. Returns a bounded list (default ' +
|
||||||
|
'50, max 100) — use search for lookups in large spaces. Pass tree:true ' +
|
||||||
|
"(with spaceId) to instead get the space's full page hierarchy as a " +
|
||||||
|
'nested tree.',
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: "listPages — list recent pages, or a space's full page tree.",
|
||||||
|
buildShape: (z) => ({
|
||||||
|
spaceId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Optional space id to scope the listing to.'),
|
||||||
|
limit: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.optional()
|
||||||
|
.describe('Maximum number of pages (default 50, max 100).'),
|
||||||
|
tree: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"When true, return the space's full page hierarchy as a nested tree " +
|
||||||
|
'(children arrays) instead of the recent-by-updatedAt flat list. ' +
|
||||||
|
'Requires spaceId; ignores limit.',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
createPage: {
|
||||||
|
mcpName: 'create_page',
|
||||||
|
inAppKey: 'createPage',
|
||||||
|
description:
|
||||||
|
'Create a new page with a Markdown body in a space, optionally under a ' +
|
||||||
|
'parent page (omit parentPageId to create at the space root). Returns ' +
|
||||||
|
'the new page id and title. Reversible: a page can be moved to trash ' +
|
||||||
|
'later.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'createPage — create a new page with a Markdown body in a space.',
|
||||||
|
// Reconciled schema DRIFT: the MCP copy pinned `content` to .min(1) while
|
||||||
|
// the in-app copy left it unbounded and DOCUMENTS an empty body as valid
|
||||||
|
// ("may be empty") — creating an empty page to fill in later is a real use
|
||||||
|
// case. The looser (no-min) form is kept, so create_page now also accepts an
|
||||||
|
// empty body (harmless — it creates an empty page) and no previously-valid
|
||||||
|
// in-app input is ever rejected. `title`/`spaceId` keep the MCP .min(1)
|
||||||
|
// (an empty title or space is never valid).
|
||||||
|
buildShape: (z) => ({
|
||||||
|
title: z.string().min(1).describe('The title of the new page.'),
|
||||||
|
content: z.string().describe('The page body as Markdown (may be empty).'),
|
||||||
|
spaceId: z.string().min(1).describe('The id of the space to create the page in.'),
|
||||||
|
parentPageId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Optional parent page id to nest the new page under.'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
movePage: {
|
||||||
|
mcpName: 'move_page',
|
||||||
|
inAppKey: 'movePage',
|
||||||
|
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.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'movePage — move a page under a new parent or to the space root.',
|
||||||
|
// Reconciled schema DRIFT: the MCP copy exposed a `position` field
|
||||||
|
// (fractional-index ordering) that the in-app copy lacked. Unified by
|
||||||
|
// KEEPING position (the in-app client already accepts an optional position
|
||||||
|
// arg, so the in-app execute now forwards it) — it is optional, so no
|
||||||
|
// previously-valid in-app call is rejected. `parentPageId` is `.nullable()`
|
||||||
|
// on both, so a real JSON null moves to root on either transport; the MCP
|
||||||
|
// execute additionally coerces the strings 'null'/'' to null as a robustness
|
||||||
|
// fallback (kept in its execute body, not in the shared schema).
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1).describe('The id of the page to move.'),
|
||||||
|
parentPageId: z
|
||||||
|
.string()
|
||||||
|
.nullable()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Target parent page id. Null or omitted moves the page to the space ' +
|
||||||
|
'root.',
|
||||||
|
),
|
||||||
|
position: z
|
||||||
|
.string()
|
||||||
|
.min(5)
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Optional fractional-index position key (min 5 chars); omit to ' +
|
||||||
|
'append at the end.',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
renamePage: {
|
||||||
|
mcpName: 'rename_page',
|
||||||
|
inAppKey: 'renamePage',
|
||||||
|
description:
|
||||||
|
'Rename a page (change its title only; the body is untouched, never ' +
|
||||||
|
'resent). Reversible: rename back at any time.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: "renamePage — change a page's title only (body untouched).",
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1).describe('The id of the page to rename.'),
|
||||||
|
title: z.string().min(1).describe('The new title.'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
deletePage: {
|
||||||
|
mcpName: 'delete_page',
|
||||||
|
inAppKey: 'deletePage',
|
||||||
|
description:
|
||||||
|
'Move a page to the trash — SOFT delete only: the page can be restored ' +
|
||||||
|
'from trash and nothing is ever permanently deleted.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'deletePage — move a page to trash (soft delete, reversible).',
|
||||||
|
// GUARDRAIL preserved (§14 H4): the schema exposes ONLY pageId, so a
|
||||||
|
// permanentlyDelete/forceDelete flag can never reach the client through this
|
||||||
|
// tool (asserted by ai-chat-tools.service.spec.ts).
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1).describe('The id of the page to move to trash.'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
updatePageJson: {
|
||||||
|
mcpName: 'update_page_json',
|
||||||
|
inAppKey: 'updatePageJson',
|
||||||
|
description:
|
||||||
|
"Replace a page's content with a raw ProseMirror JSON document (lossless " +
|
||||||
|
'write: preserves the block ids, callouts, tables and attributes you pass ' +
|
||||||
|
'in). Typical flow: read the page-JSON view -> modify the JSON -> write it back. ' +
|
||||||
|
'Keep existing node ids intact so heading anchors and history stay ' +
|
||||||
|
'stable. Minimal full-doc example: {"type":"doc","content":[{"type":' +
|
||||||
|
'"paragraph","content":[{"type":"text","text":"Hi"}]}]}. `content` may be ' +
|
||||||
|
'a JSON object or a JSON string (both accepted), and is OPTIONAL: omit it ' +
|
||||||
|
'to update only the title (though prefer the rename-page tool for a title-only ' +
|
||||||
|
'change). Supplying neither content nor title is an error. Reversible: ' +
|
||||||
|
'the previous version is kept in page history.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
"updatePageJson — overwrite a page's body with a full ProseMirror document.",
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1).describe('ID of the page to update'),
|
||||||
|
content: z
|
||||||
|
.any()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'ProseMirror document {"type":"doc","content":[...]} (JSON object or ' +
|
||||||
|
'JSON string). Omit to update only the title.',
|
||||||
|
),
|
||||||
|
title: z.string().optional().describe('Optional new title'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
exportPageMarkdown: {
|
||||||
|
mcpName: 'export_page_markdown',
|
||||||
|
inAppKey: 'exportPageMarkdown',
|
||||||
|
// CANONICAL: the MCP copy (a strict superset of the terse in-app wording).
|
||||||
|
description:
|
||||||
|
'Export a page to a single self-contained, lossless Docmost-flavoured ' +
|
||||||
|
'Markdown file (custom extensions): YAML-free meta header, body with ' +
|
||||||
|
'inline comment anchors and diagrams, and a trailing comments-thread ' +
|
||||||
|
'block. Designed for a download -> edit body -> page-Markdown import ' +
|
||||||
|
'round-trip that preserves everything, including comment highlights. ' +
|
||||||
|
'Comment THREADS are preserved in the file but are not re-pushed to the ' +
|
||||||
|
'server on import.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
'exportPageMarkdown — export a page to self-contained Markdown (body + comments).',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1).describe('The id of the page to export.'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- comment tools (unified from the per-layer inline definitions, #294) ---
|
||||||
|
//
|
||||||
|
// create_comment and resolve_comment previously carried a "per-transport
|
||||||
|
// divergence" note in BOTH layers; #294 unifies their schema + description
|
||||||
|
// here. Only the four tools that genuinely exist in BOTH layers live in the
|
||||||
|
// registry: create/list/resolve comment and check_new_comments.
|
||||||
|
//
|
||||||
|
// update_comment and delete_comment are intentionally NOT here: they exist
|
||||||
|
// ONLY on the standalone MCP server. The in-app agent deliberately exposes no
|
||||||
|
// hard comment edit/delete tool (comment edits are irreversible / not
|
||||||
|
// version-tracked; see the guardrail tests in ai-chat-tools.service.spec.ts),
|
||||||
|
// so there is nothing to unify — they stay inline in index.ts.
|
||||||
|
|
||||||
|
createComment: {
|
||||||
|
mcpName: 'create_comment',
|
||||||
|
inAppKey: 'createComment',
|
||||||
|
// CANONICAL: the in-app copy (the more-maintained one). It keeps the same
|
||||||
|
// rules as the MCP copy — inline-only, top-level requires a `selection`, no
|
||||||
|
// page-level comments, replies inherit the anchor, suggestedText must be
|
||||||
|
// unique — and adds the "retry with a corrected EXACT selection" and reply-
|
||||||
|
// to-reply-rejected guidance the MCP copy lacked. Execute-side validation
|
||||||
|
// (reject suggestedText on a reply, require a selection) stays per-layer.
|
||||||
|
description:
|
||||||
|
'Add an INLINE comment to a page, or reply to an existing top-level ' +
|
||||||
|
'comment (one level only — the backend rejects replies to replies). ' +
|
||||||
|
'The comment is anchored inline to the given exact `selection` text ' +
|
||||||
|
'(which gets highlighted); page-level comments are NOT supported. A ' +
|
||||||
|
'new top-level comment REQUIRES a `selection`. Replies inherit the ' +
|
||||||
|
"parent's anchor and take no selection. If the call fails with a " +
|
||||||
|
'"selection not found" error, retry with a corrected EXACT selection ' +
|
||||||
|
'copied verbatim from a single paragraph/block. You may also attach a ' +
|
||||||
|
'`suggestedText` proposing a replacement for the `selection` (a human ' +
|
||||||
|
'applies it from the UI); when set, the `selection` must occur exactly ' +
|
||||||
|
'once in the page. Reversible via the comment UI.',
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine:
|
||||||
|
'createComment — add an inline comment (optionally with a suggested edit).',
|
||||||
|
// Reconciled schema: the field set is identical across both layers; the
|
||||||
|
// only constraint drift is `content`, which the MCP copy pinned to
|
||||||
|
// .min(1) while the in-app copy left unbounded — the stricter MCP form is
|
||||||
|
// kept (an empty comment body is never valid).
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().describe('The id of the page to comment on.'),
|
||||||
|
content: z.string().min(1).describe('The comment body as Markdown.'),
|
||||||
|
selection: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(250)
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'EXACT contiguous text from a SINGLE paragraph/block to anchor ' +
|
||||||
|
'(highlight) the comment on (<=250 chars, avoid spanning across ' +
|
||||||
|
'formatting boundaries). Required for a new top-level comment; ' +
|
||||||
|
'omit only when replying via parentCommentId.',
|
||||||
|
),
|
||||||
|
parentCommentId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Optional id of a TOP-LEVEL comment to reply to (one level ' +
|
||||||
|
'of replies only).',
|
||||||
|
),
|
||||||
|
suggestedText: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(2000)
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Optional proposed replacement (PLAIN TEXT) for the `selection`, ' +
|
||||||
|
'applied by a human via the UI (never auto-applied). REQUIRES a ' +
|
||||||
|
'`selection`; NOT allowed on a reply. When set, the `selection` ' +
|
||||||
|
'must be UNIQUE in the page — expand it with surrounding context ' +
|
||||||
|
'(still <=250 chars) if it occurs more than once, or the call is ' +
|
||||||
|
'refused.',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
listComments: {
|
||||||
|
mcpName: 'list_comments',
|
||||||
|
inAppKey: 'listComments',
|
||||||
|
// CANONICAL: the two copies are near-identical; the MCP copy is the
|
||||||
|
// superset (it keeps the "(pagination is handled internally)" note the
|
||||||
|
// in-app copy dropped), so it is used verbatim.
|
||||||
|
description:
|
||||||
|
'List comments on a page in one call (pagination is handled ' +
|
||||||
|
'internally). By DEFAULT only ACTIVE threads are returned; resolved ' +
|
||||||
|
'threads (a resolved top-level comment and all its replies) are hidden ' +
|
||||||
|
'and their count reported as `resolvedThreadsHidden` so you can re-query ' +
|
||||||
|
'with `includeResolved: true` to see everything. Returns ' +
|
||||||
|
'`{ items, resolvedThreadsHidden }`. Content is returned as Markdown.',
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine:
|
||||||
|
'listComments — list all comments on a page (including resolved).',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().describe('ID of the page'),
|
||||||
|
includeResolved: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe('default only active threads; true — include resolved'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
resolveComment: {
|
||||||
|
mcpName: 'resolve_comment',
|
||||||
|
inAppKey: 'resolveComment',
|
||||||
|
// CANONICAL: the MCP copy's richer wording, minus its snake_case reference
|
||||||
|
// to `delete_comment` (a sibling tool that does NOT exist in the in-app
|
||||||
|
// layer) — rephrased transport-neutrally per the registry convention.
|
||||||
|
description:
|
||||||
|
'Resolve (close) or reopen a top-level comment thread (reversible — ' +
|
||||||
|
'pass resolved=false to reopen). Only top-level comments can be ' +
|
||||||
|
'resolved; the server rejects resolving a reply. Resolving keeps the ' +
|
||||||
|
'thread and its replies intact (it is not a deletion).',
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: 'resolveComment — resolve or reopen a comment thread.',
|
||||||
|
// Reconciled schema: `resolved` drifted — the MCP copy made it optional
|
||||||
|
// with .default(true) (resolve is the common case, documented), the in-app
|
||||||
|
// copy made it required. The MCP form is kept (a strict superset: it never
|
||||||
|
// rejects a previously-valid input and adds a sensible default), and
|
||||||
|
// commentId keeps the MCP copy's stricter .min(1).
|
||||||
|
buildShape: (z) => ({
|
||||||
|
commentId: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe('ID of the top-level comment thread to resolve or reopen'),
|
||||||
|
resolved: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.default(true)
|
||||||
|
.describe(
|
||||||
|
'true (default) marks the thread resolved/closed; false reopens it',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
checkNewComments: {
|
||||||
|
mcpName: 'check_new_comments',
|
||||||
|
inAppKey: 'checkNewComments',
|
||||||
|
// CANONICAL: the MCP copy (the more detailed of the two). The MCP layer's
|
||||||
|
// execute-side guard that rejects an unparseable `since` timestamp stays in
|
||||||
|
// its execute body (per-layer logic), not in the shared schema.
|
||||||
|
description:
|
||||||
|
'Check for new comments across pages in a space since a given ' +
|
||||||
|
'timestamp. Optionally scope to a page subtree (folder). Returns only ' +
|
||||||
|
'comments created after the specified time.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
'checkNewComments — find comments in a space created after a timestamp.',
|
||||||
|
// Reconciled schema: `since` keeps the MCP copy's stricter .min(1) (the
|
||||||
|
// in-app copy left it unbounded); field descriptions use the MCP copy's
|
||||||
|
// more detailed wording (it carries an example timestamp).
|
||||||
|
buildShape: (z) => ({
|
||||||
|
spaceId: z.string().describe('Space ID to check for new comments'),
|
||||||
|
since: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe(
|
||||||
|
"ISO 8601 timestamp — only return comments created after this time " +
|
||||||
|
"(e.g. '2026-03-10T00:00:00Z')",
|
||||||
|
),
|
||||||
|
parentPageId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Optional root page ID to scope the check to a subtree (folder). ' +
|
||||||
|
'Only pages under this parent will be checked.',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- table tools (unified from the per-layer inline definitions, #294) ---
|
||||||
|
//
|
||||||
|
// These tools carried a "NOT shared" note in BOTH layers because of a single
|
||||||
|
// parameter-NAME drift: the MCP layer named the table reference `table` while
|
||||||
|
// the in-app layer named it `tableRef`. #294 reconciles that drift by unifying
|
||||||
|
// on the MCP name `table` — renaming the MCP public parameter would break
|
||||||
|
// external MCP clients, whereas the in-app parameter is model-facing
|
||||||
|
// (prompt-only) and safe to rename. The in-app execute bodies now destructure
|
||||||
|
// `table` instead of `tableRef` (nothing else changes). Descriptions take the
|
||||||
|
// MCP copy's richer wording (it documented `#<index>`, padding, header-row
|
||||||
|
// behavior) plus the in-app copy's "Reversible via page history" note; sibling
|
||||||
|
// tool references are phrased transport-neutrally.
|
||||||
|
//
|
||||||
|
// NOT here (kept inline in index.ts): table_get / getTable. Its MCP tool name
|
||||||
|
// is noun-first (`table_get`) while the in-app key is verb-first (`getTable`),
|
||||||
|
// so it breaks the snake_case(inAppKey) naming convention the registry enforces
|
||||||
|
// (shared-tool-specs.contract.spec.ts). Renaming the public MCP tool would
|
||||||
|
// break external clients, so it stays per-transport (its in-app param was still
|
||||||
|
// aligned to `table` for consistency with the migrated trio below).
|
||||||
|
|
||||||
|
tableInsertRow: {
|
||||||
|
mcpName: 'table_insert_row',
|
||||||
|
inAppKey: 'tableInsertRow',
|
||||||
|
description:
|
||||||
|
'Insert a row of plain-text cells into a table. `table` is `#<index>` ' +
|
||||||
|
'from the page outline, or a block id inside it. `cells` is the text per ' +
|
||||||
|
"column (padded to the table's column count; an error if more cells than " +
|
||||||
|
'columns). `index` is the 0-based insert position (0 inserts before the ' +
|
||||||
|
'header); omit to append at the end. Reversible via page history.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'tableInsertRow — insert a row of plain-text cells into a table.',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1).describe('The id of the page.'),
|
||||||
|
table: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe('"#<index>" from the page outline, or a block id in the table.'),
|
||||||
|
cells: z.array(z.string()).describe('The cell texts for the row (one per column).'),
|
||||||
|
index: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.optional()
|
||||||
|
.describe('0-based insert position (0 inserts before the header); omit to append.'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
tableDeleteRow: {
|
||||||
|
mcpName: 'table_delete_row',
|
||||||
|
inAppKey: 'tableDeleteRow',
|
||||||
|
description:
|
||||||
|
'Delete the row at 0-based `index` from a table (`table` is `#<index>` ' +
|
||||||
|
'from the page outline, or a block id inside it). Refuses to delete the ' +
|
||||||
|
"table's only row; an out-of-range `index` throws. Deleting `index` 0 " +
|
||||||
|
'removes the header row, and the next row becomes the new header. ' +
|
||||||
|
'Reversible via page history.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'tableDeleteRow — delete a table row at a 0-based index.',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1).describe('The id of the page.'),
|
||||||
|
table: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe('"#<index>" from the page outline, or a block id in the table.'),
|
||||||
|
index: z.number().int().describe('0-based row index to delete.'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
tableUpdateCell: {
|
||||||
|
mcpName: 'table_update_cell',
|
||||||
|
inAppKey: 'tableUpdateCell',
|
||||||
|
description:
|
||||||
|
'Set the plain-text content of cell [row, col] (0-based) in a table ' +
|
||||||
|
'(`table` is `#<index>` from the page outline, or a block id inside it). ' +
|
||||||
|
"Replaces the cell's content with a single text paragraph; for rich " +
|
||||||
|
"formatting, patch the cell's paragraph id (obtained from reading the " +
|
||||||
|
'table) instead. Reversible via page history.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'tableUpdateCell — set the text of a table cell at [row, col].',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1).describe('The id of the page.'),
|
||||||
|
table: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe('"#<index>" from the page outline, or a block id in the table.'),
|
||||||
|
row: z.number().int().describe('0-based row index.'),
|
||||||
|
col: z.number().int().describe('0-based column index.'),
|
||||||
|
text: z.string().describe('The new cell text.'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
} satisfies Record<string, SharedToolSpec>;
|
} satisfies Record<string, SharedToolSpec>;
|
||||||
|
|||||||
Vendored
+16
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"_bug": "BUG #351: a `column` whose `width` is a percentage string (e.g. \"50%\") is NOT byte-stable across export->import->export (violates P2). The `column` schema's parseHTML does `parseFloat(getAttribute('data-width'))`, which silently drops the '%' unit and returns the NUMBER 50. So the first export emits data-width=\"50%\" but the re-import stores width=50, and the second export emits data-width=\"50\": md2 !== md1, a permanent GS-EDIT-REVERT churn (every git-sync pull rewrites the column width). The editor authors column widths as percentages, so this is a real data/round-trip defect. Fix belongs in src/lib/docmost-schema.ts column.width parseHTML (preserve the unit / keep the string), which is OUT OF SCOPE for this test-only PR and must be a separate, maintainer-approved change. This flat generator therefore keeps `column.width` frozen (never generates a non-default width).",
|
||||||
|
"doc": {
|
||||||
|
"type": "doc",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "columns",
|
||||||
|
"attrs": { "layout": "two_equal", "widthMode": "normal" },
|
||||||
|
"content": [
|
||||||
|
{ "type": "column", "attrs": { "width": "50%" }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "L" }] }] },
|
||||||
|
{ "type": "column", "attrs": { "width": "50%" }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "R" }] }] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user