Compare commits
111 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6956563961 | |||
| e46e89b58c | |||
| a2e63e7ea3 | |||
| 8893069500 | |||
| 4f64fae0fe | |||
| e90624a51c | |||
| 7d9cf99f6c | |||
| 3f7d96e09d | |||
| d218b3a39e | |||
| f0778cb85a | |||
| 8f7da77939 | |||
| e6a861bdaf | |||
| 5b835fc185 | |||
| cec50c3ce4 | |||
| f36a2def73 | |||
| 67dca8c10e | |||
| 5d45f5a85e | |||
| 320b200ac8 | |||
| 539512c4c8 | |||
| edc5dae103 | |||
| 67fa0d1a28 | |||
| 91d674fea6 | |||
| bb5bb52244 | |||
| f9cd3e6318 | |||
| c838fdeebe | |||
| f2d12fd2cd | |||
| fcc9ae0c24 | |||
| e4ff146ab0 | |||
| c7e034cab9 | |||
| 123e981808 | |||
| 7abce93543 | |||
| 0750a6fd34 | |||
| d833e5adb1 | |||
| f3dbcec0fd | |||
| 63f948df10 | |||
| fbaaa84419 | |||
| 32cb9eb1e3 | |||
| b47751349f | |||
| b7e5cb6970 | |||
| 906733b5c8 | |||
| f020739bfd | |||
| 22e3fcdeba | |||
| 7179f8a5b2 | |||
| fe4adf23a0 | |||
| eefe17600c | |||
| 32e99c6e42 | |||
| e48d7720e9 | |||
| 42e618ec7f | |||
| 857a0064f7 | |||
| daf6c9ea16 | |||
| 9e69d917ee | |||
| 2594828758 | |||
| b5ce63a956 | |||
| e777ebcf4f | |||
| abd6e3948b | |||
| 5125296bfa | |||
| 452a752264 | |||
| a40a00d5c5 | |||
| 81c0226be7 | |||
| d5079aa1d8 | |||
| b536a41ad3 | |||
| 28d2560dfd | |||
| 52959de2f3 | |||
| 5da12e89f9 | |||
| 3a91e0eca9 | |||
| 2e83c9cebf | |||
| f6d22a59a6 | |||
| 6baad935f9 | |||
| d255afa611 | |||
| 73c5c44301 | |||
| 8c42c4f0d6 | |||
| 071eae4e2a | |||
| a91405632e | |||
| 5d4eb8ede2 | |||
| aa1ee64b7a | |||
| 53febfd5b9 | |||
| a2ac08c04c | |||
| 40ca04eb08 | |||
| 393875d910 | |||
| c3dbee9fbf | |||
| ea1f8da906 | |||
| 9baaf1ea58 | |||
| 71375e25ee | |||
| e528988d71 | |||
| dc7a0ec9f5 | |||
| 969c00aaf1 | |||
| 085a30575f | |||
| 95bc9fe98d | |||
| cca0bfe306 | |||
| 0dbf85b129 | |||
| fb357cd52e | |||
| 177d8a31d4 | |||
| 8fa32e8438 | |||
| 807ff1f5f5 | |||
| fa89cba023 | |||
| 3386bf2865 | |||
| 98253cf614 | |||
| 181a8330f3 | |||
| 02daccc453 | |||
| d06cf97ed6 | |||
| 04032ae677 | |||
| d9d1d54aaa | |||
| 593f181bbc | |||
| 582e1976cc | |||
| e0e01157c2 | |||
| 8373360a67 | |||
| e2493cafa9 | |||
| 5a4d9f84d7 | |||
| 70bd0dba4d | |||
| b0cd4bd6cf | |||
| 56ab17fbc2 |
+38
-34
@@ -209,20 +209,6 @@ MCP_DOCMOST_PASSWORD=
|
||||
# active" behavior.
|
||||
# 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 ---
|
||||
# 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
|
||||
@@ -257,26 +243,44 @@ MCP_DOCMOST_PASSWORD=
|
||||
# per rolling day).
|
||||
# SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY=1000000
|
||||
|
||||
# --- Observability / perf metrics (#355) ---
|
||||
# --- GIT-SYNC (native two-way Docmost <-> git Markdown sync) ---
|
||||
# Master switch. Off by default. When 'true', GIT_SYNC_SERVICE_USER_ID below is
|
||||
# REQUIRED (the service account that git-originated create/move/rename/delete are
|
||||
# attributed to) — the server refuses to boot with sync enabled and no user id.
|
||||
# GIT_SYNC_ENABLED=false
|
||||
#
|
||||
# Two INDEPENDENT toggles, both OFF by default:
|
||||
# Serve the per-space vaults over smart-HTTP (the /git host). Defaults to
|
||||
# GIT_SYNC_ENABLED when unset.
|
||||
# GIT_SYNC_HTTP_ENABLED=false
|
||||
#
|
||||
# 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
|
||||
# REQUIRED when GIT_SYNC_ENABLED=true: id of the user that git-originated page
|
||||
# operations (create / move / rename / delete) are attributed to.
|
||||
# GIT_SYNC_SERVICE_USER_ID=
|
||||
#
|
||||
# Where the per-space working vaults live (non-bare repos; the engine needs a
|
||||
# working tree).
|
||||
# Defaults to "<DATA_DIR or ./data>/git-sync".
|
||||
# GIT_SYNC_DATA_DIR=
|
||||
#
|
||||
# SCAFFOLDING for the DEFERRED remote-push feature (SPEC §7) — NOT yet
|
||||
# implemented and currently INERT. The vendored sync engine does not consume
|
||||
# this value anywhere (git push to a remote is deferred), so setting it has NO
|
||||
# effect today: vaults remain local-only regardless. It is validated and carried
|
||||
# only so the wiring is ready for when remote push lands. The intended future
|
||||
# shape is a per-space URL template where the literal "{spaceId}" is substituted
|
||||
# per space (e.g. git@host:vault-{spaceId}.git).
|
||||
# GIT_SYNC_REMOTE_TEMPLATE=
|
||||
#
|
||||
# Poll-safety interval in ms — the cadence of the background reconcile cycle
|
||||
# (default: 15000).
|
||||
# GIT_SYNC_POLL_INTERVAL_MS=15000
|
||||
#
|
||||
# Debounce window in ms for collapsing bursts of page edits into one sync cycle
|
||||
# (default: 2000).
|
||||
# GIT_SYNC_DEBOUNCE_MS=2000
|
||||
#
|
||||
# Watchdog timeout in ms for the spawned `git http-backend` process serving a
|
||||
# git smart-HTTP push (default: 120000). A stalled/hung receive-pack is killed
|
||||
# after this deadline so it cannot hold the per-space lock forever.
|
||||
# GIT_SYNC_BACKEND_TIMEOUT_MS=120000
|
||||
#
|
||||
# 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
|
||||
|
||||
@@ -76,10 +76,18 @@ jobs:
|
||||
# build/ is gitignored, and plain `pnpm -r test` does NOT honour nx
|
||||
# `dependsOn: ^build`, so its consumers (mcp `pretest: tsc`, git-sync vitest
|
||||
# typecheck) fail with TS2307 Cannot find module '@docmost/prosemirror-markdown'
|
||||
# unless it is built first. Build it before the recursive test run.
|
||||
# unless it is built first. Build it BEFORE git-sync/mcp (which import it).
|
||||
- name: Build prosemirror-markdown
|
||||
run: pnpm --filter @docmost/prosemirror-markdown build
|
||||
|
||||
# git-sync and mcp are no longer committed in built form (build/ is
|
||||
# gitignored), so CI must compile them: the server resolves both via their
|
||||
# built build/index.js (git-sync's runtime consumer lands on this branch,
|
||||
# #119). The server pretest also builds them, but building here keeps it
|
||||
# explicit and independent of pnpm lifecycle ordering.
|
||||
- name: Build git-sync and mcp
|
||||
run: pnpm --filter @docmost/git-sync build && pnpm --filter @docmost/mcp build
|
||||
|
||||
- name: Run unit tests
|
||||
run: pnpm -r test
|
||||
|
||||
|
||||
+7
-10
@@ -4,19 +4,16 @@
|
||||
data
|
||||
# compiled output
|
||||
/dist
|
||||
node_modules
|
||||
/node_modules
|
||||
# workspace package node_modules (pnpm symlinks — never commit; they bake
|
||||
# machine-local store paths).
|
||||
packages/*/node_modules/
|
||||
|
||||
# git-sync compiled output (built in CI/Docker via `pnpm build`, never committed,
|
||||
# so src/ and prod can never silently diverge).
|
||||
# Compiled package output: build/ is gitignored for every workspace package
|
||||
# (built in CI/Docker via `pnpm build`, never committed, so src/ and prod can
|
||||
# never silently diverge). Private packages are rebuilt at deploy.
|
||||
packages/git-sync/build/
|
||||
|
||||
# prosemirror-markdown compiled output (built in CI/Docker via `pnpm build`,
|
||||
# never committed, so src/ and prod can never silently diverge).
|
||||
packages/prosemirror-markdown/build/
|
||||
|
||||
# mcp compiled output (built in CI/Docker via `pnpm build`, never committed, so
|
||||
# src/ and prod can never silently diverge). Matches the git-sync/prosemirror-
|
||||
# markdown convention; the package is private and rebuilt at deploy.
|
||||
packages/mcp/build/
|
||||
|
||||
# Logs
|
||||
|
||||
@@ -193,7 +193,7 @@ authenticated in-process, so no `tea`/`curl` and no keychain lookup are needed.
|
||||
|
||||
## Monorepo layout
|
||||
|
||||
pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
|
||||
pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Five workspace packages:
|
||||
|
||||
| Path | Name | Stack | Role |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -202,6 +202,7 @@ pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
|
||||
| `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/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/git-sync` | `@docmost/git-sync` | Tiptap/ProseMirror, Yjs, git | The two-way Docmost↔git Markdown sync **engine** (vault layout, git orchestration, reconcile). Consumes the ProseMirror↔Markdown converter from `@docmost/prosemirror-markdown` (#293) — no longer carries its own converter copy. Bundled into the server (loaded over the ESM bridge), built in CI and the Dockerfile. |
|
||||
|
||||
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
||||
|
||||
@@ -263,8 +264,10 @@ Migration files live in `apps/server/src/database/migrations/` and are named `YY
|
||||
|
||||
The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes `robots.txt`, public share pages, and `mcp` from the prefix). A `preHandler` hook enforces that a resolved `workspaceId` exists for most `/api` routes (multi-tenant by hostname/subdomain via `DomainMiddleware`). `GET /api/sb/:id` (the anonymous blob-sandbox read route) is listed in that preHandler's `excludedPaths`, so it is exempt from workspace resolution and carries no session auth at all (its capability is the unguessable UUID + TTL + TLS) — unlike `/api/files/public/...`, which still resolves a workspace and requires a workspace-bound attachment JWT. Auth is JWT (cookie + bearer); authorization is **CASL** (`core/casl`) — every data access is scoped to the user's abilities.
|
||||
|
||||
Two routes are mounted **outside** the `/api` prefix at the root, as raw Fastify routes that bypass the Nest pipeline (so neither `DomainMiddleware` nor `ThrottlerGuard` runs for them — each resolves the workspace and throttles itself): `/mcp` (the embedded MCP server, see below) and `/git/<spaceId>.git/...` (the git-sync smart-HTTP host, see below). Both share `mcp-auth.helpers.ts` (HTTP-Basic parsing, `FailedLoginLimiter`, `clientIp`) and the common `resolveRequestWorkspace` helper.
|
||||
|
||||
### Module structure (server)
|
||||
`AppModule` wires integration modules (`integrations/*`: storage [local/S3/Azure], mail, queue [BullMQ on Redis], security, telemetry, throttle, `mcp`, `ai`) plus `CoreModule`, `DatabaseModule`, and `CollaborationModule`. `CoreModule` (`core/*`) holds the domain modules: `page`, `space`, `comment`, `workspace`, `user`, `auth`, `group`, `attachment`, `search`, `share`, `ai-chat`, etc. Each domain module follows NestJS controller → service → repo layering; DB repos live under `database/repos` and are injected app-wide from the global `DatabaseModule`.
|
||||
`AppModule` wires integration modules (`integrations/*`: storage [local/S3/Azure], mail, queue [BullMQ on Redis], security, telemetry, throttle, `mcp`, `ai`, `git-sync`) plus `CoreModule`, `DatabaseModule`, and `CollaborationModule`. `CoreModule` (`core/*`) holds the domain modules: `page`, `space`, `comment`, `workspace`, `user`, `auth`, `group`, `attachment`, `search`, `share`, `ai-chat`, etc. Each domain module follows NestJS controller → service → repo layering; DB repos live under `database/repos` and are injected app-wide from the global `DatabaseModule`.
|
||||
|
||||
**EE removal artifact:** `app.module.ts` still contains a `try/require('./ee/ee.module')` stub. That path no longer exists, so the require fails and is swallowed (it only hard-exits when `CLOUD === 'true'`). Treat EE as gone — do not add code that depends on it.
|
||||
|
||||
@@ -279,7 +282,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/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/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.
|
||||
|
||||
### Git-sync (native two-way Docmost ↔ git Markdown sync)
|
||||
`integrations/git-sync/` (`GitSyncModule`) + the vendored pure engine in `packages/git-sync`. Off by default; gated by the `GIT_SYNC_ENABLED` master switch (and `GIT_SYNC_SERVICE_USER_ID`, the account git-originated writes are attributed to). Per-space opt-in via `space.settings.gitSync.enabled`, with a second per-space toggle `space.settings.gitSync.autoMergeConflicts` that changes PUSH behavior for a still-conflicted page (one carrying `<<<<<<<`/`>>>>>>>` markers): **off (the safe default)** records a per-page failure and holds the refs so the user resolves the git conflict first (markers never reach Docmost); **on** strips the marker lines and pushes both sides' content. Each enabled space gets an on-disk working "vault" repo; the `GitSyncOrchestrator` runs a debounced + poll-backstop reconcile cycle (PULL Docmost→vault, PUSH vault→Docmost) under a per-space Redis leader lock + in-process mutex (`SpaceLockService`). Writes go through the collaboration layer (so concurrent human edits aren't clobbered) and are stamped `lastUpdatedSource = 'git-sync'` for the listener loop-guard. The in-process `setInterval` orchestration + best-effort lock (no fencing tokens) is a known multi-replica limitation — BullMQ + fencing is the documented future direction.
|
||||
|
||||
- **`/git` smart-HTTP host** (`integrations/git-sync/http/`, gated additionally by `GIT_SYNC_HTTP_ENABLED`, which defaults to `GIT_SYNC_ENABLED`): a raw root-mounted Fastify route `/git/<spaceId>.git/...` (registered in `main.ts`, NOT under `/api`) that bridges `git clone`/`fetch`/`push` to `git http-backend`. It authenticates HTTP Basic against `AuthService` (throttled by a `FailedLoginLimiter` mirroring the `/mcp` path), authorizes via `SpaceAbilityFactory` (read = fetch, Manage = push), and gates existence so a non-member gets the SAME 404 as a missing/sync-disabled space (never 403 — that would leak space existence). A push runs the receive-pack under the space lock, then a reconcile cycle.
|
||||
- **Schema mirror:** `packages/git-sync/src/lib/docmost-schema.ts` is one of the **three** hand-synced copies of the Tiptap document schema (see Client structure) — keep it in lockstep with `editor-ext` (canonical) and `packages/mcp`.
|
||||
|
||||
### 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:
|
||||
|
||||
+14
-13
@@ -12,6 +12,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- **Native two-way Docmost ↔ git Markdown sync.** Opt-in per space (Space
|
||||
settings → a git-sync toggle, plus an `autoMergeConflicts` toggle that controls
|
||||
whether a still-conflicted page is held back or pushed with its conflict
|
||||
markers stripped): each enabled space is mirrored to an on-disk git "vault" of
|
||||
Markdown files and reconciled in both directions (Docmost → vault and vault →
|
||||
Docmost) on a debounced + poll-backstop cycle, under a per-space lock, writing
|
||||
through the collaboration layer so concurrent human edits aren't clobbered.
|
||||
Git-originated changes are attributed to a configurable service account and
|
||||
carry a "git-sync" provenance badge in page history. Optionally exposes a `/git`
|
||||
smart-HTTP host so you can `git clone`/`fetch`/`push` a space directly (HTTP
|
||||
Basic auth, space-permission authorized). Off by default and configured via the
|
||||
`GIT_SYNC_*` environment variables, including `GIT_SYNC_ENABLED`,
|
||||
`GIT_SYNC_SERVICE_USER_ID`, and `GIT_SYNC_HTTP_ENABLED` (see `.env.example`).
|
||||
(#119)
|
||||
- **Place several images side by side in a row.** A new "Inline (side by
|
||||
side)" alignment mode in the image bubble menu renders consecutive inline
|
||||
images as a row that wraps onto the next line on narrow screens. The row is
|
||||
@@ -72,19 +86,6 @@ 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
|
||||
contain a standalone footnote definition, which canonicalization would drop.
|
||||
(#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
|
||||
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
|
||||
|
||||
+14
-7
@@ -24,8 +24,9 @@ RUN pnpm build
|
||||
|
||||
FROM base AS installer
|
||||
|
||||
# git: required by the git-sync VaultGit (shells out to git)
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl bash \
|
||||
&& apt-get install -y --no-install-recommends curl bash git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
@@ -45,14 +46,20 @@ COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist
|
||||
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
|
||||
COPY --from=builder /app/packages/mcp/build /app/packages/mcp/build
|
||||
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
|
||||
# mcp now depends on @docmost/prosemirror-markdown (workspace:*) and eager-imports
|
||||
# it at runtime (the in-app ai-chat DocmostClient loads build/index.js -> lib/
|
||||
# markdown-converter.js). Ship the built package + its manifest, or the prod
|
||||
# install resolves a broken workspace symlink and every ai-chat tool dies with
|
||||
# ERR_MODULE_NOT_FOUND (#293/#326 step 5). (git-sync has no runtime consumer yet;
|
||||
# revisit at step 6 when #119 lands.)
|
||||
# @docmost/prosemirror-markdown is the shared converter (#293/#326). Both mcp and
|
||||
# git-sync depend on it (workspace:*) and load it at runtime, so the built package +
|
||||
# its manifest must be shipped or the prod install resolves a broken workspace
|
||||
# symlink and every consumer dies with ERR_MODULE_NOT_FOUND.
|
||||
COPY --from=builder /app/packages/prosemirror-markdown/build /app/packages/prosemirror-markdown/build
|
||||
COPY --from=builder /app/packages/prosemirror-markdown/package.json /app/packages/prosemirror-markdown/package.json
|
||||
# git-sync: the server loads @docmost/git-sync at runtime via the loader
|
||||
# (git-sync.loader.ts), which deliberately does NOT `require()` it — the package is
|
||||
# ESM-only, so the loader uses `require.resolve` + a dynamic `import()`. Without
|
||||
# these copied build artifacts that resolve/import fails and the server crashes on
|
||||
# first use. Built fresh by the builder's `pnpm build` (nx builds the package's tsc
|
||||
# `build` target). This branch (#119) is where git-sync gains its runtime consumer.
|
||||
COPY --from=builder /app/packages/git-sync/build /app/packages/git-sync/build
|
||||
COPY --from=builder /app/packages/git-sync/package.json /app/packages/git-sync/package.json
|
||||
|
||||
# Copy root package files
|
||||
COPY --from=builder /app/package.json /app/package.json
|
||||
|
||||
@@ -61,7 +61,6 @@
|
||||
"react-clear-modal": "^2.0.18",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-drawio": "1.0.7",
|
||||
"web-vitals": "^5.1.0",
|
||||
"react-error-boundary": "6.1.1",
|
||||
"react-helmet-async": "3.0.0",
|
||||
"react-i18next": "16.5.8",
|
||||
|
||||
@@ -1222,8 +1222,12 @@
|
||||
"Commented": "Commented",
|
||||
"Resolved comment": "Resolved comment",
|
||||
"Ran tool {{name}}": "Ran tool {{name}}",
|
||||
"AI-agent": "AI-agent",
|
||||
"AI agent «{{role}}» on behalf of {{person}}": "AI agent «{{role}}» on behalf of {{person}}",
|
||||
"AI agent {{name}}": "AI agent {{name}}",
|
||||
"Edited by AI agent on behalf of {{name}}": "Edited by AI agent on behalf of {{name}}",
|
||||
"Git sync": "Git sync",
|
||||
"Synced from Git on behalf of {{name}}": "Synced from Git on behalf of {{name}}",
|
||||
"Endpoints": "Endpoints",
|
||||
"where we fetch models": "where we fetch models",
|
||||
"All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.": "All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.",
|
||||
@@ -1248,6 +1252,10 @@
|
||||
"MCP server": "MCP server",
|
||||
"expose the workspace": "expose the workspace",
|
||||
"Enable MCP server": "Enable MCP server",
|
||||
"Enable Git sync": "Enable Git sync",
|
||||
"Sync this space's pages to a Git repository.": "Sync this space's pages to a Git repository.",
|
||||
"Auto-merge conflicts on push": "Auto-merge conflicts on push",
|
||||
"When off (recommended), a page whose content still has unresolved Git conflict markers is skipped on push until you resolve the conflict in Git. When on, the markers are stripped and both sides' content is pushed.": "When off (recommended), a page whose content still has unresolved Git conflict markers is skipped on push until you resolve the conflict in Git. When on, the markers are stripped and both sides' content is pushed.",
|
||||
"Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.": "Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.",
|
||||
"Resolves to {{url}}": "Resolves to {{url}}",
|
||||
"Model": "Model",
|
||||
|
||||
@@ -737,6 +737,7 @@
|
||||
"Deleted successfully": "Успешно удалено",
|
||||
"AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}",
|
||||
"AI agent {{name}}": "AI-агент {{name}}",
|
||||
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
|
||||
"Failed to delete chat": "Не удалось удалить чат",
|
||||
"Failed to rename chat": "Не удалось переименовать чат",
|
||||
"Failed": "Ошибка",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Badge, Tooltip } from "@mantine/core";
|
||||
import { IconGitMerge } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface GitSyncBadgeProps {
|
||||
authorName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge marking a version produced by git-sync (provenance §8.1). The history
|
||||
* version is created on the PUSH path — when an incoming git body is written back
|
||||
* into the Docmost doc — not by the pull itself. Like {@link AiAgentBadge} it is
|
||||
* ADDITIVE — shown next to the human author, never replacing them — but a git-sync
|
||||
* edit is NOT an agent edit and has no chat to deep-link into, so it is a small,
|
||||
* neutral, non-clickable label.
|
||||
*/
|
||||
export function GitSyncBadge({ authorName }: GitSyncBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const tooltip = t("Synced from Git on behalf of {{name}}", {
|
||||
name: authorName ?? "",
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip label={tooltip} withArrow>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="gray"
|
||||
radius="sm"
|
||||
leftSection={<IconGitMerge size={12} stroke={2} />}
|
||||
>
|
||||
{t("Git sync")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
IconPlus,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useLocation, useMatch } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
@@ -41,24 +41,13 @@ import { extractPageSlugId } from "@/lib";
|
||||
import {
|
||||
AI_CHATS_RQ_KEY,
|
||||
AI_CHAT_MESSAGES_RQ_KEY,
|
||||
AI_CHAT_RUN_RQ_KEY,
|
||||
useAiChatMessagesQuery,
|
||||
useAiChatRunQuery,
|
||||
useAiChatsQuery,
|
||||
useAiRolesQuery,
|
||||
} 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 ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
||||
import {
|
||||
exportAiChat,
|
||||
stopRun,
|
||||
} from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { exportAiChat } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts";
|
||||
import {
|
||||
shouldCollapseOnOutsidePointer,
|
||||
@@ -245,147 +234,6 @@ export default function AiChatWindow() {
|
||||
const { data: messageRows, isLoading: messagesLoading } =
|
||||
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
|
||||
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
||||
// pathname against the authenticated page route instead so "the current page"
|
||||
@@ -1034,18 +882,6 @@ export default function AiChatWindow() {
|
||||
assistantName={currentRole?.name}
|
||||
onTurnFinished={onTurnFinished}
|
||||
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>
|
||||
|
||||
@@ -11,7 +11,6 @@ const h = vi.hoisted(() => ({
|
||||
onFinish: null as null | ((arg: Record<string, unknown>) => void),
|
||||
sendMessage: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
setMessages: vi.fn(),
|
||||
transport: null as null | {
|
||||
prepareSendMessagesRequest: (arg: {
|
||||
messages: unknown[];
|
||||
@@ -31,8 +30,6 @@ vi.mock("@ai-sdk/react", () => ({
|
||||
status: h.state.status,
|
||||
stop: h.state.stop,
|
||||
error: null,
|
||||
// #184: ChatThread reads setMessages to merge a polled observer run.
|
||||
setMessages: h.state.setMessages,
|
||||
};
|
||||
},
|
||||
}));
|
||||
@@ -231,56 +228,3 @@ 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,7 +24,6 @@ import {
|
||||
} from "@/features/ai-chat/utils/role-launch.ts";
|
||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
|
||||
import { mergeObservedMessage } from "@/features/ai-chat/utils/run-polling.ts";
|
||||
import {
|
||||
dequeue,
|
||||
enqueueMessage,
|
||||
@@ -87,29 +86,6 @@ interface ChatThreadProps {
|
||||
* Copy/export button available mid-stream). Distinct from onTurnFinished,
|
||||
* which fires only at the terminal outcome. */
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,10 +131,6 @@ export default function ChatThread({
|
||||
assistantName,
|
||||
onTurnFinished,
|
||||
onServerChatId,
|
||||
observedRow,
|
||||
onStreamingChange,
|
||||
autonomousRunsEnabled,
|
||||
onServerStop,
|
||||
}: ChatThreadProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -244,16 +216,6 @@ export default function ChatThread({
|
||||
const flushOnAbortRef = 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).
|
||||
// Returns whether a message was actually sent, so callers can tell an empty
|
||||
// dequeue (nothing to flush) from a real send.
|
||||
@@ -312,7 +274,7 @@ export default function ChatThread({
|
||||
[],
|
||||
);
|
||||
|
||||
const { messages, sendMessage, status, stop, error, setMessages } = useChat({
|
||||
const { messages, sendMessage, status, stop, error } = useChat({
|
||||
// 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
|
||||
// every render mid-stream (see `chatStoreId` above).
|
||||
@@ -403,14 +365,7 @@ export default function ChatThread({
|
||||
return;
|
||||
lastForwardedChatIdRef.current = serverChatId;
|
||||
onServerChatId(serverChatId);
|
||||
// #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]);
|
||||
}, [messages, onServerChatId]);
|
||||
|
||||
// Live "turn was interrupted" marker for the CURRENT session. The red error
|
||||
// banner (driven by `error`) covers the error case; this covers an aborted
|
||||
@@ -423,27 +378,6 @@ export default function ChatThread({
|
||||
|
||||
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 THIS message, keeping the agent's partial output. Other queued messages
|
||||
// stay queued and flush normally after the new turn. Reuses the existing
|
||||
@@ -475,40 +409,6 @@ export default function ChatThread({
|
||||
[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
|
||||
// stale "Send now" interrupt flags. On the legit interrupt path both refs are
|
||||
// already consumed synchronously (onFinish + prepareSendMessagesRequest) before
|
||||
@@ -520,11 +420,6 @@ export default function ChatThread({
|
||||
setStopNotice(null);
|
||||
flushOnAbortRef.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]);
|
||||
|
||||
@@ -644,7 +539,7 @@ export default function ChatThread({
|
||||
<ChatInput
|
||||
onSend={(text) => sendMessage({ text })}
|
||||
onQueue={enqueue}
|
||||
onStop={handleStop}
|
||||
onStop={stop}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
deleteAiChat,
|
||||
deleteAiRole,
|
||||
getAiChatMessages,
|
||||
getAiChatRun,
|
||||
getAiChats,
|
||||
getAiRoleCatalog,
|
||||
getAiRoleCatalogBundle,
|
||||
@@ -25,7 +24,6 @@ import {
|
||||
import {
|
||||
IAiChat,
|
||||
IAiChatMessageRow,
|
||||
IAiChatRunResponse,
|
||||
IAiRole,
|
||||
IAiRoleCatalog,
|
||||
IAiRoleCatalogBundle,
|
||||
@@ -36,7 +34,6 @@ import {
|
||||
IAiRoleUpdateFromCatalogResult,
|
||||
} from "@/features/ai-chat/types/ai-chat.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_ROLES_RQ_KEY = ["ai-roles"];
|
||||
@@ -54,18 +51,16 @@ export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
|
||||
"ai-chat-messages",
|
||||
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). */
|
||||
export function useAiChatsQuery() {
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: AI_CHATS_RQ_KEY,
|
||||
queryFn: ({ pageParam }) => getAiChats({ cursor: pageParam, limit: 50 }),
|
||||
queryFn: ({ pageParam }) =>
|
||||
getAiChats({ cursor: pageParam, limit: 50 }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage
|
||||
? (lastPage.meta.nextCursor ?? undefined)
|
||||
: undefined,
|
||||
lastPage.meta.hasNextPage ? (lastPage.meta.nextCursor ?? undefined) : undefined,
|
||||
});
|
||||
|
||||
const data = useMemo<IPagination<IAiChat> | undefined>(() => {
|
||||
@@ -95,9 +90,7 @@ export function useAiChatMessagesQuery(chatId: string | undefined) {
|
||||
getAiChatMessages({ chatId: chatId as string, cursor: pageParam }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage
|
||||
? (lastPage.meta.nextCursor ?? undefined)
|
||||
: undefined,
|
||||
lastPage.meta.hasNextPage ? (lastPage.meta.nextCursor ?? undefined) : undefined,
|
||||
enabled: !!chatId,
|
||||
});
|
||||
|
||||
@@ -138,34 +131,6 @@ 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() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
@@ -315,14 +280,11 @@ export function useImportAiRolesFromCatalogMutation() {
|
||||
mutationFn: (payload) => importAiRolesFromCatalog(payload),
|
||||
onSuccess: (result) => {
|
||||
notifications.show({
|
||||
message: t(
|
||||
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}",
|
||||
{
|
||||
created: result.created,
|
||||
renamed: result.renamed,
|
||||
skipped: result.skipped,
|
||||
},
|
||||
),
|
||||
message: t("Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}", {
|
||||
created: result.created,
|
||||
renamed: result.renamed,
|
||||
skipped: result.skipped,
|
||||
}),
|
||||
});
|
||||
// Surface partial failures (e.g. unique-name races) as a red warning.
|
||||
if (result.errors.length > 0) {
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
IAiChatListParams,
|
||||
IAiChatMessageRow,
|
||||
IAiChatMessagesParams,
|
||||
IAiChatRunResponse,
|
||||
IAiRole,
|
||||
IAiRoleCatalog,
|
||||
IAiRoleCatalogBundle,
|
||||
@@ -43,38 +42,6 @@ export async function getAiChatMessages(
|
||||
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
|
||||
* created on that page), or null when there is none. Drives auto-open-on-page.
|
||||
|
||||
@@ -200,38 +200,6 @@ export interface IAiChatMessageRow {
|
||||
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 IAiChatMessagesParams {
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,151 +0,0 @@
|
||||
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,11 +93,6 @@ import {
|
||||
isBodyEditable,
|
||||
isCollabSynced,
|
||||
} from "@/features/editor/editor-sync-state";
|
||||
import {
|
||||
isVitalsActive,
|
||||
measurePageOpen,
|
||||
reportEditorTx,
|
||||
} from "@/lib/telemetry/vitals";
|
||||
|
||||
interface PageEditorProps {
|
||||
pageId: string;
|
||||
@@ -356,40 +351,6 @@ export default function PageEditor({
|
||||
editor.storage.pageId = pageId;
|
||||
handleScrollTo(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 }) {
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, screen, cleanup, within } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// Mantine Tooltip mounts its label lazily on hover via Floating UI, which is
|
||||
// flaky under jsdom. Replace ONLY the Tooltip with a thin wrapper that renders
|
||||
// the label inline (keeping Badge/Switch/etc. real), so the provenance label —
|
||||
// the contract we care about — is deterministically queryable.
|
||||
vi.mock("@mantine/core", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@mantine/core")>("@mantine/core");
|
||||
const Tooltip = ({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}) => (
|
||||
<>
|
||||
{children}
|
||||
<span data-testid="tooltip-label">{label}</span>
|
||||
</>
|
||||
);
|
||||
Tooltip.Group = ({ children }: { children?: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
);
|
||||
return { ...actual, Tooltip };
|
||||
});
|
||||
|
||||
// jsdom lacks matchMedia, which MantineProvider's color-scheme hook needs.
|
||||
beforeAll(() => {
|
||||
if (!window.matchMedia) {
|
||||
window.matchMedia = (query: string) =>
|
||||
({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}) as unknown as MediaQueryList;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Mocks for the heavy / networked module graph ---------------------------
|
||||
// HistoryItem pulls in i18n, jotai atoms (ai-chat / history), a config-backed
|
||||
// avatar and a time formatter. The provenance-badge contract is the unit under
|
||||
// test, so we stub everything else down to inert, deterministic renders and
|
||||
// keep the real Mantine Badge/Tooltip so role/label queries are meaningful.
|
||||
|
||||
// i18n: interpolate {{name}} so the git-sync tooltip carries the author name,
|
||||
// letting us assert provenance attribution without a real i18n backend.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, vars?: Record<string, unknown>) =>
|
||||
vars && typeof vars.name !== "undefined"
|
||||
? key.replace("{{name}}", String(vars.name))
|
||||
: key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// jotai setters: the badges call useSetAtom; return inert setters so a click on
|
||||
// the (deep-linkable) AiAgentBadge would fire these — proving the git-sync badge
|
||||
// does NOT wire any of them.
|
||||
const setAiChatWindowOpen = vi.fn();
|
||||
const setActiveChatId = vi.fn();
|
||||
const setDraft = vi.fn();
|
||||
const setHistoryModalOpen = vi.fn();
|
||||
vi.mock("jotai", async () => {
|
||||
const actual = await vi.importActual<typeof import("jotai")>("jotai");
|
||||
return {
|
||||
...actual,
|
||||
useSetAtom: (atom: unknown) => {
|
||||
switch (atom) {
|
||||
case aiChatWindowOpenAtom:
|
||||
return setAiChatWindowOpen;
|
||||
case activeAiChatIdAtom:
|
||||
return setActiveChatId;
|
||||
case aiChatDraftAtom:
|
||||
return setDraft;
|
||||
case historyAtoms:
|
||||
return setHistoryModalOpen;
|
||||
default:
|
||||
return vi.fn();
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Atoms are imported only as identity tokens for the useSetAtom switch above.
|
||||
vi.mock("@/features/ai-chat/atoms/ai-chat-atom.ts", () => ({
|
||||
activeAiChatIdAtom: { __tag: "activeAiChatIdAtom" },
|
||||
aiChatWindowOpenAtom: { __tag: "aiChatWindowOpenAtom" },
|
||||
aiChatDraftAtom: { __tag: "aiChatDraftAtom" },
|
||||
}));
|
||||
vi.mock("@/features/page-history/atoms/history-atoms.ts", () => ({
|
||||
historyAtoms: { __tag: "historyAtoms" },
|
||||
}));
|
||||
|
||||
// Avatar reaches into config (getAvatarUrl) — stub to a plain element.
|
||||
vi.mock("@/components/ui/custom-avatar.tsx", () => ({
|
||||
CustomAvatar: ({ name }: { name?: string }) => (
|
||||
<span data-testid="avatar">{name}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
// Deterministic, locale-free date string.
|
||||
vi.mock("@/lib/time", () => ({
|
||||
formattedDate: () => "2026-06-21",
|
||||
}));
|
||||
|
||||
import HistoryItem from "./history-item";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatDraftAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
import type { IPageHistory } from "@/features/page-history/types/page.types";
|
||||
|
||||
function makeItem(overrides: Partial<IPageHistory> = {}): IPageHistory {
|
||||
return {
|
||||
id: "h1",
|
||||
pageId: "p1",
|
||||
title: "Title",
|
||||
slug: "slug",
|
||||
icon: "",
|
||||
coverPhoto: "",
|
||||
version: 1,
|
||||
lastUpdatedById: "u1",
|
||||
workspaceId: "w1",
|
||||
createdAt: "2026-06-21T00:00:00.000Z",
|
||||
updatedAt: "2026-06-21T00:00:00.000Z",
|
||||
lastUpdatedBy: { id: "u1", name: "Alice", avatarUrl: "" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderItem(item: IPageHistory) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<HistoryItem
|
||||
historyItem={item}
|
||||
index={0}
|
||||
onSelect={vi.fn()}
|
||||
isActive={false}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("HistoryItem git-sync provenance badge", () => {
|
||||
// Test 1: the git-sync badge renders ONLY for lastUpdatedSource === 'git-sync'.
|
||||
it("renders the Git sync badge only when lastUpdatedSource is 'git-sync'", () => {
|
||||
renderItem(makeItem({ lastUpdatedSource: "git-sync" }));
|
||||
expect(screen.getByText("Git sync")).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["agent", "agent"],
|
||||
["user", "user"],
|
||||
["undefined", undefined],
|
||||
])(
|
||||
"does NOT render the Git sync badge when lastUpdatedSource is %s",
|
||||
(_label, source) => {
|
||||
renderItem(makeItem({ lastUpdatedSource: source }));
|
||||
expect(screen.queryByText("Git sync")).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
// Test 2: provenance attribution + the git-sync badge is NOT interactive.
|
||||
it("attributes the git-sync provenance to the correct author and is not clickable", () => {
|
||||
renderItem(
|
||||
makeItem({
|
||||
lastUpdatedSource: "git-sync",
|
||||
lastUpdatedBy: { id: "u2", name: "Bob", avatarUrl: "" },
|
||||
}),
|
||||
);
|
||||
|
||||
const badge = screen.getByText("Git sync");
|
||||
|
||||
// Provenance attribution: the tooltip label carries the author name (the
|
||||
// git-sync badge passes authorName -> "Synced from Git on behalf of {{name}}").
|
||||
expect(screen.getByText("Synced from Git on behalf of Bob")).toBeTruthy();
|
||||
|
||||
// The git-sync badge must NOT behave like AiAgentBadge: the badge element
|
||||
// itself is not a button, carries no role=button and no tabIndex, and
|
||||
// clicking it must not trigger any ai-chat deep-link. (The surrounding
|
||||
// history-row IS an UnstyledButton — that is the row's own select affordance,
|
||||
// not the badge — so we scope these checks to the badge element.)
|
||||
const badgeRoot = (badge.closest("[class*='mantine-Badge-root']") ??
|
||||
badge) as HTMLElement;
|
||||
expect(badgeRoot.getAttribute("role")).not.toBe("button");
|
||||
expect(badgeRoot.getAttribute("tabindex")).toBeNull();
|
||||
expect(badgeRoot.tagName.toLowerCase()).not.toBe("button");
|
||||
// No interactive descendant button lives inside the badge itself.
|
||||
expect(within(badgeRoot).queryByRole("button")).toBeNull();
|
||||
|
||||
badgeRoot.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(setActiveChatId).not.toHaveBeenCalled();
|
||||
expect(setAiChatWindowOpen).not.toHaveBeenCalled();
|
||||
expect(setDraft).not.toHaveBeenCalled();
|
||||
expect(setHistoryModalOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Sanity contrast: the agent provenance IS interactive when it carries an
|
||||
// aiChatId — proving the not-clickable assertion above is real. The old text
|
||||
// `AiAgentBadge` was superseded by `AgentAvatarStack` (#300), which becomes a
|
||||
// role=button deep-link (and fires the ai-chat atoms) when an aiChatId is present.
|
||||
it("contrast: the agent stack is a deep-link button when it has an aiChatId", () => {
|
||||
renderItem(
|
||||
makeItem({
|
||||
lastUpdatedSource: "agent",
|
||||
agent: { name: "Zeta" },
|
||||
lastUpdatedAiChatId: "chat-1",
|
||||
}),
|
||||
);
|
||||
// The agent glyph lives inside the clickable stack; walk up to its role=button.
|
||||
const root = screen.getByTestId("agent-glyph").closest("[role='button']");
|
||||
expect(root).not.toBeNull();
|
||||
(root as HTMLElement).dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true }),
|
||||
);
|
||||
expect(setActiveChatId).toHaveBeenCalledWith("chat-1");
|
||||
expect(setAiChatWindowOpen).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { GitSyncBadge } from "@/components/ui/git-sync-badge.tsx";
|
||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
||||
import { formattedDate } from "@/lib/time";
|
||||
import classes from "./css/history.module.css";
|
||||
@@ -41,6 +42,7 @@ const HistoryItem = memo(function HistoryItem({
|
||||
const contributors = historyItem.contributors;
|
||||
const hasContributors = contributors && contributors.length > 0;
|
||||
const isAgentEdit = historyItem.lastUpdatedSource === "agent";
|
||||
const isGitSyncEdit = historyItem.lastUpdatedSource === "git-sync";
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
@@ -109,6 +111,10 @@ const HistoryItem = memo(function HistoryItem({
|
||||
onActivate={() => setHistoryModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isGitSyncEdit && (
|
||||
<GitSyncBadge authorName={historyItem.lastUpdatedBy?.name} />
|
||||
)}
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeAll,
|
||||
afterEach,
|
||||
} from "vitest";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// --- Mocks for the heavy / networked module graph ---------------------------
|
||||
// EditSpaceForm wires the "Enable Git sync" Switch to a TanStack-Query mutation
|
||||
// (useUpdateSpaceMutation). We mock ONLY that hook so the test fully controls
|
||||
// mutateAsync (resolve / reject) and isPending, and stub i18n. The real Mantine
|
||||
// Switch is rendered so the checkbox role / disabled state is meaningful.
|
||||
|
||||
// i18n: identity translator — labels stay as their English keys for queries.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
// Mutation hook: a controllable mutateAsync plus a togglable isPending.
|
||||
const mutateAsync = vi.fn();
|
||||
let isPending = false;
|
||||
vi.mock("@/features/space/queries/space-query.ts", () => ({
|
||||
useUpdateSpaceMutation: () => ({
|
||||
mutateAsync,
|
||||
get isPending() {
|
||||
return isPending;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// jsdom lacks matchMedia, which MantineProvider's color-scheme hook needs.
|
||||
beforeAll(() => {
|
||||
if (!window.matchMedia) {
|
||||
window.matchMedia = (query: string) =>
|
||||
({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}) as unknown as MediaQueryList;
|
||||
}
|
||||
});
|
||||
|
||||
import { EditSpaceForm } from "./edit-space-form";
|
||||
import type { ISpace } from "@/features/space/types/space.types.ts";
|
||||
|
||||
function makeSpace(overrides: Partial<ISpace> = {}): ISpace {
|
||||
return {
|
||||
id: "space-1",
|
||||
name: "Engineering",
|
||||
description: "",
|
||||
slug: "eng",
|
||||
hostname: "host",
|
||||
creatorId: "u1",
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-01-01"),
|
||||
...overrides,
|
||||
} as ISpace;
|
||||
}
|
||||
|
||||
function renderForm(props: { space: ISpace; readOnly?: boolean }) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<EditSpaceForm space={props.space} readOnly={props.readOnly} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// The form now renders TWO switches (git-sync enable + auto-merge-conflicts) in
|
||||
// that DOM order. Mantine renders each as an <input type="checkbox"
|
||||
// role="switch"> but does NOT expose its label as the accessible name, so we
|
||||
// disambiguate by DOM order (index 0 = enable, 1 = auto-merge) and assert the
|
||||
// human-readable label text is present alongside.
|
||||
function getToggle(): HTMLInputElement {
|
||||
screen.getByText("Enable Git sync");
|
||||
return screen.getAllByRole("switch")[0] as HTMLInputElement;
|
||||
}
|
||||
|
||||
function getAutoMergeToggle(): HTMLInputElement {
|
||||
screen.getByText("Auto-merge conflicts on push");
|
||||
return screen.getAllByRole("switch")[1] as HTMLInputElement;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mutateAsync.mockReset();
|
||||
isPending = false;
|
||||
});
|
||||
|
||||
describe("EditSpaceForm git-sync toggle", () => {
|
||||
// Test 3: initial checked state derives from settings.gitSync.enabled ?? false.
|
||||
it("derives initial checked state from space.settings.gitSync.enabled (true -> checked)", () => {
|
||||
renderForm({
|
||||
space: makeSpace({ settings: { gitSync: { enabled: true } } }),
|
||||
});
|
||||
expect(getToggle().checked).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults to unchecked when gitSync settings are missing", () => {
|
||||
renderForm({ space: makeSpace() });
|
||||
expect(getToggle().checked).toBe(false);
|
||||
});
|
||||
|
||||
// Test 4: toggling fires the mutation with { spaceId, gitSyncEnabled } and
|
||||
// optimistically flips the switch.
|
||||
it("fires the mutation with the correct payload and optimistically flips on", async () => {
|
||||
mutateAsync.mockResolvedValue(undefined);
|
||||
renderForm({ space: makeSpace() });
|
||||
|
||||
const toggle = getToggle();
|
||||
expect(toggle.checked).toBe(false);
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
// Optimistic update: the switch reflects the new state immediately.
|
||||
expect(toggle.checked).toBe(true);
|
||||
|
||||
expect(mutateAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
spaceId: "space-1",
|
||||
gitSyncEnabled: true,
|
||||
});
|
||||
|
||||
// Resolution leaves the toggle on.
|
||||
await waitFor(() => expect(toggle.checked).toBe(true));
|
||||
});
|
||||
|
||||
// Test 5: rollback on mutation error — the most valuable test.
|
||||
it("rolls back the toggle to its prior state when the mutation rejects", async () => {
|
||||
mutateAsync.mockRejectedValue(new Error("network"));
|
||||
renderForm({
|
||||
space: makeSpace({ settings: { gitSync: { enabled: false } } }),
|
||||
});
|
||||
|
||||
const toggle = getToggle();
|
||||
expect(toggle.checked).toBe(false);
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
// Optimistically flips on before the rejection lands.
|
||||
expect(toggle.checked).toBe(true);
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
spaceId: "space-1",
|
||||
gitSyncEnabled: true,
|
||||
});
|
||||
|
||||
// After the rejected promise settles, the component reverts to OFF so the
|
||||
// user is not misled into believing sync is enabled.
|
||||
await waitFor(() => expect(toggle.checked).toBe(false));
|
||||
});
|
||||
|
||||
// Test 6: disabled when readOnly and when the mutation is pending.
|
||||
it("disables the toggle when readOnly", () => {
|
||||
renderForm({ space: makeSpace(), readOnly: true });
|
||||
expect(getToggle().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("disables the toggle while the mutation is pending", () => {
|
||||
isPending = true;
|
||||
renderForm({ space: makeSpace() });
|
||||
expect(getToggle().disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EditSpaceForm auto-merge-conflicts toggle", () => {
|
||||
it("derives initial checked state from space.settings.gitSync.autoMergeConflicts (true -> checked)", () => {
|
||||
renderForm({
|
||||
space: makeSpace({
|
||||
settings: { gitSync: { autoMergeConflicts: true } },
|
||||
}),
|
||||
});
|
||||
expect(getAutoMergeToggle().checked).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults to unchecked when autoMergeConflicts is missing (SAFE default)", () => {
|
||||
renderForm({ space: makeSpace() });
|
||||
expect(getAutoMergeToggle().checked).toBe(false);
|
||||
});
|
||||
|
||||
it("fires the mutation with { spaceId, autoMergeConflicts } and optimistically flips on", async () => {
|
||||
mutateAsync.mockResolvedValue(undefined);
|
||||
renderForm({ space: makeSpace() });
|
||||
|
||||
const toggle = getAutoMergeToggle();
|
||||
expect(toggle.checked).toBe(false);
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
// Optimistic update.
|
||||
expect(toggle.checked).toBe(true);
|
||||
expect(mutateAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
spaceId: "space-1",
|
||||
autoMergeConflicts: true,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(toggle.checked).toBe(true));
|
||||
});
|
||||
|
||||
it("rolls back to its prior state when the mutation rejects", async () => {
|
||||
mutateAsync.mockRejectedValue(new Error("network"));
|
||||
renderForm({
|
||||
space: makeSpace({
|
||||
settings: { gitSync: { autoMergeConflicts: false } },
|
||||
}),
|
||||
});
|
||||
|
||||
const toggle = getAutoMergeToggle();
|
||||
expect(toggle.checked).toBe(false);
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(toggle.checked).toBe(true);
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
spaceId: "space-1",
|
||||
autoMergeConflicts: true,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(toggle.checked).toBe(false));
|
||||
});
|
||||
|
||||
it("disables the toggle when readOnly", () => {
|
||||
renderForm({ space: makeSpace(), readOnly: true });
|
||||
expect(getAutoMergeToggle().disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,14 @@
|
||||
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
|
||||
import React from "react";
|
||||
import {
|
||||
Group,
|
||||
Box,
|
||||
Button,
|
||||
TextInput,
|
||||
Stack,
|
||||
Textarea,
|
||||
Divider,
|
||||
Switch,
|
||||
} from "@mantine/core";
|
||||
import React, { useState } from "react";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { z } from "zod/v4";
|
||||
@@ -29,6 +38,37 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const updateSpaceMutation = useUpdateSpaceMutation();
|
||||
|
||||
const [gitSyncEnabled, setGitSyncEnabled] = useState<boolean>(
|
||||
space?.settings?.gitSync?.enabled ?? false,
|
||||
);
|
||||
|
||||
const [autoMergeConflicts, setAutoMergeConflicts] = useState<boolean>(
|
||||
space?.settings?.gitSync?.autoMergeConflicts ?? false,
|
||||
);
|
||||
|
||||
// One parameterized handler for both git-sync space toggles: they differ only by
|
||||
// the local state setter, the mutation payload field, and the error label. The
|
||||
// update is optimistic and reverts the local state on failure (the mutation
|
||||
// surfaces a toast via onError; the raw error is still logged per AGENTS.md).
|
||||
const handleToggle = async (
|
||||
field: "gitSyncEnabled" | "autoMergeConflicts",
|
||||
value: boolean,
|
||||
previous: boolean,
|
||||
setLocal: (next: boolean) => void,
|
||||
errorLabel: string,
|
||||
) => {
|
||||
setLocal(value); // optimistic update
|
||||
try {
|
||||
await updateSpaceMutation.mutateAsync({
|
||||
spaceId: space.id,
|
||||
[field]: value,
|
||||
});
|
||||
} catch (err) {
|
||||
setLocal(previous); // revert on failure
|
||||
console.error(errorLabel, err);
|
||||
}
|
||||
};
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
@@ -104,6 +144,43 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
|
||||
</Group>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<Switch
|
||||
label={t("Enable Git sync")}
|
||||
description={t("Sync this space's pages to a Git repository.")}
|
||||
checked={gitSyncEnabled}
|
||||
disabled={readOnly || updateSpaceMutation.isPending}
|
||||
onChange={(event) =>
|
||||
handleToggle(
|
||||
"gitSyncEnabled",
|
||||
event.currentTarget.checked,
|
||||
gitSyncEnabled,
|
||||
setGitSyncEnabled,
|
||||
"Failed to toggle git-sync for space",
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
mt="md"
|
||||
label={t("Auto-merge conflicts on push")}
|
||||
description={t(
|
||||
"When off (recommended), a page whose content still has unresolved Git conflict markers is skipped on push until you resolve the conflict in Git. When on, the markers are stripped and both sides' content is pushed.",
|
||||
)}
|
||||
checked={autoMergeConflicts}
|
||||
disabled={readOnly || updateSpaceMutation.isPending}
|
||||
onChange={(event) =>
|
||||
handleToggle(
|
||||
"autoMergeConflicts",
|
||||
event.currentTarget.checked,
|
||||
autoMergeConflicts,
|
||||
setAutoMergeConflicts,
|
||||
"Failed to toggle git-sync auto-merge-conflicts",
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -13,9 +13,15 @@ export interface ISpaceCommentsSettings {
|
||||
allowViewerComments?: boolean;
|
||||
}
|
||||
|
||||
export interface ISpaceGitSyncSettings {
|
||||
enabled?: boolean;
|
||||
autoMergeConflicts?: boolean;
|
||||
}
|
||||
|
||||
export interface ISpaceSettings {
|
||||
sharing?: ISpaceSharingSettings;
|
||||
comments?: ISpaceCommentsSettings;
|
||||
gitSync?: ISpaceGitSyncSettings;
|
||||
}
|
||||
|
||||
export interface ISpace {
|
||||
@@ -35,6 +41,8 @@ export interface ISpace {
|
||||
// for updates
|
||||
disablePublicSharing?: boolean;
|
||||
allowViewerComments?: boolean;
|
||||
gitSyncEnabled?: boolean;
|
||||
autoMergeConflicts?: boolean;
|
||||
}
|
||||
|
||||
interface IMembership {
|
||||
|
||||
-62
@@ -394,10 +394,6 @@ export default function AiProviderSettings() {
|
||||
useState<boolean>(
|
||||
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 [searchToggleLoading, setSearchToggleLoading] = useState(false);
|
||||
const [dictationToggleLoading, setDictationToggleLoading] = useState(false);
|
||||
@@ -407,8 +403,6 @@ export default function AiProviderSettings() {
|
||||
publicShareAssistantToggleLoading,
|
||||
setPublicShareAssistantToggleLoading,
|
||||
] = useState(false);
|
||||
const [autonomousRunsToggleLoading, setAutonomousRunsToggleLoading] =
|
||||
useState(false);
|
||||
|
||||
// Whether a key is currently stored server-side (drives the placeholder).
|
||||
const [hasApiKey, setHasApiKey] = useState(false);
|
||||
@@ -736,37 +730,6 @@ 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.
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
@@ -997,31 +960,6 @@ export default function AiProviderSettings() {
|
||||
{...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">
|
||||
<Button
|
||||
variant="default"
|
||||
|
||||
@@ -26,9 +26,6 @@ export interface IWorkspace {
|
||||
aiDictation?: boolean;
|
||||
aiDictationStreaming?: boolean;
|
||||
aiPublicShareAssistant?: boolean;
|
||||
// Write-only field for updateWorkspace({ autonomousRuns }). Read state lives at
|
||||
// settings.ai.autonomousRuns.
|
||||
autonomousRuns?: boolean;
|
||||
trashRetentionDays?: number;
|
||||
// Default lifetime (HOURS) for new temporary notes; frozen per-note at creation.
|
||||
temporaryNoteHours?: number;
|
||||
@@ -68,9 +65,6 @@ export interface IWorkspaceAiSettings {
|
||||
dictation?: boolean;
|
||||
dictationStreaming?: 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 {
|
||||
|
||||
@@ -47,13 +47,6 @@ export function isCompactPageTreeEnabled(): boolean {
|
||||
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(
|
||||
avatarUrl: string,
|
||||
type: AvatarIconType = AvatarIconType.AVATAR,
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
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,7 +22,6 @@ import {
|
||||
isPostHogEnabled,
|
||||
} from "@/lib/config.ts";
|
||||
import posthog from "posthog-js";
|
||||
import { initVitals } from "@/lib/telemetry/vitals";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -44,10 +43,6 @@ 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 root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
|
||||
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"pretest": "pnpm --filter @docmost/editor-ext build",
|
||||
"pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/prosemirror-markdown build && pnpm --filter @docmost/git-sync build && pnpm --filter @docmost/mcp build",
|
||||
"test": "jest",
|
||||
"test:int": "jest --config test/jest-integration.json",
|
||||
"test:watch": "jest --watch",
|
||||
@@ -41,6 +41,7 @@
|
||||
"@aws-sdk/s3-request-presigner": "3.1050.0",
|
||||
"@azure/storage-blob": "12.31.0",
|
||||
"@clickhouse/client": "^1.18.2",
|
||||
"@docmost/git-sync": "workspace:*",
|
||||
"@docmost/mcp": "workspace:*",
|
||||
"@docmost/pdf-inspector": "1.9.6",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
@@ -111,7 +112,6 @@
|
||||
"pino-pretty": "^13.1.3",
|
||||
"postgres": "^3.4.8",
|
||||
"postmark": "^4.0.7",
|
||||
"prom-client": "^15.1.3",
|
||||
"react": "^18.3.1",
|
||||
"react-email": "6.0.8",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
@@ -190,7 +190,12 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
||||
"^.+\\.(t|j)sx?$": [
|
||||
"ts-jest",
|
||||
{
|
||||
"isolatedModules": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0)(@|/))"
|
||||
@@ -200,11 +205,18 @@
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node",
|
||||
"setupFiles": [
|
||||
"<rootDir>/../test/jest.setup.ts"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
||||
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
||||
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
|
||||
"^src/(.*)$": "<rootDir>/$1"
|
||||
"^src/(.*)$": "<rootDir>/$1",
|
||||
"^@docmost/git-sync$": "<rootDir>/../../../packages/git-sync/src/index.ts",
|
||||
"^@docmost/git-sync/(.*)$": "<rootDir>/../../../packages/git-sync/src/$1",
|
||||
"^@docmost/prosemirror-markdown$": "<rootDir>/../../../packages/prosemirror-markdown/src/index.ts",
|
||||
"^(\\.{1,2}/.*)\\.js$": "$1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,11 +28,10 @@ import { ClsModule } from 'nestjs-cls';
|
||||
import { NoopAuditModule } from './integrations/audit/audit.module';
|
||||
import { ThrottleModule } from './integrations/throttle/throttle.module';
|
||||
import { McpModule } from './integrations/mcp/mcp.module';
|
||||
import { GitSyncModule } from './integrations/git-sync/git-sync.module';
|
||||
import { SandboxModule } from './integrations/sandbox/sandbox.module';
|
||||
import { AiModule } from './integrations/ai/ai.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 = [];
|
||||
try {
|
||||
@@ -92,13 +91,10 @@ try {
|
||||
TelemetryModule,
|
||||
ThrottleModule,
|
||||
McpModule,
|
||||
GitSyncModule,
|
||||
SandboxModule,
|
||||
AiModule,
|
||||
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,
|
||||
],
|
||||
controllers: [AppController],
|
||||
|
||||
@@ -188,6 +188,45 @@ export class CollaborationGateway {
|
||||
return this.hocuspocus.openDirectConnection(documentName, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a git-originated body into a page, applying the merge on the instance
|
||||
* that OWNS the live Y.Doc so a connected editor CONVERGES on the change.
|
||||
*
|
||||
* git-sync must NOT use openDirectConnection directly for this: that opens the
|
||||
* document on whichever instance/process runs git-sync (the API/worker). When
|
||||
* an editor is connected to a DIFFERENT collab instance/process, that is a
|
||||
* SEPARATE, detached Y.Doc — the merge lands in the detached doc and the DB,
|
||||
* but the live editor never receives the Yjs update; its next debounced
|
||||
* autosave then overwrites the DB with its stale state and SILENTLY REVERTS
|
||||
* the git change (the data-loss bug). Routing through the custom-event channel
|
||||
* runs the merge on the owning instance's shared Document, whose update is
|
||||
* broadcast to every connection (handleUpdate), so the editor's CRDT converges
|
||||
* on the merged result.
|
||||
*
|
||||
* Without redis there is a single instance, so the write runs locally — which
|
||||
* is already the owning (and only) instance the editor is connected to.
|
||||
*/
|
||||
async writePageBody(
|
||||
documentName: string,
|
||||
payload: {
|
||||
prosemirrorJson: unknown;
|
||||
baseProsemirrorJson?: unknown;
|
||||
userId: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (this.redisSync) {
|
||||
await this.handleYjsEvent(
|
||||
'gitSyncWriteBody',
|
||||
documentName,
|
||||
payload as any,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await this.collabEventsService
|
||||
.getHandlers(this.hocuspocus)
|
||||
.gitSyncWriteBody(documentName, payload as any);
|
||||
}
|
||||
|
||||
/*
|
||||
*Can be used before calling openDirectConnection directly
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
// Exercises the REAL `gitSyncWriteBody` collab handler (the owner-routed body
|
||||
// write the data-loss fix introduces). The handler imports the editor graph via
|
||||
// collaboration.util / yjs.util (tiptapExtensions -> editor-ext -> react-dom,
|
||||
// unloadable under jest's node env, same coupling noted in
|
||||
// gitmost-datasource.service.spec.ts), so we stub those + the transformer. The
|
||||
// stubbed toYdoc builds paragraph blocks straight from the ProseMirror JSON so
|
||||
// we can assert convergence on real text.
|
||||
jest.mock('./collaboration.util', () => ({
|
||||
tiptapExtensions: [],
|
||||
getPageId: (name: string) => name.replace(/^page\./, ''),
|
||||
prosemirrorNodeToYElement: jest.fn(),
|
||||
}));
|
||||
jest.mock('./yjs.util', () => ({
|
||||
setYjsMark: jest.fn(),
|
||||
updateYjsMarkAttribute: jest.fn(),
|
||||
}));
|
||||
jest.mock('@hocuspocus/transformer', () => {
|
||||
const Yjs = require('yjs');
|
||||
return {
|
||||
TiptapTransformer: {
|
||||
toYdoc: (json: any) => {
|
||||
if (json?.__throw) throw new Error('boom: malformed doc');
|
||||
const d = new Yjs.Doc();
|
||||
const frag = d.getXmlFragment('default');
|
||||
const blocks = (json?.content ?? []).map((node: any) => {
|
||||
const el = new Yjs.XmlElement(node.type || 'paragraph');
|
||||
const text = (node.content ?? [])
|
||||
.map((t: any) => t.text ?? '')
|
||||
.join('');
|
||||
const t = new Yjs.XmlText();
|
||||
if (text) t.insert(0, text);
|
||||
el.insert(0, [t]);
|
||||
return el;
|
||||
});
|
||||
if (blocks.length) frag.insert(0, blocks);
|
||||
return d;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import * as Y from 'yjs';
|
||||
import { CollaborationHandler } from './collaboration.handler';
|
||||
|
||||
const pmDoc = (...paras: string[]) => ({
|
||||
type: 'doc',
|
||||
content: paras.map((text) => ({
|
||||
type: 'paragraph',
|
||||
content: text ? [{ type: 'text', text }] : [],
|
||||
})),
|
||||
});
|
||||
|
||||
const texts = (frag: Y.XmlFragment): string[] =>
|
||||
frag.toArray().map((el) =>
|
||||
(el as Y.XmlElement)
|
||||
.toArray()
|
||||
.map((c) => (c as Y.XmlText).toString())
|
||||
.join(''),
|
||||
);
|
||||
|
||||
// Build a fake Hocuspocus whose openDirectConnection yields a DirectConnection
|
||||
// over a REAL shared Document, with a connected "editor" doc that receives the
|
||||
// shared doc's updates (modelling Document.handleUpdate's broadcast on the
|
||||
// OWNING instance). Initial content carries live block ids; the editor starts
|
||||
// fully synced with the shared doc.
|
||||
function fakeHocuspocus(initial: { text: string; id: string }[]) {
|
||||
const shared = new Y.Doc();
|
||||
const frag = shared.getXmlFragment('default');
|
||||
shared.transact(() => {
|
||||
frag.insert(
|
||||
0,
|
||||
initial.map((s) => {
|
||||
const el = new Y.XmlElement('paragraph');
|
||||
el.setAttribute('id', s.id);
|
||||
const t = new Y.XmlText();
|
||||
if (s.text) t.insert(0, s.text);
|
||||
el.insert(0, [t]);
|
||||
return el;
|
||||
}),
|
||||
);
|
||||
});
|
||||
const editor = new Y.Doc();
|
||||
Y.applyUpdate(editor, Y.encodeStateAsUpdate(shared));
|
||||
// Broadcast relay: server-originated updates flow to the connected editor.
|
||||
shared.on('update', (u: Uint8Array, origin: any) => {
|
||||
if (origin !== 'editor') Y.applyUpdate(editor, u, 'server');
|
||||
});
|
||||
|
||||
const openDirectConnection = jest.fn(async () => ({
|
||||
// DirectConnection.transact runs the fn directly against the Document (no
|
||||
// wrapping Y transaction), exactly like @hocuspocus/server.
|
||||
transact: async (fn: (doc: Y.Doc) => void) => fn(shared),
|
||||
disconnect: jest.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
return { hocuspocus: { openDirectConnection } as any, shared, editor };
|
||||
}
|
||||
|
||||
describe('CollaborationHandler.gitSyncWriteBody (owner-routed body write)', () => {
|
||||
it('converges a connected editor on the git change (no silent revert)', async () => {
|
||||
const { hocuspocus, shared, editor } = fakeHocuspocus([
|
||||
{ text: 'alpha', id: 'p1' },
|
||||
{ text: 'beta', id: 'p2' },
|
||||
]);
|
||||
const handler = new CollaborationHandler();
|
||||
const handlers = handler.getHandlers(hocuspocus);
|
||||
|
||||
// git changed block 1 beta -> beta2; base is the pre-change content.
|
||||
await handlers.gitSyncWriteBody('page.x', {
|
||||
prosemirrorJson: pmDoc('alpha', 'beta2'),
|
||||
baseProsemirrorJson: pmDoc('alpha', 'beta'),
|
||||
userId: 'svc-user',
|
||||
});
|
||||
|
||||
// The shared (owning-instance) doc holds the merge...
|
||||
expect(texts(shared.getXmlFragment('default'))).toEqual(['alpha', 'beta2']);
|
||||
// ...and the connected editor CONVERGED via the broadcast (the bug would
|
||||
// leave it on 'beta' and revert the page on its next autosave).
|
||||
expect(texts(editor.getXmlFragment('default'))).toEqual(['alpha', 'beta2']);
|
||||
});
|
||||
|
||||
it('preserves a concurrent edit to a DIFFERENT block (3-way, finding #2)', async () => {
|
||||
const { hocuspocus, shared, editor } = fakeHocuspocus([
|
||||
{ text: 'alpha', id: 'p1' },
|
||||
{ text: 'beta', id: 'p2' },
|
||||
]);
|
||||
// The editor is actively editing block 0 while the push arrives.
|
||||
const eFrag = editor.getXmlFragment('default');
|
||||
editor.transact(
|
||||
() => (eFrag.get(0) as Y.XmlElement).get(0) instanceof Y.XmlText &&
|
||||
((eFrag.get(0) as Y.XmlElement).get(0) as Y.XmlText).insert(5, ' EDIT'),
|
||||
'editor',
|
||||
);
|
||||
Y.applyUpdate(shared, Y.encodeStateAsUpdate(editor), 'editor');
|
||||
|
||||
const handler = new CollaborationHandler();
|
||||
await handler.getHandlers(hocuspocus).gitSyncWriteBody('page.x', {
|
||||
prosemirrorJson: pmDoc('alpha', 'beta2'),
|
||||
baseProsemirrorJson: pmDoc('alpha', 'beta'),
|
||||
userId: 'svc-user',
|
||||
});
|
||||
|
||||
// Human's block-0 edit AND git's block-1 change both survive on the editor.
|
||||
expect(texts(editor.getXmlFragment('default'))).toEqual([
|
||||
'alpha EDIT',
|
||||
'beta2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('FLUSHES the pending debounced store BEFORE merging so an in-flight edit survives (finding #2)', async () => {
|
||||
// QA #119 finding #2: the 3-way merge must run against the latest live-doc
|
||||
// state. A concurrent UI edit that is still in-flight (the store is debounced)
|
||||
// must be drained into the live doc BEFORE git merges, or git clean-applies and
|
||||
// the edit is silently dropped — even on a DIFFERENT block. Model the drain via
|
||||
// the pending-store flush: when it runs, the in-flight block-0 edit lands.
|
||||
const shared = new Y.Doc();
|
||||
const frag = shared.getXmlFragment('default');
|
||||
shared.transact(() => {
|
||||
frag.insert(
|
||||
0,
|
||||
[
|
||||
{ text: 'alpha', id: 'p1' },
|
||||
{ text: 'beta', id: 'p2' },
|
||||
].map((s) => {
|
||||
const el = new Y.XmlElement('paragraph');
|
||||
el.setAttribute('id', s.id);
|
||||
const t = new Y.XmlText();
|
||||
t.insert(0, s.text);
|
||||
el.insert(0, [t]);
|
||||
return el;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const order: string[] = [];
|
||||
const debouncer = {
|
||||
isDebounced: jest.fn(() => true),
|
||||
executeNow: jest.fn(async () => {
|
||||
order.push('flush');
|
||||
// The in-flight client edit to block 0 only lands once the pending store
|
||||
// is flushed (i.e. the event loop is drained) — BEFORE the merge.
|
||||
shared.transact(() =>
|
||||
((frag.get(0) as Y.XmlElement).get(0) as Y.XmlText).insert(5, ' EDIT'),
|
||||
);
|
||||
}),
|
||||
};
|
||||
const openDirectConnection = jest.fn(async () => ({
|
||||
transact: async (fn: (doc: Y.Doc) => void) => {
|
||||
order.push('merge');
|
||||
fn(shared);
|
||||
},
|
||||
disconnect: jest.fn(async () => undefined),
|
||||
}));
|
||||
const hocuspocus = { openDirectConnection, debouncer } as any;
|
||||
|
||||
const handler = new CollaborationHandler();
|
||||
await handler.getHandlers(hocuspocus).gitSyncWriteBody('page.x', {
|
||||
prosemirrorJson: pmDoc('alpha', 'beta2'), // git changes block 1
|
||||
baseProsemirrorJson: pmDoc('alpha', 'beta'),
|
||||
userId: 'svc-user',
|
||||
});
|
||||
|
||||
// The flush ran, and it ran BEFORE the merge transaction.
|
||||
expect(debouncer.executeNow).toHaveBeenCalledTimes(1);
|
||||
expect(order).toEqual(['flush', 'merge']);
|
||||
// Both the in-flight block-0 edit and git's block-1 change survive — the
|
||||
// pre-flush bug would have produced ['alpha', 'beta2'] (UI edit dropped).
|
||||
expect(texts(shared.getXmlFragment('default'))).toEqual([
|
||||
'alpha EDIT',
|
||||
'beta2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not flush when no store is pending (isDebounced false)', async () => {
|
||||
const { hocuspocus, shared } = fakeHocuspocus([{ text: 'a', id: 'p1' }]);
|
||||
const executeNow = jest.fn();
|
||||
(hocuspocus as any).debouncer = {
|
||||
isDebounced: jest.fn(() => false),
|
||||
executeNow,
|
||||
};
|
||||
const handler = new CollaborationHandler();
|
||||
await handler.getHandlers(hocuspocus).gitSyncWriteBody('page.x', {
|
||||
prosemirrorJson: pmDoc('a', 'b'),
|
||||
userId: 'svc-user',
|
||||
});
|
||||
expect(executeNow).not.toHaveBeenCalled();
|
||||
expect(texts(shared.getXmlFragment('default'))).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('crash-safe: a transform failure never opens the connection or mutates the live doc', async () => {
|
||||
const { hocuspocus, shared } = fakeHocuspocus([{ text: 'alpha', id: 'p1' }]);
|
||||
const before = texts(shared.getXmlFragment('default'));
|
||||
const handler = new CollaborationHandler();
|
||||
|
||||
await expect(
|
||||
handler.getHandlers(hocuspocus).gitSyncWriteBody('page.x', {
|
||||
prosemirrorJson: { __throw: true } as any,
|
||||
userId: 'svc-user',
|
||||
}),
|
||||
).rejects.toThrow('boom');
|
||||
|
||||
// The incoming doc is built BEFORE opening the connection, so the throw
|
||||
// happens first: the live doc is untouched and no connection was opened.
|
||||
expect(hocuspocus.openDirectConnection).not.toHaveBeenCalled();
|
||||
expect(texts(shared.getXmlFragment('default'))).toEqual(before);
|
||||
});
|
||||
|
||||
it('falls back to a 2-way merge when no base is supplied', async () => {
|
||||
const { hocuspocus, shared, editor } = fakeHocuspocus([
|
||||
{ text: 'alpha', id: 'p1' },
|
||||
]);
|
||||
const handler = new CollaborationHandler();
|
||||
|
||||
await handler.getHandlers(hocuspocus).gitSyncWriteBody('page.x', {
|
||||
prosemirrorJson: pmDoc('alpha', 'gamma'),
|
||||
userId: 'svc-user',
|
||||
});
|
||||
|
||||
expect(texts(shared.getXmlFragment('default'))).toEqual(['alpha', 'gamma']);
|
||||
expect(texts(editor.getXmlFragment('default'))).toEqual(['alpha', 'gamma']);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
} from './yjs.util';
|
||||
import * as Y from 'yjs';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import {
|
||||
mergeXmlFragments,
|
||||
mergeXmlFragments3WayWithStats,
|
||||
} from './merge/yjs-body-merge';
|
||||
|
||||
export type CollabEventHandlers = ReturnType<
|
||||
CollaborationHandler['getHandlers']
|
||||
@@ -181,9 +185,130 @@ export class CollaborationHandler {
|
||||
},
|
||||
);
|
||||
},
|
||||
/**
|
||||
* Git-sync body write, applied as a block-level MERGE into the LIVE doc on
|
||||
* the instance that OWNS it (routed here via the custom-event channel —
|
||||
* see CollaborationGateway.writePageBody). Running on the owning instance
|
||||
* is what makes a connected editor CONVERGE: the merge mutates the shared
|
||||
* Document, whose update is broadcast to every connection, so the editor's
|
||||
* CRDT applies the git change instead of silently reverting it on its next
|
||||
* autosave (the data-loss bug this fixes).
|
||||
*
|
||||
* With a `baseProsemirrorJson` (the last-synced common ancestor) it does a
|
||||
* THREE-WAY merge — a block only the human changed is kept, a block only
|
||||
* git changed is taken (conflicts -> git). Without a base it falls back to
|
||||
* the 2-way merge.
|
||||
*/
|
||||
gitSyncWriteBody: async (
|
||||
documentName: string,
|
||||
payload: {
|
||||
prosemirrorJson: any;
|
||||
baseProsemirrorJson?: any;
|
||||
userId: string;
|
||||
},
|
||||
) => {
|
||||
const { prosemirrorJson, baseProsemirrorJson, userId } = payload;
|
||||
|
||||
// Build the incoming (and base) Yjs docs BEFORE opening the connection /
|
||||
// touching the live doc. If a transform throws (a malformed/unsupported
|
||||
// doc) we must NOT have mutated the live body — otherwise a conversion
|
||||
// failure could leave the page empty (crash-safe conversion).
|
||||
const targetDoc = TiptapTransformer.toYdoc(
|
||||
prosemirrorJson,
|
||||
'default',
|
||||
tiptapExtensions,
|
||||
);
|
||||
const baseDoc =
|
||||
baseProsemirrorJson != null
|
||||
? TiptapTransformer.toYdoc(
|
||||
baseProsemirrorJson,
|
||||
'default',
|
||||
tiptapExtensions,
|
||||
)
|
||||
: null;
|
||||
|
||||
// CONCURRENT-EDIT FLUSH (QA #119, finding #2). The 3-way merge below runs
|
||||
// against the LIVE Y.Doc, so a concurrent UI edit is only preserved if it
|
||||
// is already part of that doc. A user's edit is debounced before it lands
|
||||
// (the editor batches; the collab store is debounced up to 10s), so the
|
||||
// merge could otherwise run against a PRE-EDIT doc: git would then
|
||||
// clean-apply (no same-block conflict detected) and the in-flight UI edit
|
||||
// — even on a DIFFERENT block — would be silently dropped.
|
||||
//
|
||||
// Flushing the pending debounced store here (a) drains the event loop so a
|
||||
// just-arrived client Yjs update is applied to the live doc BEFORE we
|
||||
// merge, and (b) persists the live doc so the merge baseline is current
|
||||
// even on the doc-reload-from-DB path. After the flush the merge sees the
|
||||
// latest state, so an edit on a different block is MERGED (not overwritten)
|
||||
// and a genuine same-block edit is detected as a conflict -> the
|
||||
// boundary-snapshot in PersistenceExtension pins it to page history
|
||||
// (recoverable) instead of vanishing silently.
|
||||
await this.flushPendingStore(hocuspocus, documentName);
|
||||
|
||||
// actor:'git-sync' + the service user flow into PersistenceExtension
|
||||
// (lastUpdatedSource='git-sync', lastUpdatedById=userId).
|
||||
await this.withYdocConnection(
|
||||
hocuspocus,
|
||||
documentName,
|
||||
{ actor: 'git-sync', user: { id: userId } },
|
||||
(doc) => {
|
||||
const liveFrag = doc.getXmlFragment('default');
|
||||
const targetFrag = targetDoc.getXmlFragment('default');
|
||||
if (baseDoc) {
|
||||
const { conflicts } = mergeXmlFragments3WayWithStats(
|
||||
liveFrag,
|
||||
targetFrag,
|
||||
baseDoc.getXmlFragment('default'),
|
||||
);
|
||||
// SAME-BLOCK conflict contract (SPEC §9): a block both the human
|
||||
// and git changed resolves to GIT (deterministic). Make that
|
||||
// OBSERVABLE rather than silent — log it. The losing human content
|
||||
// is NOT destroyed: the persistence extension's boundary snapshot
|
||||
// pins the pre-merge page state to history on this user->git-sync
|
||||
// transition, so it stays recoverable.
|
||||
if (conflicts > 0) {
|
||||
this.logger.warn(
|
||||
`git-sync merge for ${documentName}: ${conflicts} same-block ` +
|
||||
`conflict(s) resolved to the git version; the prior page ` +
|
||||
`state is preserved in page history (recoverable).`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
mergeXmlFragments(liveFrag, targetFrag);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush any pending DEBOUNCED store for `documentName` so the live Y.Doc and the
|
||||
* DB are current BEFORE a git-sync merge reads them (QA #119, finding #2 —
|
||||
* concurrent UI edit silently lost). Mirrors the PersistenceExtension.onDisconnect
|
||||
* flush: only acts when a store is actually pending (`isDebounced`), runs the
|
||||
* SAME scheduled payload (`executeNow`, preserving the edit's context/actor), and
|
||||
* never throws — a flush failure must not abort the git-sync write. Awaiting it
|
||||
* also drains the event loop, so a client Yjs update sitting in the socket buffer
|
||||
* is applied to the live doc before the merge transaction runs.
|
||||
*/
|
||||
private async flushPendingStore(
|
||||
hocuspocus: Hocuspocus,
|
||||
documentName: string,
|
||||
): Promise<void> {
|
||||
const debounceId = `onStoreDocument-${documentName}`;
|
||||
try {
|
||||
const debouncer = (hocuspocus as any)?.debouncer;
|
||||
if (!debouncer?.isDebounced?.(debounceId)) return;
|
||||
await debouncer.executeNow(debounceId);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`git-sync pre-merge flush failed for ${documentName}: ` +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async withYdocConnection<T>(
|
||||
hocuspocus: Hocuspocus,
|
||||
documentName: string,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { PersistenceExtension } from './persistence.extension';
|
||||
|
||||
/**
|
||||
* Regression for the QA #119 "loss-on-fast-close" data loss: editing a page then
|
||||
* closing the tab within the collab debounce window (~3-18s) lost the edit
|
||||
* because, with `unloadImmediately: false`, Hocuspocus does NOT flush the
|
||||
* debounced onStoreDocument on a last-client disconnect. PersistenceExtension
|
||||
* now flushes the pending store on the LAST disconnect (and only then).
|
||||
*/
|
||||
describe('PersistenceExtension.onDisconnect flush (loss-on-fast-close)', () => {
|
||||
function makeExt(): PersistenceExtension {
|
||||
// onDisconnect touches none of the injected deps; pass casts.
|
||||
return new PersistenceExtension(
|
||||
null as any,
|
||||
null as any,
|
||||
null as any,
|
||||
null as any,
|
||||
null as any,
|
||||
null as any,
|
||||
null as any,
|
||||
null as any,
|
||||
);
|
||||
}
|
||||
|
||||
function makeData(opts: {
|
||||
clientsCount: number;
|
||||
isDebounced: boolean;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const executeNow = jest.fn(async () => undefined);
|
||||
const isDebounced = jest.fn(() => opts.isDebounced);
|
||||
return {
|
||||
executeNow,
|
||||
isDebounced,
|
||||
payload: {
|
||||
clientsCount: opts.clientsCount,
|
||||
context: {},
|
||||
document: { isLoading: opts.isLoading ?? false } as any,
|
||||
documentName: 'page.abc',
|
||||
instance: { debouncer: { isDebounced, executeNow } } as any,
|
||||
requestHeaders: {},
|
||||
requestParameters: new URLSearchParams(),
|
||||
socketId: 's',
|
||||
} as any,
|
||||
};
|
||||
}
|
||||
|
||||
it('flushes the pending store when the LAST client disconnects', async () => {
|
||||
const ext = makeExt();
|
||||
const { executeNow, payload } = makeData({
|
||||
clientsCount: 0,
|
||||
isDebounced: true,
|
||||
});
|
||||
await ext.onDisconnect(payload);
|
||||
expect(executeNow).toHaveBeenCalledTimes(1);
|
||||
expect(executeNow).toHaveBeenCalledWith('onStoreDocument-page.abc');
|
||||
});
|
||||
|
||||
it('does NOT flush while other editors remain connected', async () => {
|
||||
const ext = makeExt();
|
||||
const { executeNow, payload } = makeData({
|
||||
clientsCount: 2,
|
||||
isDebounced: true,
|
||||
});
|
||||
await ext.onDisconnect(payload);
|
||||
expect(executeNow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT write when nothing is pending (already persisted)', async () => {
|
||||
const ext = makeExt();
|
||||
const { executeNow, payload } = makeData({
|
||||
clientsCount: 0,
|
||||
isDebounced: false,
|
||||
});
|
||||
await ext.onDisconnect(payload);
|
||||
expect(executeNow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT flush a doc that is still loading (load error guard)', async () => {
|
||||
const ext = makeExt();
|
||||
const { executeNow, payload } = makeData({
|
||||
clientsCount: 0,
|
||||
isDebounced: true,
|
||||
isLoading: true,
|
||||
});
|
||||
await ext.onDisconnect(payload);
|
||||
expect(executeNow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
// Stub collaboration.util so importing the extension does not drag in the
|
||||
// editor-ext -> @tiptap/react -> react-dom graph (unloadable under jest's node
|
||||
// env, same coupling the gitmost-datasource / mcp specs document). The
|
||||
// extension only calls getPageId, jsonToText and isEmptyParagraphDoc from it on
|
||||
// the store path; tiptapExtensions is unused by onStoreDocument.
|
||||
jest.mock('../collaboration.util', () => ({
|
||||
tiptapExtensions: [],
|
||||
getPageId: (name: string) => name.replace(/^page\./, ''),
|
||||
jsonToText: () => 'text',
|
||||
isEmptyParagraphDoc: () => false,
|
||||
// The post-write mention extraction walks the doc via jsonToNode().descendants;
|
||||
// return a node-like stub with no descendants so no mentions are produced
|
||||
// (mention handling is out of scope here — we only assert provenance).
|
||||
jsonToNode: () => ({ descendants: () => undefined }),
|
||||
}));
|
||||
|
||||
// Control the Yjs<->JSON bridge: fromYdoc returns the "incoming" doc the writer
|
||||
// is storing. We keep it distinct from the page's persisted content so the
|
||||
// no-op guard (isDeepStrictEqual) never short-circuits the write.
|
||||
const INCOMING_JSON = { type: 'doc', content: [{ type: 'paragraph' }, { t: 1 }] };
|
||||
jest.mock('@hocuspocus/transformer', () => ({
|
||||
TiptapTransformer: {
|
||||
fromYdoc: jest.fn(() => INCOMING_JSON),
|
||||
toYdoc: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Run the executeTx callback inline with a passthrough trx.
|
||||
jest.mock('@docmost/db/utils', () => ({
|
||||
executeTx: jest.fn(async (_db: any, cb: any) => cb({} as any)),
|
||||
}));
|
||||
|
||||
import * as Y from 'yjs';
|
||||
import { PersistenceExtension } from './persistence.extension';
|
||||
import {
|
||||
onChangePayload,
|
||||
onStoreDocumentPayload,
|
||||
} from '@hocuspocus/server';
|
||||
|
||||
/**
|
||||
* Provenance-precedence coverage for PersistenceExtension.onStoreDocument
|
||||
* (test-strategy Module 4 / item #2): the contract `agent > git-sync > user`,
|
||||
* plus the negative that a git-sync store does NOT pin a boundary history
|
||||
* snapshot. We drive the precedence through the real public method (onChange to
|
||||
* arm the sticky agent marker, then onStoreDocument), mocking the repos / db /
|
||||
* Yjs bridge so no real database or collab server is needed. The store's
|
||||
* persisted `lastUpdatedSource` and the saveHistory call are the observable
|
||||
* outputs.
|
||||
*/
|
||||
describe('PersistenceExtension.onStoreDocument — provenance precedence (#2)', () => {
|
||||
const DOCUMENT_NAME = 'page.page-1';
|
||||
const PAGE_ID = 'page-1';
|
||||
|
||||
// `page.content` differs from INCOMING_JSON so the write is never skipped.
|
||||
const persistedPage = (overrides?: { lastUpdatedSource?: string }) => ({
|
||||
id: PAGE_ID,
|
||||
slugId: 'slug-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
creatorId: 'creator-1',
|
||||
contributorIds: ['creator-1'],
|
||||
content: { type: 'doc', content: [{ type: 'paragraph', content: [] }] },
|
||||
lastUpdatedSource: overrides?.lastUpdatedSource ?? 'user',
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const build = (pageOverrides?: { lastUpdatedSource?: string }) => {
|
||||
const pageRepo = {
|
||||
findById: jest.fn().mockResolvedValue(persistedPage(pageOverrides)),
|
||||
updatePage: jest.fn().mockResolvedValue({ numUpdatedRows: 1n }),
|
||||
};
|
||||
const pageHistoryRepo = {
|
||||
// No prior snapshot -> humanBaselineMissing is true, so the ONLY thing
|
||||
// gating the boundary snapshot in these tests is the source precedence.
|
||||
findPageLastHistory: jest.fn().mockResolvedValue(null),
|
||||
saveHistory: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
const historyQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
const notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
const collabHistory = {
|
||||
addContributors: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const transclusionService = {
|
||||
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
|
||||
syncPageReferences: jest.fn().mockResolvedValue(undefined),
|
||||
syncPageTemplateReferences: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const ext = new PersistenceExtension(
|
||||
pageRepo as any,
|
||||
pageHistoryRepo as any,
|
||||
{} as any, // db
|
||||
aiQueue as any,
|
||||
historyQueue as any,
|
||||
notificationQueue as any,
|
||||
collabHistory as any,
|
||||
transclusionService as any,
|
||||
);
|
||||
|
||||
return { ext, pageRepo, pageHistoryRepo, historyQueue };
|
||||
};
|
||||
|
||||
// A real Y.Doc is required for Y.encodeStateAsUpdate(document); broadcastStateless
|
||||
// is a no-op spy. The fromYdoc bridge is mocked, so the doc's contents are
|
||||
// irrelevant to the JSON path.
|
||||
const makeStorePayload = (context: any): onStoreDocumentPayload =>
|
||||
({
|
||||
documentName: DOCUMENT_NAME,
|
||||
document: Object.assign(new Y.Doc(), {
|
||||
broadcastStateless: jest.fn(),
|
||||
}),
|
||||
context,
|
||||
}) as any;
|
||||
|
||||
const makeChangePayload = (actor: string): onChangePayload =>
|
||||
({
|
||||
documentName: DOCUMENT_NAME,
|
||||
context: { user: { id: 'user-1' }, actor },
|
||||
}) as any;
|
||||
|
||||
const sourceOf = (pageRepo: { updatePage: jest.Mock }) =>
|
||||
pageRepo.updatePage.mock.calls[0][0].lastUpdatedSource;
|
||||
|
||||
it("tags 'user' for a plain write (no agent touch, no git-sync actor)", async () => {
|
||||
const { ext, pageRepo } = build();
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'user-1' }, actor: 'user' }),
|
||||
);
|
||||
|
||||
expect(sourceOf(pageRepo)).toBe('user');
|
||||
});
|
||||
|
||||
it("tags 'git-sync' when the writer's actor is 'git-sync' and no agent touched the window", async () => {
|
||||
const { ext, pageRepo } = build();
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'svc-user' }, actor: 'git-sync' }),
|
||||
);
|
||||
|
||||
expect(sourceOf(pageRepo)).toBe('git-sync');
|
||||
});
|
||||
|
||||
it("keeps 'git-sync' for an explicit git-sync store even with a sticky agent marker (#14 loop-guard)", async () => {
|
||||
const { ext, pageRepo } = build();
|
||||
|
||||
// An agent edit landed earlier in the coalescing window (sticky marker),
|
||||
// then a git-sync writer performs the store. Red-team finding #14: an
|
||||
// EXPLICIT current-write actor is authoritative for THIS write, so the
|
||||
// store must stay 'git-sync' — otherwise the PageChangeListener loop-guard
|
||||
// (keyed on lastUpdatedSource === 'git-sync') fails to recognize git-sync's
|
||||
// own write and re-exports it. Explicit 'agent' still wins (see below); the
|
||||
// sticky marker only promotes a plain human writer to 'agent'.
|
||||
await ext.onChange(makeChangePayload('agent'));
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'svc-user' }, actor: 'git-sync' }),
|
||||
);
|
||||
|
||||
expect(sourceOf(pageRepo)).toBe('git-sync');
|
||||
});
|
||||
|
||||
it("tags 'agent' when the storing writer itself is the agent (no prior onChange)", async () => {
|
||||
const { ext, pageRepo } = build();
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'agent-user' }, actor: 'agent' }),
|
||||
);
|
||||
|
||||
expect(sourceOf(pageRepo)).toBe('agent');
|
||||
});
|
||||
|
||||
// --- boundary snapshot for a git-sync store over a HUMAN baseline -----------
|
||||
// SPEC §9 observable-loss guard (bug #2): a git-sync body write is a block-level
|
||||
// 3-way merge whose same-block rule is "git wins". To keep a concurrent human
|
||||
// edit RECOVERABLE rather than silently overwritten, a git-sync store over a
|
||||
// prior NON-git-sync baseline pins that prior state to page history first —
|
||||
// exactly like the agent path. So saveHistory MUST be called here.
|
||||
it('DOES pin a boundary snapshot for a git-sync store over a prior human state', async () => {
|
||||
const { ext, pageHistoryRepo } = build({ lastUpdatedSource: 'user' });
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'svc-user' }, actor: 'git-sync' }),
|
||||
);
|
||||
|
||||
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// --- negative: a git-sync store over a git-sync baseline does NOT re-pin -----
|
||||
// The boundary is pinned once on the transition INTO git-sync; a subsequent
|
||||
// git-sync store over an already-git-sync baseline must not churn history.
|
||||
it('does NOT re-pin a boundary snapshot for a git-sync store over a git-sync baseline', async () => {
|
||||
const { ext, pageHistoryRepo } = build({ lastUpdatedSource: 'git-sync' });
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'svc-user' }, actor: 'git-sync' }),
|
||||
);
|
||||
|
||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('DOES pin a boundary snapshot for an agent store over a prior human state (control)', async () => {
|
||||
// Confirms the negative above is meaningful: under the SAME mocks, an agent
|
||||
// store over a 'user' baseline DOES trigger the boundary snapshot.
|
||||
const { ext, pageHistoryRepo } = build({ lastUpdatedSource: 'user' });
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'agent-user' }, actor: 'agent' }),
|
||||
);
|
||||
|
||||
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does NOT pin a boundary snapshot for a plain user store', async () => {
|
||||
const { ext, pageHistoryRepo } = build({ lastUpdatedSource: 'user' });
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'user-1' }, actor: 'user' }),
|
||||
);
|
||||
|
||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
afterUnloadDocumentPayload,
|
||||
Extension,
|
||||
onChangePayload,
|
||||
onDisconnectPayload,
|
||||
onLoadDocumentPayload,
|
||||
onStatelessPayload,
|
||||
onStoreDocumentPayload,
|
||||
@@ -41,7 +42,6 @@ import {
|
||||
HISTORY_INTERVAL,
|
||||
} from '../constants';
|
||||
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
|
||||
@@ -83,7 +83,17 @@ export function resolveSource(
|
||||
stickyTouched: boolean,
|
||||
contextActor?: string,
|
||||
): ProvenanceSource {
|
||||
return stickyTouched || contextActor === 'agent' ? 'agent' : 'user';
|
||||
// An EXPLICIT current-write actor is authoritative for THIS write and wins
|
||||
// over the sticky-agent fallback. Order: explicit 'agent' > explicit
|
||||
// 'git-sync' > sticky agent marker > plain human 'user'. The git-sync case
|
||||
// must NOT be masked by the sticky marker, or the PageChangeListener
|
||||
// loop-guard (which keys on lastUpdatedSource === 'git-sync') would re-export
|
||||
// git-sync's own writes (#14). Explicit agent still wins so a window that
|
||||
// mixed an agent edit stays tagged 'agent'.
|
||||
if (contextActor === 'agent') return 'agent';
|
||||
if (contextActor === 'git-sync') return 'git-sync';
|
||||
if (stickyTouched) return 'agent';
|
||||
return 'user';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,18 +202,41 @@ export class PersistenceExtension implements Extension {
|
||||
return new Y.Doc();
|
||||
}
|
||||
|
||||
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();
|
||||
/**
|
||||
* LOSS-ON-FAST-CLOSE FIX (QA #119). When the LAST editor disconnects, FLUSH any
|
||||
* pending (debounced) store to the DB IMMEDIATELY instead of waiting out the
|
||||
* up-to-10s `debounce` window.
|
||||
*
|
||||
* The collab server runs with `unloadImmediately: false` (collaboration.gateway),
|
||||
* so on a last-client disconnect Hocuspocus does NOT flush the debounced
|
||||
* onStoreDocument — it relies on the timer firing later. A quick edit-then-close
|
||||
* (closing the tab within the debounce window, ~3-18s) therefore left the edit
|
||||
* only in the soon-to-be-unloaded in-memory Y.Doc; meanwhile git-sync mirrored
|
||||
* the STALE/empty DB body to the vault (the reported "59-byte frontmatter-only"
|
||||
* data loss). Running the already-scheduled store now closes that window.
|
||||
*
|
||||
* Gated tightly so it never adds a redundant write: only on the LAST disconnect
|
||||
* (`clientsCount === 0`), only for a fully-loaded doc, and only when a store is
|
||||
* actually pending (`isDebounced`). `executeNow` runs the SAME payload Hocuspocus
|
||||
* scheduled (preserving the edit's context/actor) and clears the timer.
|
||||
*/
|
||||
async onDisconnect(data: onDisconnectPayload) {
|
||||
const { instance, document, documentName, clientsCount } = data;
|
||||
if (clientsCount > 0) return;
|
||||
if (!document || document.isLoading) return;
|
||||
const debounceId = `onStoreDocument-${documentName}`;
|
||||
if (!instance?.debouncer?.isDebounced(debounceId)) return;
|
||||
try {
|
||||
await this.storeDocument(data);
|
||||
} finally {
|
||||
observeCollabStore((performance.now() - startedAt) / 1000);
|
||||
await instance.debouncer.executeNow(debounceId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`onDisconnect flush failed for ${documentName}: ` +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async storeDocument(data: onStoreDocumentPayload) {
|
||||
async onStoreDocument(data: onStoreDocumentPayload) {
|
||||
const { documentName, document, context } = data;
|
||||
|
||||
const pageId = getPageId(documentName);
|
||||
@@ -225,6 +258,11 @@ export class PersistenceExtension implements Extension {
|
||||
// Sticky agent marker: 'agent' if any agent edit landed in this window, OR
|
||||
// if the current writer is the agent (covers a store with no prior onChange
|
||||
// agent event in the same window). §15 H2.
|
||||
// Provenance precedence: agent > git-sync > user (see resolveSource). A
|
||||
// 'git-sync' store is NOT given an immediate history snapshot — it is
|
||||
// debounced like a human edit (a git-sync write is a block-level merge into
|
||||
// the live doc, so it reads like an incremental human edit, not a bulk
|
||||
// import that would warrant its own immediate snapshot).
|
||||
const lastUpdatedSource = resolveSource(
|
||||
this.consumeAgentTouched(documentName),
|
||||
context?.actor,
|
||||
@@ -291,17 +329,25 @@ export class PersistenceExtension implements Extension {
|
||||
// flag via that same hoisted consume (a "cleared then retyped"
|
||||
// sequence can't leave a usable one behind).
|
||||
const incomingEmpty = isEmptyParagraphDoc(tiptapJson as any);
|
||||
// A git-sync write is authoritative and its content IS the vault file:
|
||||
// an empty incoming doc there means the user DELIBERATELY cleared the
|
||||
// page's markdown in git (there is no "transient glitch empty" for a
|
||||
// file-sourced write). Honor it, otherwise the empty-guard rejects the
|
||||
// clear, the vault ref has already advanced past the empty commit, and
|
||||
// vault<->Docmost diverge permanently (review warning). This mirrors the
|
||||
// #251 intentional-clear allowance for a different authoritative source.
|
||||
const gitSyncClear = lastUpdatedSource === 'git-sync';
|
||||
if (
|
||||
incomingEmpty &&
|
||||
page.content &&
|
||||
!isEmptyParagraphDoc(page.content as any)
|
||||
) {
|
||||
if (allowIntentionalClear) {
|
||||
if (allowIntentionalClear || gitSyncClear) {
|
||||
this.logger.debug(
|
||||
`Intentional clear for ${pageId}: persisting empty doc over ` +
|
||||
`non-empty content (user-signalled)`,
|
||||
`non-empty content (${gitSyncClear ? 'git-sync' : 'user-signalled'})`,
|
||||
);
|
||||
// fall through — the empty write is allowed exactly once.
|
||||
// fall through — the empty write is allowed.
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`Skipping store for ${pageId}: empty live doc would overwrite ` +
|
||||
@@ -326,21 +372,30 @@ export class PersistenceExtension implements Extension {
|
||||
//this.logger.debug('Contributors error:' + err?.['message']);
|
||||
}
|
||||
|
||||
// Approach A — boundary snapshot before the agent's first edit.
|
||||
// When this store is the agent's and the page's currently persisted
|
||||
// state was authored by a human, pin that human state as its own
|
||||
// history version BEFORE the agent overwrites it. `page` still holds
|
||||
// the OLD content/provenance here, so saveHistory(page) captures the
|
||||
// pre-agent state tagged 'user'. The agent's new content is
|
||||
// snapshotted later by the debounced PAGE_HISTORY job ('agent'). Skip
|
||||
// if the prior state is already agent-authored (boundary already
|
||||
// pinned on the user->agent transition), if the page is effectively
|
||||
// empty, or if the latest existing snapshot already equals this human
|
||||
// state (avoid duplicates).
|
||||
if (
|
||||
lastUpdatedSource === 'agent' &&
|
||||
page.lastUpdatedSource !== 'agent'
|
||||
) {
|
||||
// Approach A — boundary snapshot before a MACHINE write overwrites a
|
||||
// human (or other-source) baseline. When this store is from a machine
|
||||
// source — the AGENT or GIT-SYNC — and the page's currently persisted
|
||||
// state was authored by a DIFFERENT source, pin that prior state as its
|
||||
// own history version BEFORE the machine write overwrites it. `page`
|
||||
// still holds the OLD content/provenance here, so saveHistory(page)
|
||||
// captures the pre-write state. The machine's new content is snapshotted
|
||||
// later by the debounced PAGE_HISTORY job.
|
||||
//
|
||||
// For GIT-SYNC this is the OBSERVABLE-LOSS guard (SPEC §9 conflict
|
||||
// contract): a git-sync body write is a block-level 3-way merge whose
|
||||
// same-block rule is "git wins". Without this pin, a concurrent human
|
||||
// edit to a block git also changed would be overwritten with NO trace.
|
||||
// Pinning the pre-merge state here means the human's content is always
|
||||
// RECOVERABLE via page history rather than silently lost — git still
|
||||
// wins the live doc deterministically, but nothing is destroyed.
|
||||
//
|
||||
// Skip if the prior state was already authored by THIS machine source
|
||||
// (boundary already pinned on the transition into it), if the page is
|
||||
// effectively empty, or if the latest existing snapshot already equals
|
||||
// the prior state (avoid duplicates).
|
||||
const isMachineWrite =
|
||||
lastUpdatedSource === 'agent' || lastUpdatedSource === 'git-sync';
|
||||
if (isMachineWrite && page.lastUpdatedSource !== lastUpdatedSource) {
|
||||
// pageHistory.pageId is uuid-typed; use page.id (never the doc-name
|
||||
// slugId) so a `page.<slugId>` doc cannot throw 22P02 here (#260).
|
||||
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
// Regression coverage for the custom-event request/reply protocol in the
|
||||
// RedisSyncExtension. git-sync routes its body write through a custom event
|
||||
// (`gitSyncWriteBody`) which, when the target doc is owned by a DIFFERENT collab
|
||||
// instance, runs REMOTELY inside `handleRedisMessage` on the owning instance. The
|
||||
// remote handler can THROW (markdown->ProseMirror transform on a malformed body).
|
||||
//
|
||||
// Before the fix the throw was uncaught: (1) no `customEventComplete` reply was
|
||||
// published, so the origin's awaiting promise only rejected after `customEventTTL`
|
||||
// (~30s) as a generic 'TIMEOUT', and (2) an unhandledRejection escaped the async
|
||||
// `messageBuffer` listener on the owning instance. These tests assert the throw is
|
||||
// turned into an error-carrying reply that rejects the origin PROMPTLY with the
|
||||
// real message, with the no-throw and local paths unchanged.
|
||||
|
||||
import { RedisSyncExtension } from './redis-sync.extension';
|
||||
|
||||
type Listener = (channel: Buffer, message: Buffer) => unknown;
|
||||
|
||||
// Minimal in-memory pub/sub + lock store shared across FakeRedis duplicates,
|
||||
// modelling the two-instance topology (origin + owner) over one Redis.
|
||||
class FakeRedisBus {
|
||||
instances: FakeRedis[] = [];
|
||||
locks = new Map<string, string>();
|
||||
published: { channel: string; message: Buffer }[] = [];
|
||||
|
||||
register(inst: FakeRedis) {
|
||||
this.instances.push(inst);
|
||||
}
|
||||
|
||||
publish(channel: string, message: Buffer) {
|
||||
this.published.push({ channel, message });
|
||||
for (const inst of this.instances) {
|
||||
if (!inst.subscribed.has(channel)) continue;
|
||||
for (const listener of inst.messageListeners) {
|
||||
// ioredis delivers async; `void` mirrors the production listener
|
||||
// registration (`sub.on('messageBuffer', ...)`), whose rejection would
|
||||
// surface as an unhandledRejection if the handler did not catch.
|
||||
void listener(Buffer.from(channel), message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FakeRedis {
|
||||
subscribed = new Set<string>();
|
||||
messageListeners: Listener[] = [];
|
||||
|
||||
constructor(private bus: FakeRedisBus) {
|
||||
bus.register(this);
|
||||
}
|
||||
|
||||
duplicate() {
|
||||
return new FakeRedis(this.bus);
|
||||
}
|
||||
|
||||
subscribe(...channels: string[]) {
|
||||
for (const c of channels) this.subscribed.add(c);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
on(event: string, cb: any) {
|
||||
if (event === 'messageBuffer') this.messageListeners.push(cb as Listener);
|
||||
return this;
|
||||
}
|
||||
|
||||
publish(channel: string, message: Buffer) {
|
||||
this.bus.publish(channel, message);
|
||||
return Promise.resolve(1);
|
||||
}
|
||||
|
||||
// Models `SET key val PX ttl NX GET`: only writes when absent (NX); returns the
|
||||
// previous value (GET) so the origin observes the owner already holding the lock.
|
||||
set(key: string, val: string, ...args: any[]) {
|
||||
const hasNX = args.includes('NX');
|
||||
const hasGET = args.includes('GET');
|
||||
const old = this.bus.locks.get(key) ?? null;
|
||||
if (!hasNX || old === null) this.bus.locks.set(key, val);
|
||||
return Promise.resolve(hasGET ? old : 'OK');
|
||||
}
|
||||
|
||||
del(key: string) {
|
||||
this.bus.locks.delete(key);
|
||||
return Promise.resolve(1);
|
||||
}
|
||||
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
const pack = (m: any) => Buffer.from(JSON.stringify(m));
|
||||
const unpack = (b: Buffer) => JSON.parse(b.toString());
|
||||
|
||||
function makeExtension(
|
||||
bus: FakeRedisBus,
|
||||
serverId: string,
|
||||
customEvents: Record<string, (doc: string, payload: any) => Promise<any>>,
|
||||
) {
|
||||
const ext = new RedisSyncExtension({
|
||||
redis: new FakeRedis(bus) as any,
|
||||
pack: pack as any,
|
||||
unpack: unpack as any,
|
||||
serverId,
|
||||
customEvents: customEvents as any,
|
||||
customEventTTL: 30_000,
|
||||
});
|
||||
// Doc is NOT loaded on this instance -> handleEvent takes the remote/proxy path.
|
||||
(ext as any).instance = { documents: new Map() };
|
||||
return ext;
|
||||
}
|
||||
|
||||
describe('RedisSyncExtension custom-event error propagation', () => {
|
||||
let unhandled: unknown[];
|
||||
let onUnhandled: (e: unknown) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
// Fake timers so the 30s TTL fallback timer never fires (and never dangles).
|
||||
jest.useFakeTimers();
|
||||
unhandled = [];
|
||||
onUnhandled = (e) => unhandled.push(e);
|
||||
process.on('unhandledRejection', onUnhandled);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.off('unhandledRejection', onUnhandled);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
it('owner publishes an error-carrying reply (no unhandledRejection) when the remote handler throws', async () => {
|
||||
const bus = new FakeRedisBus();
|
||||
const owner = makeExtension(bus, 'owner', {
|
||||
boom: async () => {
|
||||
throw new Error('kaboom');
|
||||
},
|
||||
});
|
||||
|
||||
// Drive the remote branch directly, as if the origin's customEventStart arrived.
|
||||
await (owner as any).handleRedisMessage(
|
||||
Buffer.from('collabMsg:owner'),
|
||||
pack({
|
||||
type: 'customEventStart',
|
||||
documentName: 'page.x',
|
||||
eventName: 'boom',
|
||||
payload: {},
|
||||
replyTo: 'collabMsg:origin',
|
||||
replyId: 7,
|
||||
}),
|
||||
);
|
||||
await flush();
|
||||
|
||||
const replies = bus.published
|
||||
.filter((p) => p.channel === 'collabMsg:origin')
|
||||
.map((p) => unpack(p.message));
|
||||
expect(replies).toHaveLength(1);
|
||||
expect(replies[0]).toMatchObject({
|
||||
type: 'customEventComplete',
|
||||
replyId: 7,
|
||||
error: 'kaboom',
|
||||
});
|
||||
expect(unhandled).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('origin rejects PROMPTLY with the real error (not a TTL TIMEOUT) when the remote handler throws', async () => {
|
||||
const bus = new FakeRedisBus();
|
||||
// Owner already holds the document lock.
|
||||
bus.locks.set('collabLock:page.x', 'owner');
|
||||
makeExtension(bus, 'owner', {
|
||||
boom: async () => {
|
||||
throw new Error('kaboom');
|
||||
},
|
||||
});
|
||||
const origin = makeExtension(bus, 'origin', {
|
||||
boom: async () => undefined,
|
||||
});
|
||||
|
||||
const promise = (origin as any).handleEvent('boom', 'page.x', { foo: 1 });
|
||||
// Attach a catch immediately so a rejection is never momentarily unhandled.
|
||||
const settled = promise.then(
|
||||
() => ({ ok: true as const }),
|
||||
(e: unknown) => ({ ok: false as const, error: e }),
|
||||
);
|
||||
|
||||
await flush();
|
||||
// Resolves WITHOUT advancing any timer -> the 30s TIMEOUT fallback did not fire.
|
||||
const result = await settled;
|
||||
expect(result.ok).toBe(false);
|
||||
expect((result as any).error).toBeInstanceOf(Error);
|
||||
expect(((result as any).error as Error).message).toBe('kaboom');
|
||||
expect(unhandled).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('origin resolves with the payload when the remote handler succeeds (unchanged behavior)', async () => {
|
||||
const bus = new FakeRedisBus();
|
||||
bus.locks.set('collabLock:page.x', 'owner');
|
||||
makeExtension(bus, 'owner', {
|
||||
ok: async (_doc: string, payload: any) => ({ echoed: payload }),
|
||||
});
|
||||
const origin = makeExtension(bus, 'origin', {
|
||||
ok: async () => undefined,
|
||||
});
|
||||
|
||||
const promise = (origin as any).handleEvent('ok', 'page.x', { foo: 1 });
|
||||
await flush();
|
||||
await expect(promise).resolves.toEqual({ echoed: { foo: 1 } });
|
||||
expect(unhandled).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -51,9 +51,15 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
|
||||
private instance!: Hocuspocus;
|
||||
private readonly customEvents: TCE;
|
||||
private replyIdCounter: number = 0;
|
||||
// @ts-ignore
|
||||
private pendingReplies: Record<number, PromiseWithResolvers<any>['resolve']> =
|
||||
{};
|
||||
private pendingReplies: Record<
|
||||
number,
|
||||
{
|
||||
// @ts-ignore
|
||||
resolve: PromiseWithResolvers<any>['resolve'];
|
||||
// @ts-ignore
|
||||
reject: PromiseWithResolvers<any>['reject'];
|
||||
}
|
||||
> = {};
|
||||
|
||||
constructor(configuration: Configuration<TCE>) {
|
||||
const {
|
||||
@@ -176,25 +182,45 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
|
||||
}
|
||||
if (type === 'customEventStart') {
|
||||
const { documentName, eventName, payload, replyTo, replyId } = msg;
|
||||
const res = await this.handleEventLocally(
|
||||
eventName as Extract<keyof TCE, string>,
|
||||
documentName,
|
||||
payload,
|
||||
);
|
||||
const reply: RSAMessageCustomEventComplete = {
|
||||
type: 'customEventComplete',
|
||||
replyId,
|
||||
payload: res,
|
||||
};
|
||||
let reply: RSAMessageCustomEventComplete;
|
||||
try {
|
||||
const res = await this.handleEventLocally(
|
||||
eventName as Extract<keyof TCE, string>,
|
||||
documentName,
|
||||
payload,
|
||||
);
|
||||
reply = {
|
||||
type: 'customEventComplete',
|
||||
replyId,
|
||||
payload: res,
|
||||
};
|
||||
} catch (err) {
|
||||
// The remote handler threw (e.g. the markdown->ProseMirror transform in
|
||||
// gitSyncWriteBody can throw on a malformed body). Reply with the error on
|
||||
// the SAME correlation channel so the origin rejects promptly with the real
|
||||
// message instead of waiting out customEventTTL as a generic 'TIMEOUT'.
|
||||
// Catching here also keeps the throw from escaping this async messageBuffer
|
||||
// listener as an unhandledRejection on the owning instance.
|
||||
reply = {
|
||||
type: 'customEventComplete',
|
||||
replyId,
|
||||
payload: undefined,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
this.pub.publish(`${replyTo}`, this.pack(reply));
|
||||
return;
|
||||
}
|
||||
if (type === 'customEventComplete') {
|
||||
const { replyId, payload } = msg;
|
||||
const resolveFn = this.pendingReplies[replyId];
|
||||
if (!resolveFn) return;
|
||||
const { replyId, payload, error } = msg;
|
||||
const pending = this.pendingReplies[replyId];
|
||||
if (!pending) return;
|
||||
delete this.pendingReplies[replyId];
|
||||
resolveFn(payload);
|
||||
if (error !== undefined) {
|
||||
pending.reject(new Error(error));
|
||||
} else {
|
||||
pending.resolve(payload);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const { socketId } = msg;
|
||||
@@ -273,11 +299,22 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
|
||||
};
|
||||
const msg = this.pack(proxyMessage);
|
||||
this.pub.publish(`${this.msgChannel}:${proxyTo}`, msg);
|
||||
// @ts-ignore
|
||||
const { promise, resolve, reject } = Promise.withResolvers();
|
||||
this.pendingReplies[replyId] = resolve;
|
||||
// Manual deferred (no Promise.withResolvers) so this runs on Node < 22 too.
|
||||
let resolve!: (v: unknown) => void;
|
||||
let reject!: (e: unknown) => void;
|
||||
const promise = new Promise((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
this.pendingReplies[replyId] = { resolve, reject };
|
||||
setTimeout(() => {
|
||||
reject('TIMEOUT');
|
||||
// Fallback for a genuinely lost reply. A handler that threw now rejects
|
||||
// promptly via the error-carrying customEventComplete above; this TIMEOUT
|
||||
// only fires when no reply ever comes back.
|
||||
if (this.pendingReplies[replyId]) {
|
||||
delete this.pendingReplies[replyId];
|
||||
reject('TIMEOUT');
|
||||
}
|
||||
}, this.customEventTTL);
|
||||
return promise as Promise<ReturnType<TCE[TName]>>;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,10 @@ export type RSAMessageCustomEventComplete = {
|
||||
type: 'customEventComplete';
|
||||
replyId: number;
|
||||
payload: unknown;
|
||||
// When the remote handler THREW, the owner sends back the error message here
|
||||
// instead of a payload, so the origin can reject its awaiting promise promptly
|
||||
// (with the real error) rather than waiting out the customEventTTL timeout.
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type RSAMessage =
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
/**
|
||||
* JEST CONFIG NOTE (#119 ESM refactor): this is the one spec that needs the REAL
|
||||
* `@docmost/git-sync` converter (not a mock). The package is now ESM, which jest
|
||||
* cannot `require()` nor `import()` without --experimental-vm-modules, so the
|
||||
* server jest config `moduleNameMapper`s `@docmost/git-sync` to its TS SOURCE and
|
||||
* strips the ESM `.js` import suffixes. ts-jest then type-checks that source under
|
||||
* the server's (looser) tsconfig and trips a benign narrowing; the global
|
||||
* `isolatedModules: true` on the ts-jest transform (apps/server/package.json)
|
||||
* makes it transpile-only so this spec loads. Full type-checking of the package
|
||||
* is still enforced by its own `tsc`/vitest gates and the server `tsc --noEmit`.
|
||||
*
|
||||
* §13.1 IDEMPOTENCY GATE — the blocking gate for git-sync Phase B.
|
||||
*
|
||||
* Proves the `@docmost/git-sync` pure converter is schema-compatible
|
||||
* with the server's REAL editor-ext document schema: a representative corpus of
|
||||
* editor-ext ProseMirror documents must survive a full round trip through the
|
||||
* actual server write path without losing any node / mark / attribute.
|
||||
*
|
||||
* Pipeline per document (issue #194 §13.1):
|
||||
* 1. md = convertProseMirrorToMarkdown(content) // git-sync export
|
||||
* 2. doc = await markdownToProseMirror(md) // git-sync import
|
||||
* 3. push `doc` through the REAL editor-ext Yjs write path the server uses:
|
||||
* ydoc = TiptapTransformer.toYdoc(doc, 'default', tiptapExtensions)
|
||||
* normalized = TiptapTransformer.fromYdoc(ydoc, 'default')
|
||||
* This is exactly what PersistenceExtension does on store
|
||||
* (apps/server/src/collaboration/extensions/persistence.extension.ts:96/115)
|
||||
* with the same `tiptapExtensions` (collaboration.util.ts) and the same
|
||||
* `@hocuspocus/transformer`, so the gate exercises the real schema
|
||||
* validation that runs on a git-sync write (issue #194 §3.3).
|
||||
* 4. assert docsCanonicallyEqual(canon(original), canon(normalized)) === true
|
||||
*
|
||||
* Any node / mark / attr that editor-ext drops (because the git-sync
|
||||
* docmost-schema named it differently, or declares a different default) makes
|
||||
* the gate FAIL for that document — exactly the schema-divergence issue #194 §3.3 /
|
||||
* §13.1 warn about. Genuine, irreducible divergences are isolated into the
|
||||
* clearly-named `KNOWN DIVERGENCE` block at the bottom (never silently hidden).
|
||||
*
|
||||
* Requires the workspace packages built first:
|
||||
* pnpm --filter @docmost/editor-ext build
|
||||
* pnpm --filter @docmost/git-sync build
|
||||
*/
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
// Import the server's real schema FIRST so `@docmost/editor-ext` resolves to its
|
||||
// built CJS `dist` (its `main`). The ESM-only `@docmost/git-sync` package is
|
||||
// mapped to its TS SOURCE by the jest `moduleNameMapper` (the built ESM cannot
|
||||
// be `require()`d nor dynamically `import()`ed under jest's node VM), so ts-jest
|
||||
// transpiles the real converter to CJS here — exercising the actual converter
|
||||
// the server ships, not a stub.
|
||||
import { tiptapExtensions } from './collaboration.util';
|
||||
import {
|
||||
convertProseMirrorToMarkdown,
|
||||
markdownToProseMirror,
|
||||
canonicalizeContent,
|
||||
docsCanonicallyEqual,
|
||||
} from '@docmost/git-sync';
|
||||
|
||||
/**
|
||||
* Run a single editor-ext document through the full gate pipeline and return
|
||||
* the canonical original vs the canonical doc as it lands after the real Yjs
|
||||
* write path, plus the intermediate markdown for diagnostics.
|
||||
*/
|
||||
async function runGate(original: any): Promise<{
|
||||
md: string;
|
||||
imported: any;
|
||||
normalized: any;
|
||||
canonOriginal: any;
|
||||
canonNormalized: any;
|
||||
}> {
|
||||
// 1) editor-ext JSON -> markdown (git-sync export).
|
||||
const md = convertProseMirrorToMarkdown(original);
|
||||
|
||||
// 2) markdown -> ProseMirror JSON (git-sync import, docmost-schema).
|
||||
const imported = await markdownToProseMirror(md);
|
||||
|
||||
// 3) push through the REAL editor-ext schema via the server's Yjs write path.
|
||||
// toYdoc validates `imported` against tiptapExtensions (throws on an
|
||||
// unknown node, drops unknown attrs); fromYdoc reads it back as the
|
||||
// normalized editor-ext JSON the server would persist.
|
||||
const ydoc = TiptapTransformer.toYdoc(imported, 'default', tiptapExtensions);
|
||||
const normalized = TiptapTransformer.fromYdoc(ydoc, 'default');
|
||||
|
||||
return {
|
||||
md,
|
||||
imported,
|
||||
normalized,
|
||||
canonOriginal: canonicalizeContent(original),
|
||||
canonNormalized: canonicalizeContent(normalized),
|
||||
};
|
||||
}
|
||||
|
||||
const doc = (...content: any[]) => ({ type: 'doc', content });
|
||||
const text = (t: string, marks?: any[]) =>
|
||||
marks ? { type: 'text', text: t, marks } : { type: 'text', text: t };
|
||||
const para = (...content: any[]) => ({ type: 'paragraph', content });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Corpus: editor-ext ProseMirror documents covering the common node/mark types.
|
||||
// Node / mark / attr names and DEFAULTS are taken from the real schema —
|
||||
// editor-ext (packages/editor-ext/src) + the server's tiptapExtensions
|
||||
// (collaboration.util.ts) — NOT guessed. Where editor-ext materializes a
|
||||
// non-null default on import (e.g. image.align="center", callout.type, list
|
||||
// start) the fixture pre-authors that materialized value so the round trip is
|
||||
// already at its fixpoint (matches how the engine normalizes-on-write, SPEC §11).
|
||||
// ---------------------------------------------------------------------------
|
||||
const CORPUS: Record<string, any> = {
|
||||
'paragraphs + headings (h1-h3)': doc(
|
||||
{ type: 'heading', attrs: { level: 1 }, content: [text('Heading one')] },
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [text('Heading two')] },
|
||||
{ type: 'heading', attrs: { level: 3 }, content: [text('Heading three')] },
|
||||
para(text('A plain paragraph of text.')),
|
||||
para(text('Second paragraph.')),
|
||||
),
|
||||
|
||||
// A non-default paragraph alignment now round-trips (item #7 fix): it exports
|
||||
// as `<p style="text-align:center">` and the schema's paragraph parseHTML
|
||||
// reads `style="text-align"` back onto `textAlign` on import, so the alignment
|
||||
// survives the full editor-ext write path. Promoted from the old KNOWN
|
||||
// DIVERGENCE block (which only heading alignment still occupies).
|
||||
'aligned paragraph (textAlign center)': doc({
|
||||
type: 'paragraph',
|
||||
attrs: { textAlign: 'center' },
|
||||
content: [text('centered')],
|
||||
}),
|
||||
|
||||
'inline marks (bold/italic/strike/code)': doc(
|
||||
para(
|
||||
text('normal '),
|
||||
text('bold', [{ type: 'bold' }]),
|
||||
text(' '),
|
||||
text('italic', [{ type: 'italic' }]),
|
||||
text(' '),
|
||||
text('struck', [{ type: 'strike' }]),
|
||||
text(' '),
|
||||
text('code', [{ type: 'code' }]),
|
||||
),
|
||||
),
|
||||
|
||||
'links': doc(
|
||||
para(
|
||||
text('see '),
|
||||
text('the site', [
|
||||
{ type: 'link', attrs: { href: 'https://example.com' } },
|
||||
]),
|
||||
text(' for more'),
|
||||
),
|
||||
),
|
||||
|
||||
'bullet list': doc({
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{ type: 'listItem', content: [para(text('first'))] },
|
||||
{ type: 'listItem', content: [para(text('second'))] },
|
||||
{ type: 'listItem', content: [para(text('third'))] },
|
||||
],
|
||||
}),
|
||||
|
||||
'ordered list': doc({
|
||||
type: 'orderedList',
|
||||
attrs: { start: 1 },
|
||||
content: [
|
||||
{ type: 'listItem', content: [para(text('one'))] },
|
||||
{ type: 'listItem', content: [para(text('two'))] },
|
||||
],
|
||||
}),
|
||||
|
||||
'task list (checkbox)': doc({
|
||||
type: 'taskList',
|
||||
content: [
|
||||
{
|
||||
type: 'taskItem',
|
||||
attrs: { checked: true },
|
||||
content: [para(text('done item'))],
|
||||
},
|
||||
{
|
||||
type: 'taskItem',
|
||||
attrs: { checked: false },
|
||||
content: [para(text('todo item'))],
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
'blockquote': doc({
|
||||
type: 'blockquote',
|
||||
content: [para(text('a quoted line')), para(text('second quoted line'))],
|
||||
}),
|
||||
|
||||
'callout (info)': doc({
|
||||
type: 'callout',
|
||||
attrs: { type: 'info' },
|
||||
content: [para(text('an informational callout'))],
|
||||
}),
|
||||
|
||||
'callout (warning)': doc({
|
||||
type: 'callout',
|
||||
attrs: { type: 'warning' },
|
||||
content: [para(text('a warning callout'))],
|
||||
}),
|
||||
|
||||
'code block (with language)': doc({
|
||||
type: 'codeBlock',
|
||||
attrs: { language: 'typescript' },
|
||||
// A fenced code block's body is stored with a trailing newline (the form a
|
||||
// markdown ``` fence round-trips to: marked normalizes the code text to end
|
||||
// in "\n"). Authoring the fixture at that fixpoint mirrors how the engine
|
||||
// normalizes-on-write (SPEC §11): codeBlock + `language` round-trip exactly.
|
||||
content: [text('const a: number = 1;\nconsole.log(a);\n')],
|
||||
}),
|
||||
|
||||
'horizontal rule': doc(
|
||||
para(text('before')),
|
||||
{ type: 'horizontalRule' },
|
||||
para(text('after')),
|
||||
),
|
||||
|
||||
'table (header row + cells)': doc({
|
||||
type: 'table',
|
||||
content: [
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{
|
||||
type: 'tableHeader',
|
||||
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||
content: [para(text('Name'))],
|
||||
},
|
||||
{
|
||||
type: 'tableHeader',
|
||||
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||
content: [para(text('Value'))],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{
|
||||
type: 'tableCell',
|
||||
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||
content: [para(text('alpha'))],
|
||||
},
|
||||
{
|
||||
type: 'tableCell',
|
||||
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||
content: [para(text('1'))],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
// #8 — a table with a MULTI-BLOCK cell (two paragraphs). A GFM pipe table
|
||||
// cannot hold two blocks without flattening them; the converter emits a
|
||||
// lossless HTML <table> instead, and the two blocks must survive the round trip.
|
||||
'table (multi-block cell, #8)': doc({
|
||||
type: 'table',
|
||||
content: [
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{
|
||||
type: 'tableHeader',
|
||||
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||
content: [para(text('H'))],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{
|
||||
type: 'tableCell',
|
||||
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||
content: [para(text('first')), para(text('second'))],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
// #7 — a table nested inside a column. Columns render as HTML containers, and a
|
||||
// table inside one must stay an HTML <table> (a GFM pipe table cannot live
|
||||
// inside an HTML block), round-tripping without being unwrapped or lost.
|
||||
// `widthMode` is pre-authored at its materialized `normal` default (SPEC §11).
|
||||
'table inside a column (#7)': doc({
|
||||
type: 'columns',
|
||||
attrs: { layout: 'two', widthMode: 'normal' },
|
||||
content: [
|
||||
{
|
||||
type: 'column',
|
||||
content: [
|
||||
{
|
||||
type: 'table',
|
||||
content: [
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{
|
||||
type: 'tableHeader',
|
||||
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||
content: [para(text('C7'))],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'column', content: [para(text('right'))] },
|
||||
],
|
||||
}),
|
||||
|
||||
// --- editor-ext nodes/marks beyond the original corpus (item #7) ----------
|
||||
// Each of these was verified to round-trip CLEANLY through the real gate
|
||||
// (export -> markdown -> import -> editor-ext Yjs write path). Fixtures are
|
||||
// pre-authored at the engine's normalize-on-write fixpoint (SPEC §11), e.g.
|
||||
// details carries the materialized `open:false`, and color marks use the
|
||||
// `rgb(...)` form the HTML re-parser normalizes to.
|
||||
|
||||
'mention (user)': doc(
|
||||
para(
|
||||
text('hi '),
|
||||
{
|
||||
type: 'mention',
|
||||
attrs: {
|
||||
id: 'user-123',
|
||||
label: 'Alice',
|
||||
entityType: 'user',
|
||||
entityId: 'user-123',
|
||||
creatorId: 'creator-1',
|
||||
},
|
||||
},
|
||||
text(' there'),
|
||||
),
|
||||
),
|
||||
|
||||
'inline math': doc(
|
||||
para(
|
||||
text('inline '),
|
||||
{ type: 'mathInline', attrs: { text: 'x^2' } },
|
||||
text(' math'),
|
||||
),
|
||||
),
|
||||
|
||||
'block math': doc({ type: 'mathBlock', attrs: { text: 'x^2 + y^2 = z^2' } }),
|
||||
|
||||
'details (collapsible)': doc({
|
||||
type: 'details',
|
||||
// `open:false` is the value editor-ext materializes on import; pre-authoring
|
||||
// it puts the fixture at its round-trip fixpoint.
|
||||
attrs: { open: false },
|
||||
content: [
|
||||
{ type: 'detailsSummary', content: [text('Summary line')] },
|
||||
{ type: 'detailsContent', content: [para(text('hidden body'))] },
|
||||
],
|
||||
}),
|
||||
|
||||
'highlight (mark, no color)': doc(
|
||||
para(
|
||||
text('a '),
|
||||
text('highlighted', [{ type: 'highlight' }]),
|
||||
text(' word'),
|
||||
),
|
||||
),
|
||||
|
||||
'highlight (mark, with color)': doc(
|
||||
para(
|
||||
text('a '),
|
||||
text('red', [{ type: 'highlight', attrs: { color: 'rgb(255, 0, 0)' } }]),
|
||||
text(' word'),
|
||||
),
|
||||
),
|
||||
|
||||
'subscript': doc(
|
||||
para(text('H'), text('2', [{ type: 'subscript' }]), text('O')),
|
||||
),
|
||||
|
||||
'superscript': doc(
|
||||
para(text('E=mc'), text('2', [{ type: 'superscript' }])),
|
||||
),
|
||||
|
||||
'text color (textStyle)': doc(
|
||||
// The HTML re-parser normalizes CSS colors to the `rgb(...)` form, so the
|
||||
// fixture pre-authors that form; a `#hex` color would round-trip to the
|
||||
// equivalent rgb() and is therefore a value-normalization divergence (see
|
||||
// the KNOWN DIVERGENCE block below).
|
||||
para(text('green', [{ type: 'textStyle', attrs: { color: 'rgb(0, 255, 0)' } }])),
|
||||
),
|
||||
|
||||
'nested / mixed document': doc(
|
||||
{ type: 'heading', attrs: { level: 1 }, content: [text('Mixed')] },
|
||||
para(
|
||||
text('intro with '),
|
||||
text('bold', [{ type: 'bold' }]),
|
||||
text(' and a '),
|
||||
text('link', [{ type: 'link', attrs: { href: 'https://example.com' } }]),
|
||||
text('.'),
|
||||
),
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
para(text('item with '), text('code', [{ type: 'code' }])),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
para(text('item with sublist')),
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{ type: 'listItem', content: [para(text('nested a'))] },
|
||||
{ type: 'listItem', content: [para(text('nested b'))] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'callout',
|
||||
attrs: { type: 'success' },
|
||||
content: [
|
||||
para(text('callout body')),
|
||||
{ type: 'codeBlock', attrs: { language: 'bash' }, content: [text('echo hi\n')] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'blockquote',
|
||||
content: [para(text('quote at the end'))],
|
||||
},
|
||||
),
|
||||
|
||||
// Atom embeds that carry no inline text: they must round-trip via their
|
||||
// schema-matching HTML (data-type div), NOT a literal that re-imports as plain
|
||||
// text. `subpages` used to export as the literal "{{SUBPAGES}}" and came back
|
||||
// as visible text on the page (red-team round-trip data loss) — this locks it.
|
||||
// editor-ext materializes the `recursive: false` default on import, so the
|
||||
// fixture pre-authors it to sit at the round-trip fixpoint (matches the other
|
||||
// default-materializing fixtures above).
|
||||
'subpages embed': doc({ type: 'subpages', attrs: { recursive: false } }),
|
||||
};
|
||||
|
||||
describe('git-sync converter §13.1 idempotency gate (editor-ext schema)', () => {
|
||||
for (const [name, original] of Object.entries(CORPUS)) {
|
||||
it(`round-trips losslessly: ${name}`, async () => {
|
||||
const { md, canonOriginal, canonNormalized } = await runGate(original);
|
||||
|
||||
const equal = docsCanonicallyEqual(original, canonNormalized);
|
||||
if (!equal) {
|
||||
// Surface a readable diff so a real divergence is actionable.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`\n[GATE FAIL] ${name}\n--- markdown ---\n${md}\n` +
|
||||
`--- canonical original ---\n${JSON.stringify(canonOriginal, null, 2)}\n` +
|
||||
`--- canonical round-tripped ---\n${JSON.stringify(canonNormalized, null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
expect(equal).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Image layout attrs preserved by canon #4 (width/height/align all round-trip).
|
||||
//
|
||||
// The `image` NODE round-trips through editor-ext fine. Plain markdown ``
|
||||
// has no way to express layout attrs, so the canonical converter (#293/#326
|
||||
// canon decision #4) appends a machine comment `<!--img {...}-->` carrying the
|
||||
// NON-DEFAULT attrs, and re-parses it on import — the same trailing-comment
|
||||
// pattern used for media/textAlign.
|
||||
//
|
||||
// About `align` — DO NOT repeat an earlier false diagnosis: align is NOT lost.
|
||||
// `center` is the schema default, so the emitter omits it (only left/right go
|
||||
// into the comment) and the importer restores it via the image `align` default
|
||||
// ("center"). `canonNormalized.align` reads `undefined` here ONLY because
|
||||
// `canonicalizeContent` normalizes the "center" default away SYMMETRICALLY (from
|
||||
// both the original and the round trip), so docsCanonicallyEqual is unaffected —
|
||||
// this is canonical-form normalization, not a divergence. Verified empirically:
|
||||
// left/right survive the raw round trip; center is restored on import then
|
||||
// canon-stripped on both sides. The real round-trip instability in this family
|
||||
// was the empty-string-vs-absent class (image.alt `absent -> ""`), fixed
|
||||
// parse-side in the converter PACKAGE on develop (PR #350). This branch absorbs
|
||||
// that fix via the next develop merge; nothing to flip here for align.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('git-sync converter §13.1 image layout attrs round-trip (align via schema default)', () => {
|
||||
const imageDoc = doc({
|
||||
type: 'image',
|
||||
attrs: {
|
||||
src: 'https://example.com/pic.png',
|
||||
width: 640,
|
||||
height: 480,
|
||||
align: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
it('preserves width/height via the canon `<!--img {...}-->` comment; center align is the default', async () => {
|
||||
const { md, canonNormalized } = await runGate(imageDoc);
|
||||
|
||||
// Canon #4: bare `` plus a trailing `<!--img {...}-->` comment that
|
||||
// carries the non-default layout attrs. `center` is the default, so it is
|
||||
// correctly OMITTED from the comment (only width/height appear here).
|
||||
expect(md.trim()).toBe(
|
||||
' <!--img {"width":"640","height":"480"}-->',
|
||||
);
|
||||
|
||||
// The round-tripped image keeps src + width/height. width/height are
|
||||
// re-imported as strings (matching the video/audio/pdf string convention),
|
||||
// so assert the values rather than the JS type.
|
||||
const imgAttrs = (canonNormalized as any).content[0].attrs;
|
||||
expect((canonNormalized as any).content[0].type).toBe('image');
|
||||
expect(imgAttrs.src).toBe('https://example.com/pic.png');
|
||||
expect(String(imgAttrs.width)).toBe('640');
|
||||
expect(String(imgAttrs.height)).toBe('480');
|
||||
// `align` is NOT lost — see the block comment above: `center` is the schema
|
||||
// default, restored on import then normalized away symmetrically by
|
||||
// canonicalize, so it reads `undefined` on the CANONICAL form (not a loss).
|
||||
expect(imgAttrs.align).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HEADING text alignment — now round-trips (item A1; formerly a KNOWN DIVERGENCE).
|
||||
// Symmetric with the paragraph fix: a heading's non-default `textAlign` is
|
||||
// exported as a styled `<hN style="text-align:…">` (was a bare ATX `## text`
|
||||
// that dropped it) and re-parsed by the heading + textAlign parseHTML on import,
|
||||
// so a non-default heading alignment SURVIVES a full round trip.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('git-sync converter §13.1 heading text alignment round-trips', () => {
|
||||
it('preserves a heading textAlign across the markdown round trip', async () => {
|
||||
const alignedHeading = doc({
|
||||
type: 'heading',
|
||||
attrs: { level: 2, textAlign: 'center' },
|
||||
content: [text('centered heading')],
|
||||
});
|
||||
|
||||
const { md, canonNormalized } = await runGate(alignedHeading);
|
||||
|
||||
// Canon #9: ATX heading plus a trailing `<!--attrs {...}-->` comment carrying
|
||||
// the non-default textAlign (was a lossy bare `## centered heading`).
|
||||
expect(md.trim()).toBe(
|
||||
'## centered heading <!--attrs {"textAlign":"center"}-->',
|
||||
);
|
||||
expect(docsCanonicallyEqual(alignedHeading, canonNormalized)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KNOWN DIVERGENCE — textStyle color is VALUE-NORMALIZED, not lost (item #7).
|
||||
//
|
||||
// The textStyle/color mark itself round-trips (the green CORPUS has the rgb()
|
||||
// form). But a `#hex` color is normalized to the equivalent `rgb(...)` string
|
||||
// by the HTML re-parser on import, and canonicalize.ts does NOT normalize color
|
||||
// formats — so a `#hex` original is not STRING-identical to its round trip even
|
||||
// though the color is semantically preserved. Locked here so the boundary is
|
||||
// explicit: author color fixtures in rgb() form to stay in the green corpus.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('git-sync converter §13.1 KNOWN DIVERGENCE (textStyle color #hex -> rgb)', () => {
|
||||
it('normalizes a #hex text color to rgb() (semantically preserved, string-divergent)', async () => {
|
||||
const hexDoc = doc(
|
||||
para(text('green', [{ type: 'textStyle', attrs: { color: '#00ff00' } }])),
|
||||
);
|
||||
|
||||
const { canonNormalized } = await runGate(hexDoc);
|
||||
|
||||
// Color survives, but as the normalized rgb() string.
|
||||
expect(canonNormalized).toEqual({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'green',
|
||||
marks: [{ type: 'textStyle', attrs: { color: 'rgb(0, 255, 0)' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
// Not string-identical to the #hex original.
|
||||
expect(docsCanonicallyEqual(hexDoc, canonNormalized)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Backward-filled LCS length table for sequences `a` and `b`: `dp[i][j]` is the
|
||||
* length of the longest common subsequence of the suffixes `a[i:]` and `b[j:]`.
|
||||
* O(n*m) time/space — fine for page block counts.
|
||||
*
|
||||
* Shared by the two-way block diff (`yjs-body-merge.diffBlocks`) and the
|
||||
* three-way merge planner (`three-way-merge.lcsPairs`) so the (identical) table
|
||||
* construction lives in ONE place; each caller does its own traceback over the
|
||||
* returned table.
|
||||
*/
|
||||
export function buildLcsTable(a: string[], b: string[]): number[][] {
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
const dp: number[][] = Array.from({ length: n + 1 }, () =>
|
||||
new Array(m + 1).fill(0),
|
||||
);
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
dp[i][j] =
|
||||
a[i] === b[j]
|
||||
? dp[i + 1][j + 1] + 1
|
||||
: Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
return dp;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { diff3Plan, type Pick } from './three-way-merge';
|
||||
|
||||
// Materialize a plan into the merged key sequence for assertion.
|
||||
function apply(plan: Pick[], live: string[], target: string[]): string[] {
|
||||
return plan.map((p) => (p.src === 'live' ? live[p.index] : target[p.index]));
|
||||
}
|
||||
|
||||
const merge = (o: string[], a: string[], b: string[]): string[] =>
|
||||
apply(diff3Plan(o, a, b), a, b);
|
||||
|
||||
describe('diff3Plan red-team #9 (human edit + adjacent git insert)', () => {
|
||||
it('keeps human block-2 edit AND applies git insert of 2.5', () => {
|
||||
// base: 1 2 3
|
||||
// live: 1 H 3 (human rewrote block 2)
|
||||
// target: 1 2 2.5 3 (git inserted 2.5 after block 2)
|
||||
expect(
|
||||
merge(['1', '2', '3'], ['1', 'H', '3'], ['1', '2', '2.5', '3']),
|
||||
).toEqual(['1', 'H', '2.5', '3']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
diff3Plan,
|
||||
diff3PlanWithConflicts,
|
||||
type Pick,
|
||||
} from './three-way-merge';
|
||||
|
||||
// Materialize a plan into the merged key sequence for assertion.
|
||||
function apply(plan: Pick[], live: string[], target: string[]): string[] {
|
||||
return plan.map((p) => (p.src === 'live' ? live[p.index] : target[p.index]));
|
||||
}
|
||||
|
||||
const merge = (o: string[], a: string[], b: string[]): string[] =>
|
||||
apply(diff3Plan(o, a, b), a, b);
|
||||
|
||||
describe('diff3Plan (block-level three-way merge)', () => {
|
||||
it('identical on all three sides -> unchanged (all from live)', () => {
|
||||
const plan = diff3Plan(['1', '2', '3'], ['1', '2', '3'], ['1', '2', '3']);
|
||||
expect(plan.every((p) => p.src === 'live')).toBe(true);
|
||||
expect(apply(plan, ['1', '2', '3'], ['1', '2', '3'])).toEqual(['1', '2', '3']);
|
||||
});
|
||||
|
||||
it('git changed a block the human did not -> takes git', () => {
|
||||
expect(merge(['1', '2', '3'], ['1', '2', '3'], ['1', '9', '3'])).toEqual([
|
||||
'1',
|
||||
'9',
|
||||
'3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('human changed a block git did not -> KEEPS the human edit (the core 3-way win)', () => {
|
||||
expect(merge(['1', '2', '3'], ['1', 'H', '3'], ['1', '2', '3'])).toEqual([
|
||||
'1',
|
||||
'H',
|
||||
'3',
|
||||
]);
|
||||
});
|
||||
|
||||
// Bug #2 observability: diff3PlanWithConflicts reports SAME-BLOCK conflicts so
|
||||
// the caller can surface the "git wins" loss (log + history pin) instead of
|
||||
// dropping the human side silently.
|
||||
describe('diff3PlanWithConflicts (same-block conflict reporting)', () => {
|
||||
it('reports 0 conflicts when sides changed DIFFERENT blocks (clean merge)', () => {
|
||||
const r = diff3PlanWithConflicts(
|
||||
['1', '2', '3'],
|
||||
['H', '2', '3'],
|
||||
['1', '2', 'G'],
|
||||
);
|
||||
expect(r.conflicts).toBe(0);
|
||||
expect(apply(r.picks, ['H', '2', '3'], ['1', '2', 'G'])).toEqual([
|
||||
'H',
|
||||
'2',
|
||||
'G',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports 1 conflict and git wins when BOTH rewrote the SAME block', () => {
|
||||
const r = diff3PlanWithConflicts(
|
||||
['1', '2', '3'],
|
||||
['1', 'H', '3'], // human rewrote block 2
|
||||
['1', 'G', '3'], // git rewrote block 2
|
||||
);
|
||||
expect(r.conflicts).toBe(1);
|
||||
// Git wins the contested block; the human 'H' is NOT in the picks.
|
||||
expect(apply(r.picks, ['1', 'H', '3'], ['1', 'G', '3'])).toEqual([
|
||||
'1',
|
||||
'G',
|
||||
'3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does NOT count a git-only region (no human content to lose) as a conflict', () => {
|
||||
const r = diff3PlanWithConflicts(
|
||||
['1', '2', '3'],
|
||||
['1', '2', '3'], // human unchanged
|
||||
['1', '9', '3'], // git rewrote block 2
|
||||
);
|
||||
expect(r.conflicts).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('human and git changed DIFFERENT blocks -> both preserved', () => {
|
||||
// human rewrote block 1, git rewrote block 3.
|
||||
expect(merge(['1', '2', '3'], ['H', '2', '3'], ['1', '2', 'G'])).toEqual([
|
||||
'H',
|
||||
'2',
|
||||
'G',
|
||||
]);
|
||||
});
|
||||
|
||||
it('human inserted a block AND git changed a different block -> both preserved', () => {
|
||||
expect(
|
||||
merge(['1', '2', '3'], ['1', '1.5', '2', '3'], ['1', '2', 'G']),
|
||||
).toEqual(['1', '1.5', '2', 'G']);
|
||||
});
|
||||
|
||||
it('both changed the SAME block -> conflict resolves to git', () => {
|
||||
expect(merge(['1', '2', '3'], ['1', 'H', '3'], ['1', 'G', '3'])).toEqual([
|
||||
'1',
|
||||
'G',
|
||||
'3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('both made the SAME edit -> that edit (no duplication)', () => {
|
||||
expect(merge(['1', '2', '3'], ['1', 'X', '3'], ['1', 'X', '3'])).toEqual([
|
||||
'1',
|
||||
'X',
|
||||
'3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('human deleted a block git left alone -> deletion preserved', () => {
|
||||
expect(merge(['1', '2', '3'], ['1', '3'], ['1', '2', '3'])).toEqual([
|
||||
'1',
|
||||
'3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('git deleted a block the human left alone -> deletion applied', () => {
|
||||
expect(merge(['1', '2', '3'], ['1', '2', '3'], ['1', '3'])).toEqual([
|
||||
'1',
|
||||
'3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('both deleted the same block -> gone (no conflict)', () => {
|
||||
expect(merge(['1', '2', '3'], ['1', '3'], ['1', '3'])).toEqual(['1', '3']);
|
||||
});
|
||||
|
||||
it('git appended a trailing block -> appended', () => {
|
||||
expect(merge(['1', '2'], ['1', '2'], ['1', '2', '3'])).toEqual([
|
||||
'1',
|
||||
'2',
|
||||
'3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('human appended a trailing block git did not -> kept', () => {
|
||||
expect(merge(['1', '2'], ['1', '2', '3'], ['1', '2'])).toEqual([
|
||||
'1',
|
||||
'2',
|
||||
'3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('empty base, git provides content (brand-new page body) -> git content', () => {
|
||||
expect(merge([], [], ['1', '2'])).toEqual(['1', '2']);
|
||||
});
|
||||
|
||||
it('git changed block 1, human edited block 3, far apart -> both kept', () => {
|
||||
expect(
|
||||
merge(
|
||||
['a', 'b', 'c', 'd', 'e'],
|
||||
['a', 'b', 'c', 'd', 'E'],
|
||||
['A', 'b', 'c', 'd', 'e'],
|
||||
),
|
||||
).toEqual(['A', 'b', 'c', 'd', 'E']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Pure block-level THREE-WAY merge planner (diff3) over arrays of opaque block
|
||||
* keys. Used by the git-sync body write to merge an incoming git body into the
|
||||
* live page using the last-synced version as the common ancestor (review #5):
|
||||
*
|
||||
* - a block only the human changed (live != base, git == base) -> keep LIVE
|
||||
* - a block only git changed (git != base, live == base) -> take GIT
|
||||
* - a block both sides changed (a real conflict) -> GIT wins
|
||||
* - inserts/deletes from either side are preserved when unambiguous
|
||||
*
|
||||
* Content-agnostic: it works on string keys and returns the merged block order as
|
||||
* picks ({ src: 'live'|'target', index }) — the caller (the Yjs applier)
|
||||
* materializes them — so the whole algorithm is unit-testable on plain arrays.
|
||||
*
|
||||
* Algorithm: anchor on base blocks present (unchanged) in BOTH live and target
|
||||
* (their LCS-with-base intersection). Between consecutive anchors lies one region
|
||||
* the human and/or git rewrote; resolve each region three-way. Stable anchor
|
||||
* blocks are emitted from LIVE so the applier keeps the existing Yjs block
|
||||
* instances (and the human's in-flight edits) in place.
|
||||
*
|
||||
* LOCATION (deferred): this and its `lcs.ts` sibling are pure, framework-free and
|
||||
* could conceptually live in `packages/git-sync` (the engine). They are kept in
|
||||
* the server integration on purpose: `packages/git-sync` is a VENDORED engine
|
||||
* (pinned upstream, manually re-synced), so adding first-party files there
|
||||
* complicates the re-sync story, and the only consumer today is the server. Move
|
||||
* them into the engine only once the vendoring re-sync story is settled.
|
||||
*/
|
||||
|
||||
import { buildLcsTable } from './lcs';
|
||||
|
||||
/** Matched index pairs of the longest common subsequence of `a` and `b`. */
|
||||
function lcsPairs(a: string[], b: string[]): Array<[number, number]> {
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
const dp = buildLcsTable(a, b);
|
||||
const pairs: Array<[number, number]> = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) {
|
||||
pairs.push([i, j]);
|
||||
i++;
|
||||
j++;
|
||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||
i++;
|
||||
} else {
|
||||
j++;
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/** o-index -> matched index in the other side (only for LCS-matched blocks). */
|
||||
function matchMap(pairs: Array<[number, number]>): Map<number, number> {
|
||||
const m = new Map<number, number>();
|
||||
for (const [o, x] of pairs) m.set(o, x);
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* One change `side` made to `base` within a region: base blocks `[oStart,oEnd)`
|
||||
* were replaced by the side's blocks listed in `content` (region-local indices).
|
||||
* A pure insert has `oStart === oEnd`; a pure delete has empty `content`.
|
||||
*/
|
||||
interface Hunk {
|
||||
oStart: number;
|
||||
oEnd: number;
|
||||
content: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff `o` against one side as a list of non-overlapping hunks (the base spans
|
||||
* the side rewrote/inserted/deleted), derived from their LCS alignment.
|
||||
*/
|
||||
function buildHunks(o: string[], side: string[]): Hunk[] {
|
||||
const pairs = lcsPairs(o, side); // [oIdx, sideIdx] kept (unchanged) blocks
|
||||
const hunks: Hunk[] = [];
|
||||
let prevO = -1;
|
||||
let prevS = -1;
|
||||
const flush = (curO: number, curS: number): void => {
|
||||
const oStart = prevO + 1;
|
||||
const oEnd = curO;
|
||||
const content: number[] = [];
|
||||
for (let s = prevS + 1; s < curS; s++) content.push(s);
|
||||
if (oEnd > oStart || content.length > 0) hunks.push({ oStart, oEnd, content });
|
||||
};
|
||||
for (const [oIdx, sIdx] of pairs) {
|
||||
flush(oIdx, sIdx);
|
||||
prevO = oIdx;
|
||||
prevS = sIdx;
|
||||
}
|
||||
flush(o.length, side.length);
|
||||
return hunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do two hunks (one per side) touch the same base region? Pure inserts only
|
||||
* collide when nested strictly inside the other hunk's base span (or, for two
|
||||
* inserts, at the same gap); changes sitting at a shared boundary do not.
|
||||
*/
|
||||
function hunksOverlap(a: Hunk, b: Hunk): boolean {
|
||||
const aIns = a.oStart === a.oEnd;
|
||||
const bIns = b.oStart === b.oEnd;
|
||||
if (aIns && bIns) return a.oStart === b.oStart;
|
||||
if (aIns) return b.oStart < a.oStart && a.oStart < b.oEnd;
|
||||
if (bIns) return a.oStart < b.oStart && b.oStart < a.oEnd;
|
||||
return Math.max(a.oStart, b.oStart) < Math.min(a.oEnd, b.oEnd);
|
||||
}
|
||||
|
||||
interface LocalPick {
|
||||
src: 'live' | 'target';
|
||||
local: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fine-grained three-way merge of ONE inter-anchor region. Combines the human's
|
||||
* and git's NON-overlapping hunks (e.g. a human edit to one block plus a git
|
||||
* insert/delete of OTHER blocks in the same region) so neither change is lost.
|
||||
* Returns the merged region as region-local picks, or `null` when the two sides
|
||||
* changed the SAME base block — a genuine conflict the caller resolves by the
|
||||
* original all-or-nothing rule (git wins the whole region).
|
||||
*/
|
||||
function tryMergeRegion(
|
||||
o: string[],
|
||||
a: string[],
|
||||
b: string[],
|
||||
): LocalPick[] | null {
|
||||
// Agreement short-circuit (review #11). When live (a) and target (b) are
|
||||
// identical, both sides converged on the SAME result — diff3 "agreement", NOT
|
||||
// a conflict. This is the dominant echo case (live == target != base) that
|
||||
// otherwise trips the overlap check below and is logged as a false "N same-block
|
||||
// conflict(s) resolved to the git version", masking REAL data-loss signals.
|
||||
// Emit the region straight from live (which equals target); no conflict.
|
||||
if (a.length === b.length && a.every((v, i) => v === b[i])) {
|
||||
return a.map((_v, i) => ({ src: 'live', local: i }) as LocalPick);
|
||||
}
|
||||
|
||||
const aHunks = buildHunks(o, a);
|
||||
const bHunks = buildHunks(o, b);
|
||||
|
||||
// Any overlap between a human hunk and a git hunk is a real conflict; bail so
|
||||
// the caller falls back to git-wins (preserving the original behavior).
|
||||
for (const ah of aHunks) {
|
||||
for (const bh of bHunks) {
|
||||
if (hunksOverlap(ah, bh)) return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Disjoint: live index of each base block that BOTH sides kept (stable).
|
||||
const aKept = matchMap(lcsPairs(o, a)); // base index -> live index
|
||||
|
||||
const out: LocalPick[] = [];
|
||||
let pa = 0;
|
||||
let pb = 0;
|
||||
let oi = 0;
|
||||
while (oi < o.length || pa < aHunks.length || pb < bHunks.length) {
|
||||
const ah = pa < aHunks.length ? aHunks[pa] : null;
|
||||
const bh = pb < bHunks.length ? bHunks[pb] : null;
|
||||
const nextStart = Math.min(
|
||||
ah ? ah.oStart : o.length,
|
||||
bh ? bh.oStart : o.length,
|
||||
);
|
||||
|
||||
// Emit stable base blocks (kept by both) until the next hunk, from LIVE.
|
||||
while (oi < nextStart) {
|
||||
out.push({ src: 'live', local: aKept.get(oi) as number });
|
||||
oi++;
|
||||
}
|
||||
if (!ah && !bh) break;
|
||||
|
||||
// Apply the hunk at oi. When both sides act here they are disjoint, so the
|
||||
// pure-insert (oEnd === oi) is emitted before the side that consumes base oi.
|
||||
const aHere = ah !== null && ah.oStart === oi;
|
||||
const bHere = bh !== null && bh.oStart === oi;
|
||||
let useA: boolean;
|
||||
if (aHere && bHere) {
|
||||
useA = ah!.oEnd === oi; // insert side first; otherwise either order is fine
|
||||
} else {
|
||||
useA = aHere;
|
||||
}
|
||||
const h = (useA ? ah : bh) as Hunk;
|
||||
const src: 'live' | 'target' = useA ? 'live' : 'target';
|
||||
for (const idx of h.content) out.push({ src, local: idx });
|
||||
oi = h.oEnd;
|
||||
if (useA) pa++;
|
||||
else pb++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface Pick {
|
||||
src: 'live' | 'target';
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The merged block order PLUS how many regions resolved as a genuine SAME-BLOCK
|
||||
* conflict (both sides rewrote the same base block — `tryMergeRegion` returned
|
||||
* null and git won the whole region, so the live/human version of those blocks
|
||||
* is NOT in `picks`). `conflicts > 0` is the OBSERVABLE signal the caller uses to
|
||||
* surface "git won a concurrent same-block edit" (log it + pin the human
|
||||
* baseline to page history) instead of dropping the human side silently.
|
||||
*/
|
||||
export interface Diff3Result {
|
||||
picks: Pick[];
|
||||
conflicts: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Three-way merge of base `o`, live `a`, target `b` (arrays of block keys).
|
||||
* Returns the merged block order as picks from live/target. Thin wrapper over
|
||||
* `diff3PlanWithConflicts` (kept for the existing pure-array callers/tests).
|
||||
*/
|
||||
export function diff3Plan(o: string[], a: string[], b: string[]): Pick[] {
|
||||
return diff3PlanWithConflicts(o, a, b).picks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `diff3Plan` but also reports the SAME-BLOCK conflict count (see
|
||||
* `Diff3Result`). A region where both the human and git rewrote the same base
|
||||
* block cannot be merged automatically; the rule is deterministic — GIT WINS the
|
||||
* whole region — but the human's version of those blocks is then absent from the
|
||||
* picks, so we count it so the caller can make the loss observable/recoverable
|
||||
* rather than silent (the documented conflict contract).
|
||||
*/
|
||||
export function diff3PlanWithConflicts(
|
||||
o: string[],
|
||||
a: string[],
|
||||
b: string[],
|
||||
): Diff3Result {
|
||||
const oToA = matchMap(lcsPairs(o, a));
|
||||
const oToB = matchMap(lcsPairs(o, b));
|
||||
|
||||
const res: Pick[] = [];
|
||||
let conflicts = 0;
|
||||
let oi = 0;
|
||||
let ai = 0;
|
||||
let bi = 0;
|
||||
|
||||
for (;;) {
|
||||
// Next anchor: a base block present (unchanged) in BOTH live and target.
|
||||
let anchor = oi;
|
||||
while (anchor < o.length && !(oToA.has(anchor) && oToB.has(anchor))) {
|
||||
anchor++;
|
||||
}
|
||||
const aEnd = anchor < o.length ? (oToA.get(anchor) as number) : a.length;
|
||||
const bEnd = anchor < o.length ? (oToB.get(anchor) as number) : b.length;
|
||||
|
||||
// Resolve the region [oi,anchor) that one or both sides rewrote/inserted.
|
||||
// Try a fine-grained three-way merge first so a human block-edit survives a
|
||||
// git insert/delete of OTHER blocks in the same region; only a genuine
|
||||
// same-block conflict (null) falls back to the original git-wins rule.
|
||||
const merged = tryMergeRegion(
|
||||
o.slice(oi, anchor),
|
||||
a.slice(ai, aEnd),
|
||||
b.slice(bi, bEnd),
|
||||
);
|
||||
if (merged) {
|
||||
for (const p of merged) {
|
||||
res.push(
|
||||
p.src === 'live'
|
||||
? { src: 'live', index: ai + p.local }
|
||||
: { src: 'target', index: bi + p.local },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// SAME-BLOCK CONFLICT: count it ONLY when the human side actually had
|
||||
// content in this region that git's win discards (live region non-empty).
|
||||
// A region only git rewrote (live region empty) is not a human loss.
|
||||
if (aEnd > ai) conflicts++;
|
||||
for (let k = bi; k < bEnd; k++) res.push({ src: 'target', index: k });
|
||||
}
|
||||
|
||||
if (anchor >= o.length) break;
|
||||
|
||||
// Emit the stable anchor block from LIVE, then advance past it on all sides.
|
||||
res.push({ src: 'live', index: aEnd });
|
||||
ai = aEnd + 1;
|
||||
bi = bEnd + 1;
|
||||
oi = anchor + 1;
|
||||
}
|
||||
|
||||
return { picks: res, conflicts };
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import * as Y from 'yjs';
|
||||
import {
|
||||
markdownToProseMirror,
|
||||
convertProseMirrorToMarkdown,
|
||||
} from '@docmost/git-sync';
|
||||
|
||||
import { tiptapExtensions } from '../collaboration.util';
|
||||
import { mergeXmlFragments, mergeXmlFragments3Way } from './yjs-body-merge';
|
||||
|
||||
/**
|
||||
* Regression for the QA #119 callout findings (body-duplication re-verify +
|
||||
* "callout strips the whole body"). These reproduce the ACTUAL live merge path:
|
||||
*
|
||||
* live = TiptapTransformer.toYdoc(editor JSON, tiptapExtensions) (the
|
||||
* collaboration server's materialization — schema defaults stamped)
|
||||
* git = toYdoc(markdownToProseMirror(convertProseMirrorToMarkdown(editor)))
|
||||
* (the engine round-trip the push side feeds into writePageBody)
|
||||
*
|
||||
* A page containing a callout (with a neighbouring heading + paragraphs) must:
|
||||
* - merge with ZERO ops on an unchanged resync (no duplication — bug #1), and
|
||||
* - NEVER lose blocks / collapse to empty (no strip — bug #2),
|
||||
* across repeated cycles, for every editor-canonical callout type.
|
||||
*/
|
||||
|
||||
const toYdoc = (content: unknown[]) =>
|
||||
TiptapTransformer.toYdoc(
|
||||
{ type: 'doc', content },
|
||||
'default',
|
||||
tiptapExtensions as any,
|
||||
);
|
||||
|
||||
const blockTypes = (f: Y.XmlFragment) =>
|
||||
f.toArray().map((n: any) => n.nodeName);
|
||||
|
||||
function editorPage(calloutType: string) {
|
||||
return [
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { id: 'h1', level: 1 },
|
||||
content: [{ type: 'text', text: 'Title here' }],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { id: 'p1' },
|
||||
content: [{ type: 'text', text: 'Para before callout' }],
|
||||
},
|
||||
{
|
||||
type: 'callout',
|
||||
attrs: { type: calloutType },
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { id: 'pc' },
|
||||
content: [{ type: 'text', text: 'Inside the callout' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { id: 'p2' },
|
||||
content: [{ type: 'text', text: 'Para after callout' }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function gitRoundTrip(content: unknown[]): Promise<any[]> {
|
||||
const md = await convertProseMirrorToMarkdown({ type: 'doc', content });
|
||||
const json = await markdownToProseMirror(md);
|
||||
return json.content;
|
||||
}
|
||||
|
||||
describe('git-sync callout merge is idempotent + non-destructive (QA #119)', () => {
|
||||
for (const type of ['info', 'note', 'warning', 'danger', 'success', 'default']) {
|
||||
it(`callout(${type}) resyncs with 0 ops and never strips the body`, async () => {
|
||||
const editor = editorPage(type);
|
||||
const gitContent = await gitRoundTrip(editor);
|
||||
|
||||
const liveDoc = toYdoc(editor);
|
||||
const live = liveDoc.getXmlFragment('default');
|
||||
const before = live.toArray().length;
|
||||
expect(before).toBe(4);
|
||||
|
||||
// 2-way: live vs the git round-trip -> no-op (no dup, no strip).
|
||||
let applied = -1;
|
||||
liveDoc.transact(() => {
|
||||
applied = mergeXmlFragments(live, toYdoc(gitContent).getXmlFragment('default'));
|
||||
});
|
||||
expect(applied).toBe(0);
|
||||
expect(live.toArray().length).toBe(before);
|
||||
|
||||
// 3-way across 4 cycles with base == git (the steady-state) -> stable.
|
||||
for (let cycle = 0; cycle < 4; cycle++) {
|
||||
let a = -1;
|
||||
liveDoc.transact(() => {
|
||||
a = mergeXmlFragments3Way(
|
||||
live,
|
||||
toYdoc(gitContent).getXmlFragment('default'),
|
||||
toYdoc(gitContent).getXmlFragment('default'),
|
||||
);
|
||||
});
|
||||
expect(a).toBe(0);
|
||||
expect(live.toArray().length).toBe(before);
|
||||
expect(blockTypes(live)).toEqual([
|
||||
'heading',
|
||||
'paragraph',
|
||||
'callout',
|
||||
'paragraph',
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('3-way with a stale base (callout JUST added) keeps the callout + neighbours', async () => {
|
||||
// base = the previously-synced version WITHOUT the callout (git round-trip);
|
||||
// the human just inserted the callout -> the merge must KEEP everything.
|
||||
const prev = [
|
||||
{ type: 'heading', attrs: { id: 'h1', level: 1 }, content: [{ type: 'text', text: 'Title here' }] },
|
||||
{ type: 'paragraph', attrs: { id: 'p1' }, content: [{ type: 'text', text: 'Para before callout' }] },
|
||||
{ type: 'paragraph', attrs: { id: 'p2' }, content: [{ type: 'text', text: 'Para after callout' }] },
|
||||
];
|
||||
const editor = editorPage('info');
|
||||
const baseContent = await gitRoundTrip(prev);
|
||||
const gitContent = await gitRoundTrip(editor);
|
||||
|
||||
const liveDoc = toYdoc(editor);
|
||||
const live = liveDoc.getXmlFragment('default');
|
||||
liveDoc.transact(() => {
|
||||
mergeXmlFragments3Way(
|
||||
live,
|
||||
toYdoc(gitContent).getXmlFragment('default'),
|
||||
toYdoc(baseContent).getXmlFragment('default'),
|
||||
);
|
||||
});
|
||||
// Body survives in full — NOT stripped to empty / a lone paragraph.
|
||||
expect(blockTypes(live)).toEqual([
|
||||
'heading',
|
||||
'paragraph',
|
||||
'callout',
|
||||
'paragraph',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('git-sync callout type fidelity (QA "callout type -> [!info]")', () => {
|
||||
for (const type of ['info', 'note', 'warning', 'danger', 'success', 'default']) {
|
||||
it(`preserves callout type "${type}" across the engine round-trip`, async () => {
|
||||
const content = editorPage(type);
|
||||
const gitContent = await gitRoundTrip(content);
|
||||
const co = gitContent.find((b: any) => b.type === 'callout');
|
||||
expect(co?.attrs?.type).toBe(type);
|
||||
});
|
||||
}
|
||||
|
||||
it('maps a known GitHub/Obsidian alias to the editor banner (tip -> success)', async () => {
|
||||
// `tip` is not a schema callout type — it is an input alias the editor itself
|
||||
// maps onto the supported set (GITHUB_ALERT_TYPE_MAP: tip -> success). git-sync
|
||||
// mirrors that so the ingest lands on the closest banner instead of flatly info.
|
||||
const content = editorPage('tip');
|
||||
const gitContent = await gitRoundTrip(content);
|
||||
const co = gitContent.find((b: any) => b.type === 'callout');
|
||||
expect(co?.attrs?.type).toBe('success');
|
||||
});
|
||||
|
||||
it('flattens a genuinely unknown callout type to info', async () => {
|
||||
const content = editorPage('banana'); // not a type and not a known alias
|
||||
const gitContent = await gitRoundTrip(content);
|
||||
const co = gitContent.find((b: any) => b.type === 'callout');
|
||||
expect(co?.attrs?.type).toBe('info');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { mergeXmlFragments, mergeXmlFragments3Way } from './yjs-body-merge';
|
||||
|
||||
/**
|
||||
* Regression for the HIGH-severity runaway whole-body duplication: a page body
|
||||
* was RE-APPENDED in full on every git-sync reconcile cycle, unbounded, with NO
|
||||
* client connected.
|
||||
*
|
||||
* ROOT CAUSE (confirmed in-process against the real failing page): the LIVE Yjs
|
||||
* document materializes the editor-schema default `indent: 0` on every
|
||||
* paragraph/heading (and on the paragraph inside every list item, callout, and
|
||||
* table cell), but a body re-imported from git — parsed from clean markdown —
|
||||
* carries NO indent attribute. So every live block's comparison key differed from
|
||||
* the same block coming back from git; the three-way merge could anchor on
|
||||
* NOTHING, and the trailing unit that git's export already contained (but the
|
||||
* merge could not match against the byte-identical live tail) was re-appended
|
||||
* each cycle. Each grown export then diverged from the last-pushed base by one
|
||||
* more unit — a self-sustaining loop.
|
||||
*
|
||||
* The fix normalizes the materialized default (`indent: 0`) out of the block key
|
||||
* (the schema-derived `serializeXmlNode` normalization in yjs-body-merge.ts drops
|
||||
* every attr equal to its ProseMirror-schema default; `indent: 0` is one such),
|
||||
* so a live block compares equal to its git-round-tripped twin and the resync is
|
||||
* a true no-op. The sibling `yjs-body-merge.schema-defaults.spec.ts` covers the
|
||||
* rest of the bug class (image.align, link mark internal, …).
|
||||
*
|
||||
* These tests model that EXACTLY at the Yjs level: a LIVE fragment whose blocks
|
||||
* carry `indent: 0` + block ids, versus a git-derived fragment of the SAME
|
||||
* content with neither — for a body built from BYTE-IDENTICAL units that each
|
||||
* contain a heading, a paragraph, a callout, and a table with empty cells (the
|
||||
* trigger). RED before the fix (the merge applies > 0 ops and the body grows),
|
||||
* GREEN after (0 ops, no growth).
|
||||
*/
|
||||
|
||||
type Attrs = Record<string, string | number>;
|
||||
|
||||
function el(
|
||||
name: string,
|
||||
attrs: Attrs,
|
||||
children: (Y.XmlElement | Y.XmlText)[],
|
||||
) {
|
||||
const e = new Y.XmlElement(name);
|
||||
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v as string);
|
||||
if (children.length) e.insert(0, children);
|
||||
return e;
|
||||
}
|
||||
|
||||
function text(s: string): Y.XmlText {
|
||||
const t = new Y.XmlText();
|
||||
if (s) t.insert(0, s);
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* One byte-identical content unit (heading / paragraph / callout / table-with-
|
||||
* empty-cells). `live` toggles the two things that exist ONLY in the live Yjs
|
||||
* doc and NOT in a git round-trip: the materialized `indent: 0` default and the
|
||||
* per-block `id`. `n` makes each unit's ids unique (as the editor would stamp)
|
||||
* while keeping the visible CONTENT byte-identical across units.
|
||||
*/
|
||||
function unit(
|
||||
live: boolean,
|
||||
n: number,
|
||||
headingText = 'Big Heading',
|
||||
): Y.XmlElement[] {
|
||||
const ind: Attrs = live ? { indent: 0 } : {};
|
||||
const id = (base: string): Attrs => (live ? { id: `${base}${n}` } : {});
|
||||
const para = (attrs: Attrs, s: string) =>
|
||||
el('paragraph', { ...attrs, ...ind }, [text(s)]);
|
||||
|
||||
const cell = (name: string) =>
|
||||
el(name, { colspan: 1, rowspan: 1 }, [para({}, '')]);
|
||||
|
||||
return [
|
||||
el('heading', { ...id('h'), level: 1, ...ind }, [text(headingText)]),
|
||||
para(id('p'), 'Para with the same words'),
|
||||
el('callout', { type: 'info' }, [para(id('c'), 'CalloutText here')]),
|
||||
el('table', {}, [
|
||||
el('tableRow', {}, [cell('tableHeader'), cell('tableHeader')]),
|
||||
el('tableRow', {}, [cell('tableCell'), cell('tableCell')]),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
function fragmentOf(units: Y.XmlElement[][]): {
|
||||
doc: Y.Doc;
|
||||
frag: Y.XmlFragment;
|
||||
} {
|
||||
const doc = new Y.Doc();
|
||||
const frag = doc.getXmlFragment('default');
|
||||
const blocks = units.flat();
|
||||
if (blocks.length) frag.insert(0, blocks);
|
||||
return { doc, frag };
|
||||
}
|
||||
|
||||
const blockCount = (frag: Y.XmlFragment): number => frag.toArray().length;
|
||||
|
||||
describe('git-sync reconcile import is idempotent (no whole-body duplication)', () => {
|
||||
const UNITS = 3;
|
||||
|
||||
it('3-way: identical content, live carries indent:0, base stale-by-one -> 0 ops, no growth', () => {
|
||||
// LIVE: the editor-stamped Yjs doc (indent:0 + ids on every block).
|
||||
const { doc: liveDoc, frag: live } = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => unit(true, i)),
|
||||
);
|
||||
// INCOMING (git export -> re-import): same content, NO indent / ids.
|
||||
const { frag: incoming } = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => unit(false, i)),
|
||||
);
|
||||
// BASE = last-pushed file, lagging by ONE unit (the realistic divergence
|
||||
// that drives the trailing insert-vs-insert).
|
||||
const { frag: base } = fragmentOf(
|
||||
Array.from({ length: UNITS - 1 }, (_, i) => unit(false, i)),
|
||||
);
|
||||
|
||||
const before = blockCount(live);
|
||||
let applied = -1;
|
||||
liveDoc.transact(() => {
|
||||
applied = mergeXmlFragments3Way(live, incoming, base);
|
||||
});
|
||||
|
||||
expect(applied).toBe(0);
|
||||
expect(blockCount(live)).toBe(before);
|
||||
});
|
||||
|
||||
it('3-way is a fixpoint across repeated cycles (does not grow)', () => {
|
||||
const { doc: liveDoc, frag: live } = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => unit(true, i)),
|
||||
);
|
||||
const incomingUnits = () =>
|
||||
fragmentOf(Array.from({ length: UNITS }, (_, i) => unit(false, i))).frag;
|
||||
const baseUnits = () =>
|
||||
fragmentOf(Array.from({ length: UNITS - 1 }, (_, i) => unit(false, i)))
|
||||
.frag;
|
||||
|
||||
const before = blockCount(live);
|
||||
for (let cycle = 0; cycle < 5; cycle++) {
|
||||
let applied = -1;
|
||||
liveDoc.transact(() => {
|
||||
applied = mergeXmlFragments3Way(live, incomingUnits(), baseUnits());
|
||||
});
|
||||
expect(applied).toBe(0);
|
||||
expect(blockCount(live)).toBe(before);
|
||||
}
|
||||
});
|
||||
|
||||
it('2-way: identical content, live carries indent:0 -> 0 ops, no growth', () => {
|
||||
const { doc: liveDoc, frag: live } = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => unit(true, i)),
|
||||
);
|
||||
const { frag: incoming } = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => unit(false, i)),
|
||||
);
|
||||
|
||||
const before = blockCount(live);
|
||||
let applied = -1;
|
||||
liveDoc.transact(() => {
|
||||
applied = mergeXmlFragments(live, incoming);
|
||||
});
|
||||
|
||||
expect(applied).toBe(0);
|
||||
expect(blockCount(live)).toBe(before);
|
||||
});
|
||||
|
||||
it('does NOT regress real edits: a git change to one block still lands', () => {
|
||||
const { doc: liveDoc, frag: live } = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => unit(true, i)),
|
||||
);
|
||||
const base = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => unit(false, i)),
|
||||
).frag;
|
||||
// git edits the heading text of the LAST unit.
|
||||
const incoming = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) =>
|
||||
unit(false, i, i === UNITS - 1 ? 'EDITED Heading' : 'Big Heading'),
|
||||
),
|
||||
).frag;
|
||||
|
||||
const before = blockCount(live);
|
||||
liveDoc.transact(() => {
|
||||
mergeXmlFragments3Way(live, incoming, base);
|
||||
});
|
||||
|
||||
// The edit landed, and the body did NOT grow (one block changed in place).
|
||||
const headings = live
|
||||
.toArray()
|
||||
.filter((b) => (b as Y.XmlElement).nodeName === 'heading')
|
||||
.map((b) =>
|
||||
(b as Y.XmlElement)
|
||||
.toArray()
|
||||
.map((c) => (c as Y.XmlText).toString())
|
||||
.join(''),
|
||||
);
|
||||
expect(headings).toContain('EDITED Heading');
|
||||
expect(blockCount(live)).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { tiptapExtensions } from '../collaboration.util';
|
||||
import { mergeXmlFragments, mergeXmlFragments3Way } from './yjs-body-merge';
|
||||
|
||||
/**
|
||||
* Regression for the BUG CLASS behind the runaway whole-body duplication: the
|
||||
* point-fix (7a7b840e) only normalized `indent: 0`, but the SAME divergence
|
||||
* recurs for every attribute whose editor-ext (server) schema default the live
|
||||
* Yjs doc MATERIALIZES while the git round-trip — which comes through the engine
|
||||
* schema (different, usually null, defaults) plus `y-prosemirror`'s null-attr
|
||||
* dropping — does NOT carry. Confirmed triggers beyond `indent`:
|
||||
*
|
||||
* - `image.align` : editor-ext default "center" (materialized) vs engine
|
||||
* default null (dropped) -> element-attr divergence.
|
||||
* - link mark `internal`: editor-ext default false (materialized) vs engine
|
||||
* default null -> MARK-attr divergence (the prior denylist
|
||||
* could not reach marks at all — they are serialized raw in
|
||||
* the XmlText delta).
|
||||
*
|
||||
* `highlight.colorName` is normalized too (defense-in-depth); it is NOT a strong
|
||||
* real-world trigger because BOTH schemas default it to null, but the schema-
|
||||
* derived normalization handles it for free and stays idempotent.
|
||||
*
|
||||
* The fix derives the defaults from the ACTUAL ProseMirror schema (getSchema of
|
||||
* the server tiptapExtensions) and drops any element- OR mark-attribute equal to
|
||||
* its schema default (or null/undefined) from the block comparison key — so a
|
||||
* live block compares equal to its git-round-tripped twin and an unchanged
|
||||
* resync applies 0 ops. RED before the fix (keys diverge -> ops > 0 / growth),
|
||||
* GREEN after.
|
||||
*/
|
||||
|
||||
type Attrs = Record<string, unknown>;
|
||||
|
||||
function el(
|
||||
name: string,
|
||||
attrs: Attrs,
|
||||
children: (Y.XmlElement | Y.XmlText)[],
|
||||
): Y.XmlElement {
|
||||
const e = new Y.XmlElement(name);
|
||||
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v as string);
|
||||
if (children.length) e.insert(0, children);
|
||||
return e;
|
||||
}
|
||||
|
||||
/** Text carrying marks, as the live Yjs doc stores them (XmlText format ops). */
|
||||
function markedText(s: string, marks: Record<string, unknown>): Y.XmlText {
|
||||
const t = new Y.XmlText();
|
||||
t.insert(0, s, marks);
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* One byte-identical RICH unit: a paragraph with a LINK, a top-level IMAGE, and
|
||||
* a paragraph with a HIGHLIGHT. `live` toggles exactly what the editor
|
||||
* materializes but a git round-trip does not: block `id`, `indent: 0`,
|
||||
* `image.align: "center"`, the link mark's `internal: false`, and the
|
||||
* highlight's `colorName: null`.
|
||||
*/
|
||||
function richUnit(live: boolean, n: number): Y.XmlElement[] {
|
||||
const ind: Attrs = live ? { indent: 0 } : {};
|
||||
const id = (base: string): Attrs => (live ? { id: `${base}${n}` } : {});
|
||||
|
||||
const linkMarks = live
|
||||
? {
|
||||
link: {
|
||||
href: 'https://example.com',
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer nofollow',
|
||||
class: null,
|
||||
title: null,
|
||||
internal: false, // editor-ext default, materialized
|
||||
},
|
||||
}
|
||||
: {
|
||||
link: {
|
||||
href: 'https://example.com',
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer nofollow',
|
||||
internal: null, // engine default
|
||||
},
|
||||
};
|
||||
|
||||
const hlMarks = live
|
||||
? { highlight: { color: '#ffd43b', colorName: null } }
|
||||
: { highlight: { color: '#ffd43b' } };
|
||||
|
||||
const imageAttrs: Attrs = live
|
||||
? { src: 'https://img.example.com/a.png', align: 'center' } // materialized
|
||||
: { src: 'https://img.example.com/a.png' }; // align:null dropped on git side
|
||||
|
||||
return [
|
||||
el('paragraph', { ...id('lp'), ...ind }, [
|
||||
markedText('click here', linkMarks),
|
||||
]),
|
||||
el('image', imageAttrs, []),
|
||||
el('paragraph', { ...id('hp'), ...ind }, [markedText('hot', hlMarks)]),
|
||||
];
|
||||
}
|
||||
|
||||
function fragmentOf(units: Y.XmlElement[][]): {
|
||||
doc: Y.Doc;
|
||||
frag: Y.XmlFragment;
|
||||
} {
|
||||
const doc = new Y.Doc();
|
||||
const frag = doc.getXmlFragment('default');
|
||||
const blocks = units.flat();
|
||||
if (blocks.length) frag.insert(0, blocks);
|
||||
return { doc, frag };
|
||||
}
|
||||
|
||||
const blockCount = (frag: Y.XmlFragment): number => frag.toArray().length;
|
||||
|
||||
describe('git-sync reconcile is idempotent for schema-default attrs (image/link/highlight)', () => {
|
||||
const UNITS = 3;
|
||||
|
||||
it('3-way: live carries image.align/link.internal/indent defaults, base stale-by-one -> 0 ops', () => {
|
||||
const { doc: liveDoc, frag: live } = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => richUnit(true, i)),
|
||||
);
|
||||
const { frag: incoming } = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => richUnit(false, i)),
|
||||
);
|
||||
const { frag: base } = fragmentOf(
|
||||
Array.from({ length: UNITS - 1 }, (_, i) => richUnit(false, i)),
|
||||
);
|
||||
|
||||
const before = blockCount(live);
|
||||
let applied = -1;
|
||||
liveDoc.transact(() => {
|
||||
applied = mergeXmlFragments3Way(live, incoming, base);
|
||||
});
|
||||
|
||||
expect(applied).toBe(0);
|
||||
expect(blockCount(live)).toBe(before);
|
||||
});
|
||||
|
||||
it('2-way: live carries the materialized defaults -> 0 ops, no growth', () => {
|
||||
const { doc: liveDoc, frag: live } = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => richUnit(true, i)),
|
||||
);
|
||||
const { frag: incoming } = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => richUnit(false, i)),
|
||||
);
|
||||
|
||||
const before = blockCount(live);
|
||||
let applied = -1;
|
||||
liveDoc.transact(() => {
|
||||
applied = mergeXmlFragments(live, incoming);
|
||||
});
|
||||
|
||||
expect(applied).toBe(0);
|
||||
expect(blockCount(live)).toBe(before);
|
||||
});
|
||||
|
||||
it('is a fixpoint across repeated cycles (does not grow)', () => {
|
||||
const { doc: liveDoc, frag: live } = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => richUnit(true, i)),
|
||||
);
|
||||
const incoming = () =>
|
||||
fragmentOf(Array.from({ length: UNITS }, (_, i) => richUnit(false, i)))
|
||||
.frag;
|
||||
const base = () =>
|
||||
fragmentOf(
|
||||
Array.from({ length: UNITS - 1 }, (_, i) => richUnit(false, i)),
|
||||
).frag;
|
||||
|
||||
const before = blockCount(live);
|
||||
for (let cycle = 0; cycle < 5; cycle++) {
|
||||
let applied = -1;
|
||||
liveDoc.transact(() => {
|
||||
applied = mergeXmlFragments3Way(live, incoming(), base());
|
||||
});
|
||||
expect(applied).toBe(0);
|
||||
expect(blockCount(live)).toBe(before);
|
||||
}
|
||||
});
|
||||
|
||||
it('does NOT regress a genuine non-default value (a real link.href / image.align:left still diffs)', () => {
|
||||
const { doc: liveDoc, frag: live } = fragmentOf([richUnit(true, 0)]);
|
||||
const base = fragmentOf([richUnit(false, 0)]).frag;
|
||||
// git genuinely changes the image alignment to a NON-default value.
|
||||
const incomingUnit = richUnit(false, 0);
|
||||
(incomingUnit[1] as Y.XmlElement).setAttribute('align', 'left');
|
||||
const incoming = fragmentOf([incomingUnit]).frag;
|
||||
|
||||
liveDoc.transact(() => {
|
||||
mergeXmlFragments3Way(live, incoming, base);
|
||||
});
|
||||
|
||||
const img = live
|
||||
.toArray()
|
||||
.find((b) => (b as Y.XmlElement).nodeName === 'image') as Y.XmlElement;
|
||||
expect(img.getAttribute('align')).toBe('left');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* FAITHFUL end-to-end proof through the REAL server transformer: build the live
|
||||
* doc the way the collaboration server does (defaults omitted in the JSON ->
|
||||
* TiptapTransformer.toYdoc MATERIALIZES image.align:"center", link.internal:false,
|
||||
* indent:0) versus the git-derived doc (engine-style: defaults emitted as
|
||||
* explicit null, no block ids). An unchanged resync must apply 0 ops.
|
||||
*/
|
||||
describe('git-sync reconcile is idempotent through the real toYdoc materialization', () => {
|
||||
const liveContent = [
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { id: 'p1' },
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'click here',
|
||||
marks: [{ type: 'link', attrs: { href: 'https://example.com' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'image', attrs: { src: 'https://img.example.com/a.png' } },
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { id: 'p2' },
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'hot',
|
||||
marks: [{ type: 'highlight', attrs: { color: '#ffd43b' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// git/engine-style: explicit nulls for the engine-default attrs, no ids.
|
||||
const gitContent = [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'click here',
|
||||
marks: [
|
||||
{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: 'https://example.com',
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer nofollow',
|
||||
class: null,
|
||||
title: null,
|
||||
internal: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
attrs: { src: 'https://img.example.com/a.png', align: null },
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'hot',
|
||||
marks: [
|
||||
{ type: 'highlight', attrs: { color: '#ffd43b', colorName: null } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const toYdoc = (content: unknown[]) =>
|
||||
TiptapTransformer.toYdoc(
|
||||
{ type: 'doc', content },
|
||||
'default',
|
||||
tiptapExtensions as any,
|
||||
);
|
||||
|
||||
it('3-way: materialized-default live vs engine-style git, base stale-by-one -> 0 ops', () => {
|
||||
const liveDoc = toYdoc(liveContent);
|
||||
const targetDoc = toYdoc(gitContent);
|
||||
const baseDoc = toYdoc(gitContent.slice(0, gitContent.length - 1));
|
||||
|
||||
const live = liveDoc.getXmlFragment('default');
|
||||
const before = live.toArray().length;
|
||||
let applied = -1;
|
||||
liveDoc.transact(() => {
|
||||
applied = mergeXmlFragments3Way(
|
||||
live,
|
||||
targetDoc.getXmlFragment('default'),
|
||||
baseDoc.getXmlFragment('default'),
|
||||
);
|
||||
});
|
||||
|
||||
expect(applied).toBe(0);
|
||||
expect(live.toArray().length).toBe(before);
|
||||
});
|
||||
|
||||
it('2-way: materialized-default live vs engine-style git -> 0 ops', () => {
|
||||
const liveDoc = toYdoc(liveContent);
|
||||
const targetDoc = toYdoc(gitContent);
|
||||
|
||||
const live = liveDoc.getXmlFragment('default');
|
||||
const before = live.toArray().length;
|
||||
let applied = -1;
|
||||
liveDoc.transact(() => {
|
||||
applied = mergeXmlFragments(live, targetDoc.getXmlFragment('default'));
|
||||
});
|
||||
|
||||
expect(applied).toBe(0);
|
||||
expect(live.toArray().length).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,373 @@
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import {
|
||||
mergeXmlFragments,
|
||||
mergeXmlFragments3Way,
|
||||
mergeXmlFragments3WayWithStats,
|
||||
cloneXmlNode,
|
||||
diffBlocks,
|
||||
} from './yjs-body-merge';
|
||||
|
||||
// Build a Y.XmlFragment('default') in `doc` from a list of paragraph specs.
|
||||
// Each spec is the paragraph's plain text (a single XmlText child).
|
||||
function buildFragment(doc: Y.Doc, paragraphs: string[]): Y.XmlFragment {
|
||||
const frag = doc.getXmlFragment('default');
|
||||
const blocks = paragraphs.map((text) => {
|
||||
const el = new Y.XmlElement('paragraph');
|
||||
const t = new Y.XmlText();
|
||||
if (text) t.insert(0, text);
|
||||
el.insert(0, [t]);
|
||||
return el;
|
||||
});
|
||||
if (blocks.length) frag.insert(0, blocks);
|
||||
return frag;
|
||||
}
|
||||
|
||||
function texts(frag: Y.XmlFragment): string[] {
|
||||
return frag.toArray().map((el) => (el as Y.XmlElement).toArray()
|
||||
.map((c) => (c as Y.XmlText).toString())
|
||||
.join(''));
|
||||
}
|
||||
|
||||
describe('yjs-body-merge', () => {
|
||||
describe('diffBlocks (LCS edit script)', () => {
|
||||
it('identical sequences produce only keeps (no edits)', () => {
|
||||
const ops = diffBlocks(['a', 'b', 'c'], ['a', 'b', 'c']);
|
||||
expect(ops.every((o) => o.op === 'keep')).toBe(true);
|
||||
});
|
||||
|
||||
it('a single changed middle element is one del + one ins', () => {
|
||||
const ops = diffBlocks(['a', 'b', 'c'], ['a', 'B', 'c']);
|
||||
expect(ops.filter((o) => o.op === 'del')).toHaveLength(1);
|
||||
expect(ops.filter((o) => o.op === 'ins')).toHaveLength(1);
|
||||
expect(ops.filter((o) => o.op === 'keep')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeXmlFragments', () => {
|
||||
it('identical content is a complete no-op (0 ops) — never clobbers an unchanged resync', () => {
|
||||
const live = new Y.Doc();
|
||||
const target = new Y.Doc();
|
||||
const liveFrag = buildFragment(live, ['one', 'two', 'three']);
|
||||
const targetFrag = buildFragment(target, ['one', 'two', 'three']);
|
||||
|
||||
// Capture block identities to prove they are left untouched.
|
||||
const before = liveFrag.toArray();
|
||||
let applied = -1;
|
||||
live.transact(() => {
|
||||
applied = mergeXmlFragments(liveFrag, targetFrag);
|
||||
});
|
||||
|
||||
expect(applied).toBe(0);
|
||||
// Same Y.XmlElement instances — nothing was deleted/recreated.
|
||||
expect(liveFrag.toArray()).toEqual(before);
|
||||
expect(texts(liveFrag)).toEqual(['one', 'two', 'three']);
|
||||
});
|
||||
|
||||
it('a human edit to one block survives a git change to a DIFFERENT block', () => {
|
||||
// Live: the human has the doc open; block 0 holds their edit. Git changed
|
||||
// only block 2. The merge must touch ONLY block 2 and leave block 0 (and
|
||||
// its in-flight edit) exactly as-is.
|
||||
const live = new Y.Doc();
|
||||
const target = new Y.Doc();
|
||||
const liveFrag = buildFragment(live, ['HUMAN EDIT', 'shared', 'old tail']);
|
||||
const targetFrag = buildFragment(target, [
|
||||
'HUMAN EDIT',
|
||||
'shared',
|
||||
'new tail from git',
|
||||
]);
|
||||
|
||||
const block0Before = liveFrag.get(0); // the human's block instance
|
||||
const block1Before = liveFrag.get(1);
|
||||
|
||||
let applied = -1;
|
||||
live.transact(() => {
|
||||
applied = mergeXmlFragments(liveFrag, targetFrag);
|
||||
});
|
||||
|
||||
// Only block 2 was replaced: one del + one ins.
|
||||
expect(applied).toBe(2);
|
||||
// The human's block and the shared block are the SAME instances (untouched).
|
||||
expect(liveFrag.get(0)).toBe(block0Before);
|
||||
expect(liveFrag.get(1)).toBe(block1Before);
|
||||
// Block 2 now carries git's content.
|
||||
expect(texts(liveFrag)).toEqual([
|
||||
'HUMAN EDIT',
|
||||
'shared',
|
||||
'new tail from git',
|
||||
]);
|
||||
});
|
||||
|
||||
it('appends a new trailing block without disturbing existing ones', () => {
|
||||
const live = new Y.Doc();
|
||||
const target = new Y.Doc();
|
||||
const liveFrag = buildFragment(live, ['a', 'b']);
|
||||
const targetFrag = buildFragment(target, ['a', 'b', 'c']);
|
||||
const a = liveFrag.get(0);
|
||||
const b = liveFrag.get(1);
|
||||
|
||||
let applied = -1;
|
||||
live.transact(() => {
|
||||
applied = mergeXmlFragments(liveFrag, targetFrag);
|
||||
});
|
||||
|
||||
expect(applied).toBe(1); // single insert
|
||||
expect(liveFrag.get(0)).toBe(a);
|
||||
expect(liveFrag.get(1)).toBe(b);
|
||||
expect(texts(liveFrag)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('deletes a removed block, keeping its neighbours', () => {
|
||||
const live = new Y.Doc();
|
||||
const target = new Y.Doc();
|
||||
const liveFrag = buildFragment(live, ['a', 'b', 'c']);
|
||||
const targetFrag = buildFragment(target, ['a', 'c']);
|
||||
const a = liveFrag.get(0);
|
||||
|
||||
let applied = -1;
|
||||
live.transact(() => {
|
||||
applied = mergeXmlFragments(liveFrag, targetFrag);
|
||||
});
|
||||
|
||||
expect(applied).toBe(1); // single delete
|
||||
expect(liveFrag.get(0)).toBe(a);
|
||||
expect(texts(liveFrag)).toEqual(['a', 'c']);
|
||||
});
|
||||
|
||||
it('a fully different body is replaced (and stays valid)', () => {
|
||||
const live = new Y.Doc();
|
||||
const target = new Y.Doc();
|
||||
const liveFrag = buildFragment(live, ['x', 'y']);
|
||||
const targetFrag = buildFragment(target, ['p', 'q', 'r']);
|
||||
live.transact(() => mergeXmlFragments(liveFrag, targetFrag));
|
||||
expect(texts(liveFrag)).toEqual(['p', 'q', 'r']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeXmlFragments3Way', () => {
|
||||
it('keeps a human edit to one block while applying a git change to another (3-way)', () => {
|
||||
// base (last synced): [a, b, c]. Human edited block 0 in the live doc; git
|
||||
// changed block 2 in the incoming file. 3-way must keep BOTH — the 2-way
|
||||
// merge would instead revert the human's block 0 to git's stale version.
|
||||
const base = new Y.Doc();
|
||||
const live = new Y.Doc();
|
||||
const target = new Y.Doc();
|
||||
const baseFrag = buildFragment(base, ['a', 'b', 'c']);
|
||||
const liveFrag = buildFragment(live, ['HUMAN', 'b', 'c']);
|
||||
const targetFrag = buildFragment(target, ['a', 'b', 'GIT']);
|
||||
|
||||
const humanBlock = liveFrag.get(0); // the human's live instance
|
||||
live.transact(() =>
|
||||
mergeXmlFragments3Way(liveFrag, targetFrag, baseFrag),
|
||||
);
|
||||
|
||||
// Human's block preserved as the SAME instance; git's change applied.
|
||||
expect(liveFrag.get(0)).toBe(humanBlock);
|
||||
expect(texts(liveFrag)).toEqual(['HUMAN', 'b', 'GIT']);
|
||||
});
|
||||
|
||||
it('a block both sides changed resolves to git (conflict policy)', () => {
|
||||
const base = new Y.Doc();
|
||||
const live = new Y.Doc();
|
||||
const target = new Y.Doc();
|
||||
const baseFrag = buildFragment(base, ['a', 'b', 'c']);
|
||||
const liveFrag = buildFragment(live, ['a', 'HUMAN', 'c']);
|
||||
const targetFrag = buildFragment(target, ['a', 'GIT', 'c']);
|
||||
|
||||
live.transact(() =>
|
||||
mergeXmlFragments3Way(liveFrag, targetFrag, baseFrag),
|
||||
);
|
||||
expect(texts(liveFrag)).toEqual(['a', 'GIT', 'c']);
|
||||
});
|
||||
|
||||
// Bug #2 observability: the stats variant reports the same-block conflict so
|
||||
// the handler can log it + the persistence layer can pin the human baseline.
|
||||
it('reports the same-block conflict count via mergeXmlFragments3WayWithStats', () => {
|
||||
const base = new Y.Doc();
|
||||
const live = new Y.Doc();
|
||||
const target = new Y.Doc();
|
||||
const baseFrag = buildFragment(base, ['a', 'b', 'c']);
|
||||
const liveFrag = buildFragment(live, ['a', 'HUMAN', 'c']);
|
||||
const targetFrag = buildFragment(target, ['a', 'GIT', 'c']);
|
||||
|
||||
let result!: { applied: number; conflicts: number };
|
||||
live.transact(() => {
|
||||
result = mergeXmlFragments3WayWithStats(liveFrag, targetFrag, baseFrag);
|
||||
});
|
||||
expect(result.conflicts).toBe(1);
|
||||
expect(texts(liveFrag)).toEqual(['a', 'GIT', 'c']);
|
||||
});
|
||||
|
||||
it('reports 0 conflicts for a clean different-block 3-way merge', () => {
|
||||
const base = new Y.Doc();
|
||||
const live = new Y.Doc();
|
||||
const target = new Y.Doc();
|
||||
const baseFrag = buildFragment(base, ['a', 'b', 'c']);
|
||||
const liveFrag = buildFragment(live, ['HUMAN', 'b', 'c']);
|
||||
const targetFrag = buildFragment(target, ['a', 'b', 'GIT']);
|
||||
|
||||
let result!: { applied: number; conflicts: number };
|
||||
live.transact(() => {
|
||||
result = mergeXmlFragments3WayWithStats(liveFrag, targetFrag, baseFrag);
|
||||
});
|
||||
expect(result.conflicts).toBe(0);
|
||||
expect(texts(liveFrag)).toEqual(['HUMAN', 'b', 'GIT']);
|
||||
});
|
||||
|
||||
it('git change with no concurrent human edit (live == base) applies cleanly', () => {
|
||||
const base = new Y.Doc();
|
||||
const live = new Y.Doc();
|
||||
const target = new Y.Doc();
|
||||
const baseFrag = buildFragment(base, ['a', 'b']);
|
||||
const liveFrag = buildFragment(live, ['a', 'b']);
|
||||
const targetFrag = buildFragment(target, ['a', 'B2']);
|
||||
|
||||
live.transact(() =>
|
||||
mergeXmlFragments3Way(liveFrag, targetFrag, baseFrag),
|
||||
);
|
||||
expect(texts(liveFrag)).toEqual(['a', 'B2']);
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: start-of-document content duplicating on every two-way sync.
|
||||
//
|
||||
// The LIVE Docmost doc stamps a per-block UniqueID on every heading/paragraph;
|
||||
// a body arriving FROM git is parsed from clean markdown and carries NO block
|
||||
// ids. If the merge comparison key includes that `id`, an unchanged live block
|
||||
// never matches the SAME block coming from git, so the three-way merge cannot
|
||||
// anchor on it — and an incoming block with no anchor (content inserted at the
|
||||
// TOP of the page) is RE-ADDED on every cycle, an unbounded duplication loop.
|
||||
// These tests model that exact id-asymmetry and assert the reconciliation is
|
||||
// IDEMPOTENT (no block growth). They are RED before excluding `id` from the
|
||||
// key in `serializeXmlNode`.
|
||||
describe('idempotent reconciliation with live block ids (start-of-doc dup)', () => {
|
||||
// Build a fragment from block specs. `id` is set only when provided, mirroring
|
||||
// the live doc (ids present) vs a git-parsed body (ids absent).
|
||||
type Spec = { tag: 'heading' | 'paragraph'; text: string; id?: string };
|
||||
function buildDoc(doc: Y.Doc, specs: Spec[]): Y.XmlFragment {
|
||||
const frag = doc.getXmlFragment('default');
|
||||
const blocks = specs.map((s) => {
|
||||
const el = new Y.XmlElement(s.tag);
|
||||
if (s.id) el.setAttribute('id', s.id);
|
||||
if (s.tag === 'heading') el.setAttribute('level', '2');
|
||||
const t = new Y.XmlText();
|
||||
if (s.text) t.insert(0, s.text);
|
||||
el.insert(0, [t]);
|
||||
return el;
|
||||
});
|
||||
if (blocks.length) frag.insert(0, blocks);
|
||||
return frag;
|
||||
}
|
||||
const textsOf = (frag: Y.XmlFragment): string[] =>
|
||||
frag.toArray().map((el) =>
|
||||
(el as Y.XmlElement)
|
||||
.toArray()
|
||||
.map((c) => (c as Y.XmlText).toString())
|
||||
.join(''),
|
||||
);
|
||||
|
||||
it('re-merging the SAME git body does NOT re-add the top block (idempotent)', () => {
|
||||
// last-synced base (from git markdown): NO block ids.
|
||||
const base = new Y.Doc();
|
||||
const baseFrag = buildDoc(base, [
|
||||
{ tag: 'heading', text: 'Title' },
|
||||
{ tag: 'paragraph', text: 'Some paragraph.' },
|
||||
{ tag: 'paragraph', text: 'End block.' },
|
||||
]);
|
||||
// live Docmost doc: SAME content, but every block carries a UniqueID.
|
||||
const live = new Y.Doc();
|
||||
const liveFrag = buildDoc(live, [
|
||||
{ tag: 'heading', text: 'Title', id: 'ida' },
|
||||
{ tag: 'paragraph', text: 'Some paragraph.', id: 'idb' },
|
||||
{ tag: 'paragraph', text: 'End block.', id: 'idc' },
|
||||
]);
|
||||
// incoming git body: the user inserted a heading at the very TOP.
|
||||
const buildTarget = (): Y.XmlFragment =>
|
||||
buildDoc(new Y.Doc(), [
|
||||
{ tag: 'heading', text: 'TOPDUP' },
|
||||
{ tag: 'heading', text: 'Title' },
|
||||
{ tag: 'paragraph', text: 'Some paragraph.' },
|
||||
{ tag: 'paragraph', text: 'End block.' },
|
||||
]);
|
||||
|
||||
// First sync: the top block is added once.
|
||||
live.transact(() =>
|
||||
mergeXmlFragments3Way(liveFrag, buildTarget(), baseFrag),
|
||||
);
|
||||
expect(textsOf(liveFrag)).toEqual([
|
||||
'TOPDUP',
|
||||
'Title',
|
||||
'Some paragraph.',
|
||||
'End block.',
|
||||
]);
|
||||
|
||||
// Subsequent sync of the SAME git body against the SAME base must be a
|
||||
// NO-OP — not a second copy of the top block. Before the fix this re-adds
|
||||
// 'TOPDUP', growing the doc on every cycle.
|
||||
live.transact(() =>
|
||||
mergeXmlFragments3Way(liveFrag, buildTarget(), baseFrag),
|
||||
);
|
||||
expect(textsOf(liveFrag)).toEqual([
|
||||
'TOPDUP',
|
||||
'Title',
|
||||
'Some paragraph.',
|
||||
'End block.',
|
||||
]);
|
||||
expect(textsOf(liveFrag).filter((t) => t === 'TOPDUP')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('an unchanged git body (live ids, none in git) is a complete no-op', () => {
|
||||
// base == git body (no pending git change); live is the same content with
|
||||
// ids. With `id` in the key the whole body looks rewritten; the merge must
|
||||
// still leave live byte-identical (block instances untouched).
|
||||
const base = new Y.Doc();
|
||||
const baseFrag = buildDoc(base, [
|
||||
{ tag: 'heading', text: 'Title' },
|
||||
{ tag: 'paragraph', text: 'Body.' },
|
||||
]);
|
||||
const live = new Y.Doc();
|
||||
const liveFrag = buildDoc(live, [
|
||||
{ tag: 'heading', text: 'Title', id: 'ida' },
|
||||
{ tag: 'paragraph', text: 'Body.', id: 'idb' },
|
||||
]);
|
||||
const before = liveFrag.toArray();
|
||||
let applied = -1;
|
||||
live.transact(() => {
|
||||
applied = mergeXmlFragments3Way(
|
||||
liveFrag,
|
||||
buildDoc(new Y.Doc(), [
|
||||
{ tag: 'heading', text: 'Title' },
|
||||
{ tag: 'paragraph', text: 'Body.' },
|
||||
]),
|
||||
baseFrag,
|
||||
);
|
||||
});
|
||||
expect(applied).toBe(0);
|
||||
// Same live block instances (ids preserved) — nothing recreated.
|
||||
expect(liveFrag.toArray()).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloneXmlNode', () => {
|
||||
it('preserves text marks (XmlText delta) across docs', () => {
|
||||
const src = new Y.Doc();
|
||||
const srcFrag = src.getXmlFragment('default');
|
||||
const el = new Y.XmlElement('paragraph');
|
||||
const t = new Y.XmlText();
|
||||
t.insert(0, 'plain ');
|
||||
t.insert(6, 'bold', { bold: true });
|
||||
el.insert(0, [t]);
|
||||
srcFrag.insert(0, [el]);
|
||||
|
||||
const dst = new Y.Doc();
|
||||
const dstFrag = dst.getXmlFragment('default');
|
||||
dstFrag.insert(0, [cloneXmlNode(srcFrag.get(0) as Y.XmlElement)]);
|
||||
|
||||
const clonedText = (dstFrag.get(0) as Y.XmlElement).get(0) as Y.XmlText;
|
||||
expect(clonedText.toDelta()).toEqual([
|
||||
{ insert: 'plain ' },
|
||||
{ insert: 'bold', attributes: { bold: true } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
import * as Y from 'yjs';
|
||||
import { getSchema } from '@tiptap/core';
|
||||
import type { Schema } from '@tiptap/pm/model';
|
||||
|
||||
import { tiptapExtensions } from '../collaboration.util';
|
||||
import { diff3PlanWithConflicts } from './three-way-merge';
|
||||
import { buildLcsTable } from './lcs';
|
||||
|
||||
/**
|
||||
* Block-level merge of an incoming (git) page body into a LIVE Yjs document,
|
||||
* replacing the previous full-body "delete everything + re-insert" write that
|
||||
* clobbered concurrent human edits on every sync (review #5 — "do the write as a
|
||||
* merge").
|
||||
*
|
||||
* Strategy: diff the two documents at TOP-LEVEL BLOCK granularity (an LCS over a
|
||||
* canonical structural serialization of each block) and apply only the minimal
|
||||
* insert/delete operations. Blocks that are byte-identical on both sides are
|
||||
* left UNTOUCHED in the live doc — so a human editing one paragraph is unaffected
|
||||
* when git changes a different paragraph, and an unchanged re-sync is a complete
|
||||
* no-op (zero Yjs operations). Yjs then CRDT-merges the minimal ops with any
|
||||
* concurrent edits.
|
||||
*
|
||||
* Merge mode: a THREE-WAY merge (live vs incoming vs base) runs whenever the
|
||||
* engine plumbs the last-synced base (`baseMarkdown` from refs/docmost/last-pushed)
|
||||
* — which it now does end-to-end — so a block both sides changed is a genuine
|
||||
* conflict resolved deterministically (git wins that block; the prior state is
|
||||
* preserved in page history). Only when NO base is available (a brand-new file)
|
||||
* does it fall back to a 2-way merge (live vs incoming). Common cases — unchanged
|
||||
* resync and edits to DIFFERENT blocks — are lossless in both modes.
|
||||
*/
|
||||
|
||||
type XmlNode = Y.XmlElement | Y.XmlText | Y.XmlHook;
|
||||
|
||||
/**
|
||||
* Node attributes that are VOLATILE identity (not content) and so must be
|
||||
* excluded from the block comparison key.
|
||||
*
|
||||
* `id` is the per-block UniqueID the editor stamps on every heading/paragraph
|
||||
* (and transclusionSource). It exists ONLY in the live Yjs document — a body
|
||||
* arriving from git is parsed from clean markdown, which carries no block ids
|
||||
* (`markdownToProseMirror` materializes `id: null`, which the Yjs transform then
|
||||
* drops). If `id` were part of the key, an UNCHANGED live block (id "abc123")
|
||||
* would never match the SAME block coming from git (no id), so the three-way
|
||||
* merge's LCS could not anchor on it. The merge would then treat every live
|
||||
* block as deleted-and-reinserted and, when an incoming block has no matching
|
||||
* anchor (e.g. content inserted at the very TOP of the page), RE-ADD a copy of
|
||||
* it on every sync cycle — a non-convergent, unbounded duplication loop
|
||||
* (start-of-document content duplicating each push/pull cycle).
|
||||
*
|
||||
* Excluding `id` makes blocks compare by CONTENT, so an unchanged block matches
|
||||
* across the git round-trip and the reconciliation is idempotent. Block identity
|
||||
* is still preserved in the merged output: `diff3Plan` keeps the LIVE block
|
||||
* INSTANCE (with its id) for an anchor — picks are by index, not by key — so the
|
||||
* stable Yjs block (and any in-flight human edit on it) stays put. This mirrors
|
||||
* `canonicalize.ts`, which already strips the regenerated block `id` from the
|
||||
* round-trip idempotency comparison for exactly the same reason.
|
||||
*
|
||||
* Known limitation (accepted trade-off of content-based matching): two GENUINELY
|
||||
* DISTINCT blocks whose content is byte-identical now collapse to the same content
|
||||
* key, so when git deletes one of the duplicates the LCS may drop the OTHER live
|
||||
* instance instead. The visible result is identical (one copy removed, one kept),
|
||||
* but a concurrent in-flight human edit on the dropped instance could be lost.
|
||||
*/
|
||||
const VOLATILE_KEY_ATTRS = new Set(['id']);
|
||||
|
||||
/**
|
||||
* The editor (ProseMirror) schema, built ONCE from the same `tiptapExtensions`
|
||||
* the collaboration server uses to materialize Yjs docs. Memoized: building the
|
||||
* schema is non-trivial and the block key is computed per block per cycle.
|
||||
*
|
||||
* Why the schema (not a hardcoded denylist): the LIVE Yjs document is produced by
|
||||
* `TiptapTransformer.toYdoc(pm, 'default', tiptapExtensions)`, which STAMPS every
|
||||
* schema-default attribute onto every node and mark — `indent: 0` on every
|
||||
* paragraph/heading, `image.align: "center"`, the link mark's `internal: false`,
|
||||
* `highlight.colorName: null`, and so on for youtube/pdf/any future node. A body
|
||||
* re-imported from git comes through the engine's `markdownToProseMirror`, whose
|
||||
* schema declares those attrs with DIFFERENT (usually null) defaults; the
|
||||
* resulting null/absent element attrs are then DROPPED by `y-prosemirror`'s
|
||||
* toYdoc. So the SAME block carries materialized defaults on the live side and
|
||||
* nothing on the git side, its key diverges, the three-way merge anchors on
|
||||
* NOTHING, and the whole body is RE-APPENDED every reconcile cycle — an unbounded
|
||||
* duplication loop with no client connected.
|
||||
*
|
||||
* Deriving the defaults from the actual schema normalizes ALL such attributes
|
||||
* generally (it is not another per-attribute denylist): any attribute whose value
|
||||
* equals the schema default — or is null/undefined — is dropped from the key, on
|
||||
* BOTH element attributes and the mark attributes inside each XmlText delta, so a
|
||||
* live block compares equal to its git-round-tripped twin and an unchanged resync
|
||||
* applies zero ops. Genuinely non-default values (a real `indent: 2`, an
|
||||
* `align: "left"`, a real `link.href`, a real highlight color) are content and
|
||||
* stay in the key, so real edits still diff and land.
|
||||
*/
|
||||
let memoSchema: Schema | null = null;
|
||||
let memoSchemaTried = false;
|
||||
function getMergeSchema(): Schema | null {
|
||||
if (!memoSchemaTried) {
|
||||
memoSchemaTried = true;
|
||||
try {
|
||||
memoSchema = getSchema(tiptapExtensions as any);
|
||||
} catch {
|
||||
// Defensive: if the schema can't be built (e.g. a degenerate extension
|
||||
// set in a unit test that stubs `tiptapExtensions`), fall back to dropping
|
||||
// only null/undefined attrs. The real server always builds it fine.
|
||||
memoSchema = null;
|
||||
}
|
||||
}
|
||||
return memoSchema;
|
||||
}
|
||||
|
||||
/** True if `value` is the schema default for `attrName` of `attrSpecs`, or is
|
||||
* null/undefined (which a git round-trip drops). Such attributes are excluded
|
||||
* from the comparison key. `attrSpecs` is a ProseMirror node/mark spec attr map
|
||||
* (`{ [name]: { default } }`); a missing map (unknown node/mark) only drops
|
||||
* null/undefined. (A non-null value matching an attr declared without a default
|
||||
* cannot occur — `spec.default === value` is then `undefined === value`, false.) */
|
||||
function isDefaultAttr(
|
||||
attrSpecs: Record<string, any> | undefined | null,
|
||||
attrName: string,
|
||||
value: unknown,
|
||||
): boolean {
|
||||
if (value === null || value === undefined) return true;
|
||||
const spec = attrSpecs?.[attrName];
|
||||
return !!spec && spec.default === value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize one XmlText delta op's mark attributes: drop every mark-attr whose
|
||||
* value equals the mark's schema default (or is null/undefined), so the link
|
||||
* mark's materialized `internal: false`/`target: "_blank"` and a highlight's
|
||||
* `colorName: null` no longer diverge from a git round-trip that carries neither.
|
||||
* The text (op.insert) and genuinely-set mark attrs (a real `href`, a real
|
||||
* highlight color) are preserved verbatim. `attributes` maps markName -> mark
|
||||
* attrs object (or `true`/boolean for attr-less marks); each is handled safely.
|
||||
*/
|
||||
function normalizeDelta(delta: any[]): any[] {
|
||||
const schema = getMergeSchema();
|
||||
return delta.map((op) => {
|
||||
if (!op || op.attributes == null || typeof op.attributes !== 'object') {
|
||||
return op;
|
||||
}
|
||||
const marks: Record<string, unknown> = {};
|
||||
for (const markName of Object.keys(op.attributes).sort()) {
|
||||
const markVal = op.attributes[markName];
|
||||
if (markVal === null || markVal === undefined) continue;
|
||||
if (typeof markVal !== 'object') {
|
||||
// attr-less mark stored as a primitive (e.g. `true`) — keep as-is.
|
||||
marks[markName] = markVal;
|
||||
continue;
|
||||
}
|
||||
const markSpec = schema?.marks[markName]?.spec.attrs as
|
||||
| Record<string, any>
|
||||
| undefined;
|
||||
const cleaned: Record<string, unknown> = {};
|
||||
for (const ak of Object.keys(markVal as object).sort()) {
|
||||
const av = (markVal as Record<string, unknown>)[ak];
|
||||
if (isDefaultAttr(markSpec, ak, av)) continue;
|
||||
cleaned[ak] = av;
|
||||
}
|
||||
marks[markName] = cleaned;
|
||||
}
|
||||
return { ...op, attributes: marks };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical, comparable serialization of a Yjs XML node (structure + text +
|
||||
* marks + attributes), with attribute keys sorted so equal blocks always produce
|
||||
* an identical string regardless of attribute insertion order. The volatile
|
||||
* block `id` (see `VOLATILE_KEY_ATTRS`) and every schema-default attribute (see
|
||||
* `getMergeSchema`) are excluded at every level — on element attributes AND on
|
||||
* the mark attributes inside each XmlText delta — so a block compares equal by
|
||||
* CONTENT across the git round-trip (which materializes neither), keeping the
|
||||
* merge anchor-able and idempotent.
|
||||
*/
|
||||
export function serializeXmlNode(node: unknown): unknown {
|
||||
if (node instanceof Y.XmlText) {
|
||||
return { t: normalizeDelta(node.toDelta()) };
|
||||
}
|
||||
if (node instanceof Y.XmlElement) {
|
||||
const attrs = node.getAttributes() as Record<string, unknown>;
|
||||
const attrSpecs = getMergeSchema()?.nodes[node.nodeName]?.spec.attrs as
|
||||
| Record<string, any>
|
||||
| undefined;
|
||||
const sorted: Record<string, unknown> = {};
|
||||
for (const k of Object.keys(attrs).sort()) {
|
||||
if (VOLATILE_KEY_ATTRS.has(k)) continue;
|
||||
if (isDefaultAttr(attrSpecs, k, attrs[k])) continue;
|
||||
sorted[k] = attrs[k];
|
||||
}
|
||||
return {
|
||||
n: node.nodeName,
|
||||
a: sorted,
|
||||
c: node.toArray().map(serializeXmlNode),
|
||||
};
|
||||
}
|
||||
// XmlHook / unknown: fall back to a stable string so it compares by identity
|
||||
// of its serialized form (these do not occur in the Docmost block schema).
|
||||
return { u: String(node) };
|
||||
}
|
||||
|
||||
const key = (node: unknown): string => JSON.stringify(serializeXmlNode(node));
|
||||
|
||||
/**
|
||||
* Deep-clone a detached/owned Yjs XML node into a fresh node that can be inserted
|
||||
* into ANOTHER document (Yjs types are bound to their doc, so cross-doc moves are
|
||||
* impossible — we rebuild). Preserves nodeName, attributes, text+marks (via the
|
||||
* XmlText delta) and the full child subtree.
|
||||
*/
|
||||
export function cloneXmlNode(node: XmlNode): Y.XmlElement | Y.XmlText {
|
||||
if (node instanceof Y.XmlText) {
|
||||
const t = new Y.XmlText();
|
||||
const delta = node.toDelta();
|
||||
if (delta.length) t.applyDelta(delta);
|
||||
return t;
|
||||
}
|
||||
if (node instanceof Y.XmlElement) {
|
||||
const el = new Y.XmlElement(node.nodeName);
|
||||
const attrs = node.getAttributes() as Record<string, unknown>;
|
||||
for (const k of Object.keys(attrs)) el.setAttribute(k, attrs[k] as string);
|
||||
const kids = node.toArray().map((c) => cloneXmlNode(c as XmlNode));
|
||||
if (kids.length) el.insert(0, kids);
|
||||
return el;
|
||||
}
|
||||
// Best-effort for any other node type (XmlHook — does not occur in the
|
||||
// Docmost block schema): an empty paragraph so the merge never crashes.
|
||||
return new Y.XmlElement('paragraph');
|
||||
}
|
||||
|
||||
type Op = { op: 'keep' } | { op: 'del' } | { op: 'ins'; bi: number };
|
||||
|
||||
/**
|
||||
* LCS-based edit script turning sequence `a` (live block keys) into `b` (incoming
|
||||
* block keys): a run of keep/del/ins ops. O(n*m) table — fine for page block
|
||||
* counts.
|
||||
*/
|
||||
export function diffBlocks(a: string[], b: string[]): Op[] {
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
const dp = buildLcsTable(a, b);
|
||||
const ops: Op[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) {
|
||||
ops.push({ op: 'keep' });
|
||||
i++;
|
||||
j++;
|
||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||
ops.push({ op: 'del' });
|
||||
i++;
|
||||
} else {
|
||||
ops.push({ op: 'ins', bi: j });
|
||||
j++;
|
||||
}
|
||||
}
|
||||
while (i < n) {
|
||||
ops.push({ op: 'del' });
|
||||
i++;
|
||||
}
|
||||
while (j < m) {
|
||||
ops.push({ op: 'ins', bi: j });
|
||||
j++;
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge `target` block children into `live`, mutating `live` in place with the
|
||||
* minimal set of inserts/deletes. MUST be called inside a Yjs transaction.
|
||||
* Returns the number of block operations applied (0 == content already identical).
|
||||
*/
|
||||
export function mergeXmlFragments(
|
||||
live: Y.XmlFragment,
|
||||
target: Y.XmlFragment,
|
||||
): number {
|
||||
const liveKids = live.toArray();
|
||||
const targetKids = target.toArray();
|
||||
const liveKeys = liveKids.map(key);
|
||||
const targetKeys = targetKids.map(key);
|
||||
|
||||
const ops = diffBlocks(liveKeys, targetKeys);
|
||||
|
||||
let cursor = 0; // index into the LIVE fragment as we mutate it
|
||||
let applied = 0;
|
||||
for (const op of ops) {
|
||||
if (op.op === 'keep') {
|
||||
cursor++;
|
||||
} else if (op.op === 'del') {
|
||||
live.delete(cursor, 1); // remove the live block at the cursor; do not advance
|
||||
applied++;
|
||||
} else {
|
||||
live.insert(cursor, [cloneXmlNode(targetKids[op.bi] as XmlNode)]);
|
||||
cursor++;
|
||||
applied++;
|
||||
}
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
/** Outcome of a 3-way block merge: ops applied + same-block conflict count. */
|
||||
export interface Merge3WayResult {
|
||||
/** Number of block insert/delete operations spliced into `live`. */
|
||||
applied: number;
|
||||
/**
|
||||
* Regions where the human AND git rewrote the SAME base block. The rule is
|
||||
* deterministic (GIT WINS the region), so the human's version of those blocks
|
||||
* is dropped from the live doc. `conflicts > 0` is the OBSERVABLE signal the
|
||||
* caller uses to LOG the loss and pin the human baseline to page history (so it
|
||||
* is recoverable), instead of the edit vanishing silently.
|
||||
*/
|
||||
conflicts: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* THREE-WAY block merge: reconcile `live` toward `target` using `base` (the
|
||||
* last-synced common ancestor) so a block only the human changed is KEPT and a
|
||||
* block only git changed is taken — instead of git's version always winning
|
||||
* (review #5). Conflicts (both changed the same block) resolve to git.
|
||||
*
|
||||
* Implementation: diff3Plan computes the merged block ORDER (picks from live or
|
||||
* target); we materialize that as a virtual target fragment and reuse the 2-way
|
||||
* `mergeXmlFragments` to splice it into `live` minimally (so untouched live block
|
||||
* instances — and their in-flight edits — stay put). MUST be called inside a Yjs
|
||||
* transaction. Returns the number of block operations applied. (Use
|
||||
* `mergeXmlFragments3WayWithStats` when the SAME-BLOCK conflict count is needed.)
|
||||
*/
|
||||
export function mergeXmlFragments3Way(
|
||||
live: Y.XmlFragment,
|
||||
target: Y.XmlFragment,
|
||||
base: Y.XmlFragment,
|
||||
): number {
|
||||
return mergeXmlFragments3WayWithStats(live, target, base).applied;
|
||||
}
|
||||
|
||||
/**
|
||||
* As `mergeXmlFragments3Way`, but also returns the SAME-BLOCK conflict count so
|
||||
* the caller can make a "git won a concurrent same-block edit" event OBSERVABLE
|
||||
* (the documented conflict contract: git wins deterministically, but the losing
|
||||
* human content is never destroyed silently — it is logged and recoverable via
|
||||
* page history).
|
||||
*/
|
||||
export function mergeXmlFragments3WayWithStats(
|
||||
live: Y.XmlFragment,
|
||||
target: Y.XmlFragment,
|
||||
base: Y.XmlFragment,
|
||||
): Merge3WayResult {
|
||||
const liveKids = live.toArray();
|
||||
const targetKids = target.toArray();
|
||||
const liveKeys = liveKids.map(key);
|
||||
const targetKeys = targetKids.map(key);
|
||||
const baseKeys = base.toArray().map(key);
|
||||
|
||||
const { picks: plan, conflicts } = diff3PlanWithConflicts(
|
||||
baseKeys,
|
||||
liveKeys,
|
||||
targetKeys,
|
||||
);
|
||||
|
||||
// Build the merged block sequence in a throwaway doc, cloning from whichever
|
||||
// side each pick came from, then 2-way merge it back into the live fragment.
|
||||
const merged = new Y.Doc();
|
||||
const mergedFrag = merged.getXmlFragment('default');
|
||||
const nodes = plan.map((p) =>
|
||||
cloneXmlNode(
|
||||
(p.src === 'live' ? liveKids[p.index] : targetKids[p.index]) as XmlNode,
|
||||
),
|
||||
);
|
||||
if (nodes.length) mergedFrag.insert(0, nodes);
|
||||
|
||||
return { applied: mergeXmlFragments(live, mergedFrag), conflicts };
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { getSchema } from '@tiptap/core';
|
||||
import { Schema } from '@tiptap/pm/model';
|
||||
import { tiptapExtensions } from './collaboration.util';
|
||||
// The canonical converter mirror's extension set. The schema mirror now lives in
|
||||
// the single `@docmost/prosemirror-markdown` package (#293); the server jest
|
||||
// config maps it to the package SOURCE (moduleNameMapper
|
||||
// `^@docmost/prosemirror-markdown$`), so this reads the real mirror, not a build.
|
||||
import { docmostExtensions as gitSyncExtensions } from '@docmost/prosemirror-markdown';
|
||||
|
||||
/**
|
||||
* ATTRIBUTE-LEVEL SCHEMA CONTRACT (review #293, variant A).
|
||||
*
|
||||
* The document schema exists as three hand-synced copies (editor-ext =
|
||||
* source-of-truth, plus the git-sync and mcp converter mirrors). The existing
|
||||
* `schema-editor-ext-contract.test.ts` compares only node/mark TYPE NAMES, so a
|
||||
* NEW ATTRIBUTE added to an existing node upstream slips through and its value is
|
||||
* silently dropped on every git-sync round trip. That is a repeatedly-hit
|
||||
* data-loss class (image caption #221, paragraph alignment #10, details `open`).
|
||||
*
|
||||
* This test closes the attribute gap MECHANICALLY: it builds the real canonical
|
||||
* schema from the server's `tiptapExtensions` (the same set the collab write path
|
||||
* uses) and the git-sync mirror schema, then asserts that for every node/mark the
|
||||
* two schemas share, their ATTRIBUTE-KEY sets are equal — minus a committed
|
||||
* allowlist of intentional, understood divergences. A forgotten attribute now
|
||||
* fails CI loudly instead of losing data in production.
|
||||
*
|
||||
* WHY THIS ISN'T THE "fragile attribute compare" the sibling name-level contract
|
||||
* (`packages/git-sync/test/schema-editor-ext-contract.test.ts`) deferred: that
|
||||
* concern was about comparing raw extension CONFIGS, where editor-ext spreads
|
||||
* global attributes (textAlign, id, …) across separate extensions and StarterKit
|
||||
* contributes types the mirror gets elsewhere. We instead compare the RESOLVED
|
||||
* ProseMirror `Schema` objects — `getSchema()` has already merged every
|
||||
* addGlobalAttributes spread into concrete per-node attrs on both sides — so the
|
||||
* compare is apples-to-apples (57 shared nodes/marks, only a handful of
|
||||
* documented divergences) rather than config-shape noise.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Intentional, understood attribute divergences between the canonical schema and
|
||||
* the git-sync mirror. Each entry MUST carry a reason. The test asserts the
|
||||
* allowlist is not stale (every listed attr is actually still divergent), so this
|
||||
* cannot rot into a silent escape hatch.
|
||||
*
|
||||
* Shape: { [nodeOrMarkName]: { canonicalOnly?: string[]; mirrorOnly?: string[] } }
|
||||
*/
|
||||
const ALLOWED_DIVERGENCES: Record<
|
||||
string,
|
||||
{ canonicalOnly?: string[]; mirrorOnly?: string[] }
|
||||
> = {
|
||||
// mirrorOnly: the converter mirror carries `align` on table cells/headers so a
|
||||
// GFM column-alignment marker (:--, :-:, --:) can be reconstructed on export;
|
||||
// editor-ext expresses cell alignment differently. Intentional, round-trip-used.
|
||||
tableCell: { mirrorOnly: ['align'] },
|
||||
tableHeader: { mirrorOnly: ['align'] },
|
||||
// youtube: the mirror adds `align` (media alignment it renders as data-align)
|
||||
// and does NOT carry editor-ext's `start` (video start-time). `start` is a
|
||||
// PRE-EXISTING gap (a youtube embed's start offset is not preserved across a
|
||||
// markdown round trip) — documented here so the contract is green for the known
|
||||
// state and RED for any NEW drift. Follow-up: carry `start` through the mirror.
|
||||
youtube: { mirrorOnly: ['align'], canonicalOnly: ['start'] },
|
||||
// image.title: the mirror carries a `title` attr (used to round-trip the
|
||||
// markdown image title ``) that editor-ext does not declare
|
||||
// on its image node. Mirror-only and round-trip-used, not data loss. Intentional.
|
||||
image: { mirrorOnly: ['title'] },
|
||||
// highlight.colorName (a named-color alias alongside the color value) is a
|
||||
// PRE-EXISTING mirror gap; the color value itself round-trips. Documented.
|
||||
highlight: { canonicalOnly: ['colorName'] },
|
||||
};
|
||||
|
||||
function attrKeys(schema: Schema): Map<string, Set<string>> {
|
||||
const out = new Map<string, Set<string>>();
|
||||
for (const [name, type] of Object.entries(schema.nodes)) {
|
||||
out.set(name, new Set(Object.keys((type.spec as any).attrs ?? {})));
|
||||
}
|
||||
for (const [name, type] of Object.entries(schema.marks)) {
|
||||
out.set(name, new Set(Object.keys((type.spec as any).attrs ?? {})));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function diff(a: Set<string>, b: Set<string>): string[] {
|
||||
return [...a].filter((x) => !b.has(x)).sort();
|
||||
}
|
||||
|
||||
describe('schema attribute contract: git-sync mirror vs canonical editor-ext', () => {
|
||||
const canonical = attrKeys(getSchema(tiptapExtensions as never));
|
||||
const mirror = attrKeys(getSchema(gitSyncExtensions as never));
|
||||
|
||||
it('builds meaningful schemas (guard against a vacuous pass)', () => {
|
||||
expect(canonical.size).toBeGreaterThan(10);
|
||||
expect(mirror.size).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it('every shared node/mark has matching attribute keys (modulo the allowlist)', () => {
|
||||
const drift: string[] = [];
|
||||
for (const [name, canonAttrs] of canonical) {
|
||||
const mirrorAttrs = mirror.get(name);
|
||||
if (!mirrorAttrs) continue; // name-level gaps are the other test's job
|
||||
const allow = ALLOWED_DIVERGENCES[name] ?? {};
|
||||
const canonicalOnly = diff(canonAttrs, mirrorAttrs).filter(
|
||||
(k) => !(allow.canonicalOnly ?? []).includes(k),
|
||||
);
|
||||
const mirrorOnly = diff(mirrorAttrs, canonAttrs).filter(
|
||||
(k) => !(allow.mirrorOnly ?? []).includes(k),
|
||||
);
|
||||
if (canonicalOnly.length) {
|
||||
drift.push(
|
||||
`${name}: attrs in editor-ext but MISSING from git-sync mirror ` +
|
||||
`(silently dropped on round trip): ${canonicalOnly.join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (mirrorOnly.length) {
|
||||
drift.push(
|
||||
`${name}: attrs in git-sync mirror but NOT in editor-ext ` +
|
||||
`(mirror invented an attribute): ${mirrorOnly.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(drift).toEqual([]);
|
||||
});
|
||||
|
||||
it('the allowlist is not stale (every listed divergence is still real)', () => {
|
||||
const stale: string[] = [];
|
||||
for (const [name, allow] of Object.entries(ALLOWED_DIVERGENCES)) {
|
||||
const canonAttrs = canonical.get(name);
|
||||
const mirrorAttrs = mirror.get(name);
|
||||
if (!canonAttrs || !mirrorAttrs) {
|
||||
stale.push(`${name}: no longer a shared node/mark`);
|
||||
continue;
|
||||
}
|
||||
for (const k of allow.canonicalOnly ?? []) {
|
||||
if (!(canonAttrs.has(k) && !mirrorAttrs.has(k))) {
|
||||
stale.push(`${name}.canonicalOnly '${k}' is no longer divergent`);
|
||||
}
|
||||
}
|
||||
for (const k of allow.mirrorOnly ?? []) {
|
||||
if (!(mirrorAttrs.has(k) && !canonAttrs.has(k))) {
|
||||
stale.push(`${name}.mirrorOnly '${k}' is no longer divergent`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -73,6 +73,32 @@ describe('agentSourceFields', () => {
|
||||
).toEqual({ lastUpdatedSource: 'agent', lastUpdatedAiChatId: null });
|
||||
});
|
||||
|
||||
it("stamps ONLY the source column 'git-sync' (no chat key) for a git-sync write", () => {
|
||||
// The git-sync data plane (issue #194 §8.1) has no internal ai_chats row, so
|
||||
// it stamps the *Source column 'git-sync' and OMITS the chat key entirely
|
||||
// (unlike the agent branch, which also writes aiChatId). Pinned directly here
|
||||
// because the page.service.spec only exercises it indirectly.
|
||||
expect(
|
||||
agentSourceFields(
|
||||
{ actor: 'git-sync', aiChatId: null },
|
||||
'lastUpdatedSource',
|
||||
'lastUpdatedAiChatId',
|
||||
),
|
||||
).toEqual({ lastUpdatedSource: 'git-sync' });
|
||||
});
|
||||
|
||||
it("ignores any aiChatId on a git-sync write (chat key never written)", () => {
|
||||
// Even if a non-null aiChatId is present, the git-sync branch must not emit
|
||||
// the chat key.
|
||||
expect(
|
||||
agentSourceFields(
|
||||
{ actor: 'git-sync', aiChatId: 'should-be-ignored' },
|
||||
'createdSource',
|
||||
'aiChatId',
|
||||
),
|
||||
).toEqual({ createdSource: 'git-sync' });
|
||||
});
|
||||
|
||||
it('returns {} for a user write so the column keeps its default', () => {
|
||||
expect(
|
||||
agentSourceFields(
|
||||
|
||||
@@ -9,6 +9,8 @@ import { ProvenanceSource } from '../../core/auth/dto/jwt-payload';
|
||||
* cannot fake an 'agent' marker.
|
||||
*/
|
||||
export interface AuthProvenanceData {
|
||||
// ProvenanceSource includes 'git-sync' — set by the in-process git-sync data
|
||||
// plane (issue #194 §8.1) when it drives PageService writes; never from a request token.
|
||||
actor: ProvenanceSource;
|
||||
aiChatId: string | null;
|
||||
}
|
||||
@@ -60,6 +62,14 @@ export function agentSourceFields<S extends string, C extends string>(
|
||||
sourceKey: S,
|
||||
chatKey: C,
|
||||
): Partial<Record<S, ProvenanceSource> & Record<C, string | null>> {
|
||||
// git-sync data-plane write (issue #194 §8.1): stamp the source 'git-sync' with NO
|
||||
// aiChatId (it has no internal ai_chats row). Mirrors the agent branch; each
|
||||
// write has a single actor, so precedence is irrelevant here.
|
||||
if (provenance?.actor === 'git-sync') {
|
||||
return { [sourceKey]: 'git-sync' } as Partial<
|
||||
Record<S, ProvenanceSource> & Record<C, string | null>
|
||||
>;
|
||||
}
|
||||
if (provenance?.actor !== 'agent') return {};
|
||||
return {
|
||||
[sourceKey]: 'agent',
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Dynamic ESM import bridge for a CommonJS build.
|
||||
*
|
||||
* The server compiles with `module: commonjs`, and TypeScript downlevels a
|
||||
* literal `import()` expression to `require()` — which cannot load an ESM-only
|
||||
* package (`@docmost/mcp`, `@docmost/git-sync`). Indirecting through `new
|
||||
* Function` hides the `import()` from the TS downleveler so the REAL dynamic
|
||||
* `import()` survives to runtime and can load ESM from CommonJS.
|
||||
*
|
||||
* This is the single shared copy of that bridge. The per-package typed loaders
|
||||
* (git-sync.loader.ts, docmost-client.loader.ts, mcp.service.ts) import this and
|
||||
* keep their own typed `loadX()` wrappers (require.resolve + pathToFileURL +
|
||||
* memoization) on top.
|
||||
*/
|
||||
export const esmImport = new Function(
|
||||
'specifier',
|
||||
'return import(specifier)',
|
||||
) as (specifier: string) => Promise<unknown>;
|
||||
@@ -0,0 +1,71 @@
|
||||
import { resolveRequestWorkspace } from './resolve-request-workspace';
|
||||
|
||||
// Unit tests for the shared self-hosted/cloud workspace resolver deduplicated out
|
||||
// of DomainMiddleware + GitHttpService (architecture #11). They must behave
|
||||
// identically, so this pins the single source of truth.
|
||||
|
||||
type AnyMock = jest.Mock;
|
||||
|
||||
function build(opts: {
|
||||
selfHosted: boolean;
|
||||
first?: { id: string } | null;
|
||||
byHostname?: { id: string } | null;
|
||||
}) {
|
||||
const env = {
|
||||
isSelfHosted: jest.fn(() => opts.selfHosted),
|
||||
isCloud: jest.fn(() => !opts.selfHosted),
|
||||
};
|
||||
const repo = {
|
||||
findFirst: jest.fn(async () => opts.first ?? null) as AnyMock,
|
||||
findByHostname: jest.fn(async () => opts.byHostname ?? null) as AnyMock,
|
||||
};
|
||||
return { env, repo };
|
||||
}
|
||||
|
||||
describe('resolveRequestWorkspace', () => {
|
||||
it('self-hosted: returns the first/default workspace, ignoring the host', async () => {
|
||||
const { env, repo } = build({ selfHosted: true, first: { id: 'ws-1' } });
|
||||
const ws = await resolveRequestWorkspace(
|
||||
env as any,
|
||||
repo as any,
|
||||
'anything.example.com',
|
||||
);
|
||||
expect(ws).toEqual({ id: 'ws-1' });
|
||||
expect(repo.findFirst).toHaveBeenCalledTimes(1);
|
||||
expect(repo.findByHostname).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('self-hosted: returns null when no workspace is configured', async () => {
|
||||
const { env, repo } = build({ selfHosted: true, first: null });
|
||||
expect(await resolveRequestWorkspace(env as any, repo as any, 'h')).toBeNull();
|
||||
});
|
||||
|
||||
it('cloud: resolves by the host-header subdomain', async () => {
|
||||
const { env, repo } = build({
|
||||
selfHosted: false,
|
||||
byHostname: { id: 'ws-acme' },
|
||||
});
|
||||
const ws = await resolveRequestWorkspace(
|
||||
env as any,
|
||||
repo as any,
|
||||
'acme.example.com',
|
||||
);
|
||||
expect(ws).toEqual({ id: 'ws-acme' });
|
||||
expect(repo.findByHostname).toHaveBeenCalledWith('acme');
|
||||
expect(repo.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cloud: returns null for a blank/missing host (no throw)', async () => {
|
||||
const { env, repo } = build({ selfHosted: false, byHostname: { id: 'x' } });
|
||||
expect(await resolveRequestWorkspace(env as any, repo as any, undefined)).toBeNull();
|
||||
expect(await resolveRequestWorkspace(env as any, repo as any, '')).toBeNull();
|
||||
expect(repo.findByHostname).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cloud: returns null when the subdomain matches no workspace', async () => {
|
||||
const { env, repo } = build({ selfHosted: false, byHostname: null });
|
||||
expect(
|
||||
await resolveRequestWorkspace(env as any, repo as any, 'ghost.example.com'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { Workspace } from '@docmost/db/types/entity.types';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
|
||||
/**
|
||||
* The ONE canonical way to resolve the workspace for an incoming request:
|
||||
* - self-hosted (single workspace) -> the first/default workspace;
|
||||
* - cloud (multi-tenant) -> resolved by the host-header subdomain.
|
||||
* Returns null when none resolves (no workspace configured, or a blank/unknown
|
||||
* subdomain on cloud). `isSelfHosted()` is `!isCloud()`, so exactly one branch is
|
||||
* always taken.
|
||||
*
|
||||
* Extracted so the self-hosted/cloud branch is not hand-duplicated. Shared by
|
||||
* `DomainMiddleware` (the normal /api request path) and `GitHttpService` (the raw
|
||||
* root-mounted /git smart-HTTP host, which Nest middleware does NOT run for) so
|
||||
* the two cannot drift.
|
||||
*
|
||||
* This helper does NOT catch DB errors — callers decide: DomainMiddleware lets a
|
||||
* throw bubble (as before); GitHttpService wraps it to log + treat as
|
||||
* unresolvable (-> 404). A blank/missing host on cloud resolves to null rather
|
||||
* than throwing.
|
||||
*/
|
||||
export async function resolveRequestWorkspace(
|
||||
environmentService: EnvironmentService,
|
||||
workspaceRepo: WorkspaceRepo,
|
||||
hostHeader: string | undefined,
|
||||
): Promise<Workspace | null> {
|
||||
if (environmentService.isSelfHosted()) {
|
||||
return (await workspaceRepo.findFirst()) ?? null;
|
||||
}
|
||||
// Cloud (isSelfHosted === !isCloud, so this is the only remaining branch).
|
||||
const subdomain = hostHeader ? hostHeader.split('.')[0] : '';
|
||||
if (!subdomain) return null;
|
||||
return (await workspaceRepo.findByHostname(subdomain)) ?? null;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Injectable, NestMiddleware, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { resolveRequestWorkspace } from '../helpers/resolve-request-workspace';
|
||||
|
||||
@Injectable()
|
||||
export class DomainMiddleware implements NestMiddleware {
|
||||
@@ -14,30 +15,19 @@ export class DomainMiddleware implements NestMiddleware {
|
||||
res: FastifyReply['raw'],
|
||||
next: () => void,
|
||||
) {
|
||||
if (this.environmentService.isSelfHosted()) {
|
||||
const workspace = await this.workspaceRepo.findFirst();
|
||||
if (!workspace) {
|
||||
//throw new NotFoundException('Workspace not found');
|
||||
(req as any).workspaceId = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
// TODO: unify
|
||||
(req as any).workspaceId = workspace.id;
|
||||
(req as any).workspace = workspace;
|
||||
} else if (this.environmentService.isCloud()) {
|
||||
const header = req.headers.host;
|
||||
const subdomain = header.split('.')[0];
|
||||
|
||||
const workspace = await this.workspaceRepo.findByHostname(subdomain);
|
||||
|
||||
if (!workspace) {
|
||||
(req as any).workspaceId = null;
|
||||
return next();
|
||||
}
|
||||
// Shared self-hosted/cloud resolution (the SAME branch the /git host uses),
|
||||
// so the logic cannot drift between the two.
|
||||
const workspace = await resolveRequestWorkspace(
|
||||
this.environmentService,
|
||||
this.workspaceRepo,
|
||||
req.headers.host,
|
||||
);
|
||||
|
||||
if (workspace) {
|
||||
(req as any).workspaceId = workspace.id;
|
||||
(req as any).workspace = workspace;
|
||||
} else {
|
||||
(req as any).workspaceId = null;
|
||||
}
|
||||
|
||||
next();
|
||||
|
||||
@@ -1,527 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,452 +0,0 @@
|
||||
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,7 +25,6 @@ describe('AiChatController.boundChat', () => {
|
||||
};
|
||||
const controller = new AiChatController(
|
||||
{} as never,
|
||||
{} as never, // aiChatRunService
|
||||
aiChatRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
|
||||
@@ -53,7 +53,6 @@ describe('AiChatController.export', () => {
|
||||
};
|
||||
const controller = new AiChatController(
|
||||
{} as never,
|
||||
{} as never, // aiChatRunService
|
||||
aiChatRepo as never,
|
||||
aiChatMessageRepo as never,
|
||||
{} as never,
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
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,7 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
ConflictException,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
@@ -21,13 +20,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { SkipTransform } from '../../common/decorators/skip-transform.decorator';
|
||||
import {
|
||||
AiChat,
|
||||
AiChatMessage,
|
||||
AiChatRun,
|
||||
User,
|
||||
Workspace,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { AiChat, User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
||||
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||
@@ -35,12 +28,7 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard';
|
||||
import { AI_CHAT_THROTTLER } from '../../integrations/throttle/throttler-names';
|
||||
import { FileInterceptor } from '../../common/interceptors/file.interceptor';
|
||||
import {
|
||||
AiChatRunHooks,
|
||||
AiChatService,
|
||||
AiChatStreamBody,
|
||||
} from './ai-chat.service';
|
||||
import { AiChatRunService } from './ai-chat-run.service';
|
||||
import { AiChatService, AiChatStreamBody } from './ai-chat.service';
|
||||
import { AiTranscriptionService } from './ai-transcription.service';
|
||||
import {
|
||||
BoundChatDto,
|
||||
@@ -48,9 +36,7 @@ import {
|
||||
ExportChatDto,
|
||||
GeneratePageTitleDto,
|
||||
GetChatMessagesDto,
|
||||
GetRunDto,
|
||||
RenameChatDto,
|
||||
StopRunDto,
|
||||
} from './dto/ai-chat.dto';
|
||||
import { describeProviderError } from '../../integrations/ai/ai-error.util';
|
||||
import { buildChatMarkdown } from './chat-markdown.util';
|
||||
@@ -67,7 +53,6 @@ export class AiChatController {
|
||||
|
||||
constructor(
|
||||
private readonly aiChatService: AiChatService,
|
||||
private readonly aiChatRunService: AiChatRunService,
|
||||
private readonly aiChatRepo: AiChatRepo,
|
||||
private readonly aiChatMessageRepo: AiChatMessageRepo,
|
||||
private readonly aiTranscription: AiTranscriptionService,
|
||||
@@ -164,75 +149,6 @@ export class AiChatController {
|
||||
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. */
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('rename')
|
||||
@@ -284,20 +200,11 @@ export class AiChatController {
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<void> {
|
||||
// A7 gate: the workspace must have AI chat explicitly enabled.
|
||||
const settings = (workspace.settings ?? {}) as {
|
||||
ai?: { chat?: boolean; autonomousRuns?: boolean };
|
||||
};
|
||||
const settings = (workspace.settings ?? {}) as { ai?: { chat?: boolean } };
|
||||
if (settings.ai?.chat !== true) {
|
||||
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;
|
||||
if (!sessionId) {
|
||||
// The chat requires an interactive session to mint loopback tokens
|
||||
@@ -321,58 +228,6 @@ export class AiChatController {
|
||||
// HttpException) instead of breaking mid-stream.
|
||||
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
|
||||
// normal completion, so only abort when the response has not finished
|
||||
// writing (a genuine disconnect). `once` fires at most once and self-removes;
|
||||
@@ -387,44 +242,18 @@ export class AiChatController {
|
||||
// A genuine disconnect leaves the response unfinished (unlike a normal
|
||||
// completion, which also fires `close`). Such a drop — e.g. a reverse
|
||||
// proxy cutting the SSE mid-answer — is otherwise invisible server-side,
|
||||
// so log it here.
|
||||
// so log it here before aborting the agent loop.
|
||||
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(
|
||||
`AI chat stream: client disconnected before completion; aborting turn ` +
|
||||
`(elapsed=${Date.now() - reqStartedAt}ms since request received)`,
|
||||
);
|
||||
controller.abort();
|
||||
}
|
||||
this.logger.warn(
|
||||
`AI chat stream: client disconnected before completion; aborting turn ` +
|
||||
`(elapsed=${Date.now() - reqStartedAt}ms since request received)`,
|
||||
);
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
req.raw.once('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
|
||||
// the AI SDK can write the UI-message stream directly to the Node socket.
|
||||
res.hijack();
|
||||
@@ -439,32 +268,15 @@ export class AiChatController {
|
||||
signal: controller.signal,
|
||||
model,
|
||||
role,
|
||||
// #184: present only when the flag is on; wraps the turn in a durable run.
|
||||
runHooks,
|
||||
});
|
||||
} catch (err) {
|
||||
// Any failure AFTER hijack can no longer go through Nest's exception
|
||||
// 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);
|
||||
}
|
||||
// Any failure AFTER hijack can no longer send a clean JSON error, so emit
|
||||
// a minimal error on the raw socket if nothing has been written yet.
|
||||
this.logger.error('AI chat stream failed', err as Error);
|
||||
if (!res.raw.headersSent) {
|
||||
const status = isHttp ? err.getStatus() : 500;
|
||||
const payload = isHttp
|
||||
? err.getResponse()
|
||||
: { error: 'Internal server error' };
|
||||
res.raw.statusCode = status;
|
||||
res.raw.statusCode = 500;
|
||||
res.raw.setHeader('Content-Type', 'application/json');
|
||||
res.raw.end(
|
||||
JSON.stringify(
|
||||
typeof payload === 'string' ? { message: payload } : payload,
|
||||
),
|
||||
);
|
||||
res.raw.end(JSON.stringify({ error: 'Internal server error' }));
|
||||
} else if (!res.raw.writableEnded) {
|
||||
res.raw.end();
|
||||
}
|
||||
|
||||
@@ -57,7 +57,6 @@ describe('AiChatController.generatePageTitle', () => {
|
||||
const aiChatService = { generatePageTitle: generate };
|
||||
const controller = new AiChatController(
|
||||
aiChatService as never,
|
||||
{} as never, // aiChatRunService
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { AiModule } from '../../integrations/ai/ai.module';
|
||||
import { TokenModule } from '../auth/token.module';
|
||||
import { AiChatController } from './ai-chat.controller';
|
||||
import { AiChatService } from './ai-chat.service';
|
||||
import { AiChatRunService } from './ai-chat-run.service';
|
||||
import { AiTranscriptionService } from './ai-transcription.service';
|
||||
import { AiChatToolsService } from './tools/ai-chat-tools.service';
|
||||
import { EmbeddingModule } from './embedding/embedding.module';
|
||||
@@ -43,7 +42,6 @@ import { PublicShareChatToolsService } from './tools/public-share-chat-tools.ser
|
||||
controllers: [AiChatController, PublicShareChatController],
|
||||
providers: [
|
||||
AiChatService,
|
||||
AiChatRunService,
|
||||
AiTranscriptionService,
|
||||
AiChatToolsService,
|
||||
PublicShareChatService,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { AiChatService, AiChatRunHooks } from './ai-chat.service';
|
||||
import { AiChatRunService } from './ai-chat-run.service';
|
||||
import type { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { AiChatService } from './ai-chat.service';
|
||||
|
||||
/**
|
||||
* Lifecycle unit tests for AiChatService.onModuleInit (#183 crash-recovery
|
||||
@@ -63,99 +61,3 @@ describe('AiChatService.onModuleInit (startup sweep)', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,489 +0,0 @@
|
||||
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,12 +453,6 @@ 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', () => {
|
||||
// finish-step usage is per-step in v6; the caller accumulates and passes the
|
||||
// running sum, which this just wraps.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,30 +43,6 @@ export class BoundChatDto {
|
||||
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
|
||||
* role/tool-action labels; defaults to English server-side. */
|
||||
export class ExportChatDto {
|
||||
|
||||
@@ -455,13 +455,59 @@ export class AiChatToolsService {
|
||||
},
|
||||
}),
|
||||
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
// This layer keeps only its own execute-side guards (require a selection
|
||||
// for a top-level comment; reject suggestedText on a reply / without a
|
||||
// selection) — the schema+description are shared.
|
||||
createComment: sharedTool(
|
||||
sharedToolSpecs.createComment,
|
||||
async ({
|
||||
// INTENTIONAL per-transport divergence (not shared): the description is
|
||||
// tuned for the in-app agent (e.g. "retry with a corrected EXACT selection"
|
||||
// and "Reversible via the comment UI"); the standalone MCP `create_comment`
|
||||
// keeps its own wording. Kept per-layer.
|
||||
createComment: tool({
|
||||
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.',
|
||||
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,
|
||||
content,
|
||||
selection,
|
||||
@@ -502,17 +548,26 @@ export class AiChatToolsService {
|
||||
const data = (result?.data ?? {}) as { id?: string };
|
||||
return { commentId: data.id, pageId };
|
||||
},
|
||||
),
|
||||
}),
|
||||
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
resolveComment: sharedTool(
|
||||
sharedToolSpecs.resolveComment,
|
||||
async ({ commentId, resolved }) => {
|
||||
resolveComment: tool({
|
||||
description:
|
||||
'Resolve or reopen a top-level comment thread (reversible — toggle ' +
|
||||
'the resolved flag). Only top-level comments can be 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 }.
|
||||
await client.resolveComment(commentId, resolved);
|
||||
return { commentId, resolved };
|
||||
},
|
||||
),
|
||||
}),
|
||||
|
||||
// --- READ tools (added) ---
|
||||
|
||||
@@ -618,12 +673,24 @@ export class AiChatToolsService {
|
||||
await client.getTable(pageId, tableRef),
|
||||
}),
|
||||
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
listComments: sharedTool(
|
||||
sharedToolSpecs.listComments,
|
||||
async ({ pageId, includeResolved }) =>
|
||||
listComments: tool({
|
||||
description:
|
||||
'List comments on a page in one call. 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: 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),
|
||||
),
|
||||
}),
|
||||
|
||||
getComment: tool({
|
||||
description: 'Fetch a single comment by id (content as Markdown).',
|
||||
@@ -633,12 +700,26 @@ export class AiChatToolsService {
|
||||
execute: async ({ commentId }) => await client.getComment(commentId),
|
||||
}),
|
||||
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
checkNewComments: sharedTool(
|
||||
sharedToolSpecs.checkNewComments,
|
||||
async ({ spaceId, since, parentPageId }) =>
|
||||
checkNewComments: tool({
|
||||
description:
|
||||
'Find new comments across a space (optionally scoped to a subtree) ' +
|
||||
'created after a given timestamp.',
|
||||
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),
|
||||
),
|
||||
}),
|
||||
|
||||
listShares: sharedTool(
|
||||
sharedToolSpecs.listShares,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { esmImport } from '../../../common/helpers/esm-import';
|
||||
|
||||
/**
|
||||
* Minimal structural type for the `DocmostClient` class we consume from the
|
||||
@@ -256,14 +257,8 @@ interface DocmostMcpModule {
|
||||
SHARED_TOOL_SPECS: Record<string, SharedToolSpec>;
|
||||
}
|
||||
|
||||
// TS with module:commonjs downlevels a literal `import()` to `require()`, which
|
||||
// cannot load the ESM-only `@docmost/mcp` package. Indirect through Function so
|
||||
// the real dynamic `import()` survives compilation and can load ESM from
|
||||
// CommonJS at runtime (same trick as integrations/mcp/mcp.service.ts).
|
||||
const esmImport = new Function(
|
||||
'specifier',
|
||||
'return import(specifier)',
|
||||
) as (specifier: string) => Promise<unknown>;
|
||||
// The CJS->ESM dynamic-import bridge lives in one shared helper
|
||||
// (common/helpers/esm-import.ts). The typed `loadDocmostMcp()` wrapper stays here.
|
||||
|
||||
// Memoize the in-flight/loaded module so the dynamic import runs at most once.
|
||||
let modulePromise: Promise<DocmostMcpModule> | null = null;
|
||||
|
||||
@@ -108,14 +108,23 @@ export const INLINE_TOOL_TIERS: Record<
|
||||
tier: 'core',
|
||||
catalogLine: "listPages — list recent pages, or a space's full page tree.",
|
||||
},
|
||||
// NOTE: createComment, listComments and resolveComment moved to
|
||||
// @docmost/mcp's SHARED_TOOL_SPECS (#294); they carry their own tier +
|
||||
// catalogLine there. getComment stays inline (MCP-only shape divergence is
|
||||
// n/a — it simply has no shared spec).
|
||||
listComments: {
|
||||
tier: 'core',
|
||||
catalogLine: 'listComments — list all comments on a page (including resolved).',
|
||||
},
|
||||
getComment: {
|
||||
tier: 'core',
|
||||
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 ---
|
||||
createPage: {
|
||||
@@ -148,8 +157,11 @@ export const INLINE_TOOL_TIERS: Record<
|
||||
tier: 'deferred',
|
||||
catalogLine: 'getTable — read a table as a matrix of cell texts and cell ids.',
|
||||
},
|
||||
// NOTE: checkNewComments moved to @docmost/mcp's SHARED_TOOL_SPECS (#294);
|
||||
// it carries its own deferred tier + catalogLine there.
|
||||
checkNewComments: {
|
||||
tier: 'deferred',
|
||||
catalogLine:
|
||||
'checkNewComments — find comments in a space created after a timestamp.',
|
||||
},
|
||||
getPageHistory: {
|
||||
tier: 'deferred',
|
||||
catalogLine:
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
AUTH_THROTTLER,
|
||||
PAGE_TEMPLATE_THROTTLER,
|
||||
PUBLIC_SHARE_AI_THROTTLER,
|
||||
VITALS_THROTTLER,
|
||||
} from '../../integrations/throttle/throttler-names';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { AuthService } from './services/auth.service';
|
||||
@@ -185,21 +184,16 @@ export class AuthController {
|
||||
}
|
||||
|
||||
// The global ThrottlerGuard applies ALL named throttlers to every route by
|
||||
// default, so each non-AUTH bucket (AI chat, page template, public-share AI,
|
||||
// client vitals) is explicitly skipped here. collab-token is auth-guarded
|
||||
// (JwtAuthGuard), per-user and client-cached, so those feature buckets are
|
||||
// irrelevant to it; skipping them avoids spurious 429s when a user opens many
|
||||
// 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.
|
||||
// default, so each non-AUTH bucket (AI chat, page template, public-share AI)
|
||||
// is explicitly skipped here. collab-token is auth-guarded (JwtAuthGuard),
|
||||
// per-user and client-cached, so those feature buckets are irrelevant to it;
|
||||
// skipping them avoids spurious 429s when a user opens many pages in a short
|
||||
// window. The AUTH bucket is skipped too for the same per-user, cached reason.
|
||||
@SkipThrottle({
|
||||
[AUTH_THROTTLER]: true,
|
||||
[AI_CHAT_THROTTLER]: true,
|
||||
[PAGE_TEMPLATE_THROTTLER]: true,
|
||||
[PUBLIC_SHARE_AI_THROTTLER]: true,
|
||||
[VITALS_THROTTLER]: true,
|
||||
})
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
* from the SIGNED token claim (never a request body), so 'agent' is unspoofable.
|
||||
* Single source of truth so a typo like 'agnet' can't slip through as a bare
|
||||
* string (#143 review). Distinct from `ActorType` (auth principal kind).
|
||||
*
|
||||
* 'git-sync' marks writes made by the git-sync data plane (issue #194 §8.1). It NEVER
|
||||
* travels in a user-facing token; it is set in-process on the collab connection
|
||||
* context by the native datasource, so it cannot be spoofed from a request.
|
||||
*/
|
||||
export type ProvenanceSource = 'user' | 'agent';
|
||||
export type ProvenanceSource = 'user' | 'agent' | 'git-sync';
|
||||
|
||||
export enum JwtType {
|
||||
ACCESS = 'access',
|
||||
@@ -26,7 +30,8 @@ export type JwtPayload = {
|
||||
// normal user token (treated as 'user'); set only when the internal agent
|
||||
// mints a provenance access token so REST writes (create/rename/move page,
|
||||
// comment create/resolve) record a non-spoofable 'agent' marker (§6.5 / §15
|
||||
// C3 / §14 N2).
|
||||
// C3 / §14 N2). (git-sync writes use the in-process actor, not a token — see
|
||||
// the ProvenanceSource note.)
|
||||
actor?: ProvenanceSource;
|
||||
// Nullable: an external MCP agent has no internal ai_chats row, so it carries
|
||||
// an 'agent' actor with a null aiChatId.
|
||||
@@ -39,7 +44,8 @@ export type JwtCollabPayload = {
|
||||
type: 'collab';
|
||||
// Optional agent-edit provenance, signed into the collab token. Absent for
|
||||
// the human collab path (treated as 'user'); set only when the internal agent
|
||||
// mints a provenance collab token (§6.6 / §15 C2).
|
||||
// mints a provenance collab token (§6.6 / §15 C2). 'git-sync' (in ProvenanceSource)
|
||||
// is accepted for type-compatibility with the in-process git-sync write path.
|
||||
actor?: ProvenanceSource;
|
||||
// Nullable: an external MCP agent has no internal ai_chats row, so it carries
|
||||
// an 'agent' actor with a null aiChatId.
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { PageService } from './page.service';
|
||||
import { MovePageDto } from '../dto/move-page.dto';
|
||||
import { Page } from '@docmost/db/types/entity.types';
|
||||
import { CreatePageDto } from '../dto/create-page.dto';
|
||||
import { UpdatePageDto } from '../dto/update-page.dto';
|
||||
import { Page, User } from '@docmost/db/types/entity.types';
|
||||
import { DEFAULT_TEMPORARY_NOTE_HOURS } from '../constants/temporary-note.constants';
|
||||
import { AuthProvenanceData } from '../../../common/decorators/auth-provenance.decorator';
|
||||
|
||||
// Direct instantiation with stub deps. The Test.createTestingModule form failed
|
||||
// to resolve the @InjectKysely()/@InjectQueue() tokens at compile(), and this
|
||||
@@ -496,4 +499,295 @@ describe('PageService', () => {
|
||||
expect(db.selectFrom).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('git-sync provenance stamping (#1)', () => {
|
||||
const GIT_SYNC: AuthProvenanceData = { actor: 'git-sync', aiChatId: null };
|
||||
const USER_PROVENANCE: AuthProvenanceData = { actor: 'user', aiChatId: null };
|
||||
|
||||
describe('create()', () => {
|
||||
// Build a service whose insertPage/generalQueue are observable and whose
|
||||
// nextPagePosition (a DB query) is stubbed, so create() reaches insertPage
|
||||
// without a real database.
|
||||
const makeService = () => {
|
||||
const insertedPage = { id: 'page-1', slugId: 'slug-1' };
|
||||
const pageRepo = {
|
||||
insertPage: jest.fn().mockResolvedValue(insertedPage),
|
||||
};
|
||||
// add() is fire-and-forget (the service .catch()es it); resolve so no
|
||||
// unhandled rejection leaks.
|
||||
const generalQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
const svc = new PageService(
|
||||
pageRepo as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // db
|
||||
{} as any, // storageService
|
||||
{} as any, // attachmentQueue
|
||||
{} as any, // aiQueue
|
||||
generalQueue as any, // generalQueue
|
||||
{} as any, // eventEmitter
|
||||
{} as any, // collaborationGateway
|
||||
{} as any, // watcherService
|
||||
{} as any, // transclusionService
|
||||
);
|
||||
|
||||
// nextPagePosition runs a kysely query; stub it so create() never hits
|
||||
// the db. No DTO content is provided, so parseProsemirrorContent is
|
||||
// skipped entirely (content/textContent/ydoc stay undefined).
|
||||
jest.spyOn(svc, 'nextPagePosition').mockResolvedValue('a0');
|
||||
|
||||
return { svc, pageRepo };
|
||||
};
|
||||
|
||||
const createDto: CreatePageDto = {
|
||||
title: 'New page',
|
||||
spaceId: 'space-1',
|
||||
} as any;
|
||||
|
||||
it("stamps lastUpdatedSource:'git-sync' on the insertPage payload", async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.create('user-1', 'ws-1', createDto, GIT_SYNC);
|
||||
|
||||
expect(pageRepo.insertPage).toHaveBeenCalledTimes(1);
|
||||
expect(pageRepo.insertPage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ lastUpdatedSource: 'git-sync' }),
|
||||
);
|
||||
// git-sync carries no aiChatId (unlike the agent branch).
|
||||
const payload = pageRepo.insertPage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedAiChatId).toBeUndefined();
|
||||
// The human stays the responsible author.
|
||||
expect(payload.creatorId).toBe('user-1');
|
||||
expect(payload.lastUpdatedById).toBe('user-1');
|
||||
});
|
||||
|
||||
it('leaves the source column unset for a plain user create', async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.create('user-1', 'ws-1', createDto, USER_PROVENANCE);
|
||||
|
||||
const payload = pageRepo.insertPage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedSource).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update() (rename)', () => {
|
||||
const makeService = () => {
|
||||
const pageRepo = {
|
||||
updatePage: jest.fn().mockResolvedValue({ numUpdatedRows: 1n }),
|
||||
// update() re-reads the row at the end to return the refreshed page.
|
||||
findById: jest.fn().mockResolvedValue({ id: 'page-1' }),
|
||||
};
|
||||
const generalQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
const aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
const svc = new PageService(
|
||||
pageRepo as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // db
|
||||
{} as any, // storageService
|
||||
{} as any, // attachmentQueue
|
||||
aiQueue as any, // aiQueue
|
||||
generalQueue as any, // generalQueue
|
||||
{} as any, // eventEmitter
|
||||
{} as any, // collaborationGateway
|
||||
{} as any, // watcherService
|
||||
{} as any, // transclusionService
|
||||
);
|
||||
|
||||
return { svc, pageRepo };
|
||||
};
|
||||
|
||||
const page: Page = {
|
||||
id: 'page-1',
|
||||
slugId: 'slug-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
title: 'Old title',
|
||||
icon: null,
|
||||
parentPageId: null,
|
||||
contributorIds: [],
|
||||
} as any;
|
||||
|
||||
const user: User = { id: 'user-1' } as any;
|
||||
|
||||
it("stamps lastUpdatedSource:'git-sync' on the updatePage payload", async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
const dto: UpdatePageDto = { title: 'New title' } as any;
|
||||
|
||||
await svc.update(page, dto, user, GIT_SYNC);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const payload = pageRepo.updatePage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedSource).toBe('git-sync');
|
||||
expect(payload.lastUpdatedAiChatId).toBeUndefined();
|
||||
// The acting user stays the responsible author.
|
||||
expect(payload.lastUpdatedById).toBe('user-1');
|
||||
});
|
||||
|
||||
it('leaves the source column unset for a plain user rename', async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
const dto: UpdatePageDto = { title: 'New title' } as any;
|
||||
|
||||
await svc.update(page, dto, user, USER_PROVENANCE);
|
||||
|
||||
const payload = pageRepo.updatePage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedSource).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('movePage()', () => {
|
||||
const SPACE_ID = 'space-1';
|
||||
const VALID_POSITION = 'a0';
|
||||
|
||||
const makeService = () => {
|
||||
const pageRepo = {
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
id: 'dest-parent',
|
||||
deletedAt: null,
|
||||
spaceId: SPACE_ID,
|
||||
}),
|
||||
updatePage: jest.fn().mockResolvedValue({ numUpdatedRows: 1n }),
|
||||
};
|
||||
const eventEmitter = { emit: jest.fn() };
|
||||
|
||||
// movePage now runs the cycle-check + UPDATE inside executeTx(this.db),
|
||||
// i.e. this.db.transaction().execute(fn => fn(trx)). A permissive
|
||||
// chainable Proxy stands in for the Kysely trx so the per-space
|
||||
// advisory-lock `sql``.execute(trx)` resolves and updatePage runs.
|
||||
const trxStub: any = new Proxy(function () {}, {
|
||||
get: (_t, p) =>
|
||||
p === 'then'
|
||||
? undefined
|
||||
: p === 'execute' || p === 'executeTakeFirst'
|
||||
? () => Promise.resolve([])
|
||||
: () => trxStub,
|
||||
});
|
||||
const db = {
|
||||
transaction: () => ({ execute: (fn: any) => fn(trxStub) }),
|
||||
};
|
||||
|
||||
const svc = new PageService(
|
||||
pageRepo as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // attachmentRepo
|
||||
db as any, // db
|
||||
{} as any, // storageService
|
||||
{} as any, // attachmentQueue
|
||||
{} as any, // aiQueue
|
||||
{} as any, // generalQueue
|
||||
eventEmitter as any, // eventEmitter
|
||||
{} as any, // collaborationGateway
|
||||
{} as any, // watcherService
|
||||
{} as any, // transclusionService
|
||||
);
|
||||
|
||||
// No cycle: the destination's ancestor chain does not contain the moved
|
||||
// page, so movePage reaches updatePage.
|
||||
jest
|
||||
.spyOn(svc, 'getPageBreadCrumbs')
|
||||
.mockResolvedValue([{ id: 'dest-parent' }, { id: 'root' }] as any);
|
||||
|
||||
return { svc, pageRepo };
|
||||
};
|
||||
|
||||
const movedPage: Page = {
|
||||
id: 'page-1',
|
||||
parentPageId: 'old-parent',
|
||||
spaceId: SPACE_ID,
|
||||
workspaceId: 'ws-1',
|
||||
slugId: 'slug-1',
|
||||
title: 'Page 1',
|
||||
icon: null,
|
||||
} as any;
|
||||
|
||||
const dto: MovePageDto = {
|
||||
pageId: 'page-1',
|
||||
position: VALID_POSITION,
|
||||
parentPageId: 'dest-parent',
|
||||
};
|
||||
|
||||
it("stamps lastUpdatedSource:'git-sync' on the updatePage payload", async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.movePage(dto, movedPage, GIT_SYNC);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const payload = pageRepo.updatePage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedSource).toBe('git-sync');
|
||||
expect(payload.lastUpdatedAiChatId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves the source column unset for a plain user move', async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.movePage(dto, movedPage, USER_PROVENANCE);
|
||||
|
||||
const payload = pageRepo.updatePage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedSource).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removePage()', () => {
|
||||
// removePage forwards a `source` 4th arg to pageRepo.removePage: 'git-sync'
|
||||
// for a git-sync-driven soft-delete (so the change-listener loop-guard skips
|
||||
// its own write), undefined otherwise.
|
||||
const makeService = () => {
|
||||
const pageRepo = {
|
||||
removePage: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const svc = new PageService(
|
||||
pageRepo as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // db
|
||||
{} as any, // storageService
|
||||
{} as any, // attachmentQueue
|
||||
{} as any, // aiQueue
|
||||
{} as any, // generalQueue
|
||||
{} as any, // eventEmitter
|
||||
{} as any, // collaborationGateway
|
||||
{} as any, // watcherService
|
||||
{} as any, // transclusionService
|
||||
);
|
||||
|
||||
return { svc, pageRepo };
|
||||
};
|
||||
|
||||
it("forwards 'git-sync' as the source for a git-sync soft-delete", async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.removePage('page-1', 'user-1', 'ws-1', GIT_SYNC);
|
||||
|
||||
expect(pageRepo.removePage).toHaveBeenCalledTimes(1);
|
||||
const [pageId, userId, workspaceId, source] =
|
||||
pageRepo.removePage.mock.calls[0];
|
||||
expect(pageId).toBe('page-1');
|
||||
expect(userId).toBe('user-1');
|
||||
expect(workspaceId).toBe('ws-1');
|
||||
expect(source).toBe('git-sync');
|
||||
});
|
||||
|
||||
it('forwards undefined as the source for a plain user delete', async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.removePage('page-1', 'user-1', 'ws-1', USER_PROVENANCE);
|
||||
|
||||
const [, , , source] = pageRepo.removePage.mock.calls[0];
|
||||
expect(source).toBeUndefined();
|
||||
});
|
||||
|
||||
it('forwards undefined as the source when no provenance is given', async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.removePage('page-1', 'user-1', 'ws-1');
|
||||
|
||||
const [, , , source] = pageRepo.removePage.mock.calls[0];
|
||||
expect(source).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -948,6 +948,12 @@ export class PageService {
|
||||
// Optional agent-edit provenance (from the signed access claim). Stamps the
|
||||
// source marker when the agent moves a page via REST (§6.6 REST path).
|
||||
provenance?: AuthProvenanceData,
|
||||
// Optional responsible author. When set (git-sync), the move is ATTRIBUTED
|
||||
// to that account via `lastUpdatedById` — parity with create/delete/rename,
|
||||
// which all stamp the service user. A normal user move omits it, leaving
|
||||
// `lastUpdatedById` untouched (a reparent is not a content edit, so the
|
||||
// existing author is preserved — unchanged behavior).
|
||||
actorUserId?: string,
|
||||
) {
|
||||
// validate position value by attempting to generate a key
|
||||
try {
|
||||
@@ -1017,6 +1023,9 @@ export class PageService {
|
||||
{
|
||||
position: dto.position,
|
||||
parentPageId: parentPageId,
|
||||
// Attribute a git-initiated move to the service account (parity with
|
||||
// create/delete/rename). Omitted for normal user moves -> unchanged.
|
||||
...(actorUserId ? { lastUpdatedById: actorUserId } : {}),
|
||||
// Agent-edit provenance: annotate the source on an agent move. A
|
||||
// normal user request leaves the existing source value unchanged.
|
||||
...agentSourceFields(
|
||||
@@ -1289,8 +1298,18 @@ export class PageService {
|
||||
pageId: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
// Optional provenance. A git-sync-driven soft-delete stamps
|
||||
// `lastUpdatedSource = 'git-sync'` so the change-listener loop-guard skips
|
||||
// its own write (mirrors the create/update/move provenance branches above).
|
||||
provenance?: AuthProvenanceData,
|
||||
): Promise<void> {
|
||||
await this.pageRepo.removePage(pageId, userId, workspaceId);
|
||||
const isGitSync = provenance?.actor === 'git-sync';
|
||||
await this.pageRepo.removePage(
|
||||
pageId,
|
||||
userId,
|
||||
workspaceId,
|
||||
isGitSync ? 'git-sync' : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
private async parseProsemirrorContent(
|
||||
|
||||
@@ -15,4 +15,12 @@ export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowViewerComments: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
gitSyncEnabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
autoMergeConflicts?: boolean;
|
||||
}
|
||||
|
||||
@@ -22,4 +22,199 @@ describe('SpaceService', () => {
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('updateSpace gitSyncEnabled', () => {
|
||||
const workspaceId = 'ws-1';
|
||||
const spaceId = 'space-1';
|
||||
|
||||
// executeTx runs the callback immediately with a passthrough trx so the
|
||||
// repo calls happen inline; mirrors how the sibling sharing/comments flags
|
||||
// are persisted.
|
||||
const buildService = (settingsBefore: Record<string, any>) => {
|
||||
const spaceRepo = {
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
id: spaceId,
|
||||
name: 'Space',
|
||||
slug: 'space',
|
||||
description: '',
|
||||
settings: settingsBefore,
|
||||
}),
|
||||
updateGitSyncSettings: jest.fn().mockResolvedValue({}),
|
||||
updateSharingSettings: jest.fn().mockResolvedValue({}),
|
||||
updateCommentSettings: jest.fn().mockResolvedValue({}),
|
||||
updateSpace: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: spaceId, name: 'Space', slug: 'space' }),
|
||||
slugExists: jest.fn().mockResolvedValue(false),
|
||||
};
|
||||
const auditService = { log: jest.fn() };
|
||||
|
||||
const svc = new SpaceService(
|
||||
spaceRepo as any,
|
||||
{} as any, // spaceMemberService
|
||||
{} as any, // shareRepo
|
||||
{} as any, // workspaceRepo
|
||||
{} as any, // licenseCheckService
|
||||
{} as any, // db
|
||||
{} as any, // attachmentQueue
|
||||
auditService as any,
|
||||
);
|
||||
|
||||
// executeTx is invoked via the imported helper; patch it on the module.
|
||||
jest
|
||||
.spyOn(require('@docmost/db/utils'), 'executeTx')
|
||||
.mockImplementation(async (_db: any, cb: any) => cb({} as any));
|
||||
|
||||
return { svc, spaceRepo, auditService };
|
||||
};
|
||||
|
||||
it('persists gitSyncEnabled via updateGitSyncSettings(enabled)', async () => {
|
||||
const { svc, spaceRepo } = buildService({});
|
||||
|
||||
await svc.updateSpace(
|
||||
{ spaceId, gitSyncEnabled: true } as any,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(spaceRepo.updateGitSyncSettings).toHaveBeenCalledWith(
|
||||
spaceId,
|
||||
workspaceId,
|
||||
'enabled',
|
||||
true,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not call updateGitSyncSettings when flag is undefined', async () => {
|
||||
const { svc, spaceRepo } = buildService({});
|
||||
|
||||
await svc.updateSpace({ spaceId } as any, workspaceId);
|
||||
|
||||
expect(spaceRepo.updateGitSyncSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- audit delta on the git-sync toggle (test-strategy Module 4 / item #5)
|
||||
// updateSpace builds a before/after delta only when a flag's value actually
|
||||
// changes, and only logs an audit event when that delta is non-empty. These
|
||||
// assert that contract specifically for gitSyncEnabled.
|
||||
it('writes a SPACE_UPDATED audit delta on a REAL gitSyncEnabled change (false -> true)', async () => {
|
||||
// Prior persisted state: gitSync.enabled = false; the request flips it on.
|
||||
const { svc, auditService } = buildService({ gitSync: { enabled: false } });
|
||||
|
||||
await svc.updateSpace(
|
||||
{ spaceId, gitSyncEnabled: true } as any,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(auditService.log).toHaveBeenCalledTimes(1);
|
||||
expect(auditService.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resourceId: spaceId,
|
||||
spaceId,
|
||||
changes: {
|
||||
before: expect.objectContaining({ gitSyncEnabled: false }),
|
||||
after: expect.objectContaining({ gitSyncEnabled: true }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('also records the delta when no prior gitSync settings exist (undefined -> true defaults prev to false)', async () => {
|
||||
// No gitSync key at all: prev resolves to the `?? false` default, so
|
||||
// enabling it is still a real change and is audited.
|
||||
const { svc, auditService } = buildService({});
|
||||
|
||||
await svc.updateSpace(
|
||||
{ spaceId, gitSyncEnabled: true } as any,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(auditService.log).toHaveBeenCalledTimes(1);
|
||||
const call = auditService.log.mock.calls[0][0];
|
||||
expect(call.changes.before.gitSyncEnabled).toBe(false);
|
||||
expect(call.changes.after.gitSyncEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT write an audit delta on a no-op gitSyncEnabled (same value true -> true)', async () => {
|
||||
// Prior persisted state already true; the request sets the same value.
|
||||
// updateGitSyncSettings still runs (idempotent persist), but nothing is
|
||||
// added to the before/after delta, so no audit event is emitted.
|
||||
const { svc, spaceRepo, auditService } = buildService({
|
||||
gitSync: { enabled: true },
|
||||
});
|
||||
|
||||
await svc.updateSpace(
|
||||
{ spaceId, gitSyncEnabled: true } as any,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(spaceRepo.updateGitSyncSettings).toHaveBeenCalledTimes(1);
|
||||
expect(auditService.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- autoMergeConflicts: a SECOND key in the SAME `gitSync` jsonb object,
|
||||
// persisted the same way as `enabled` (the repo's jsonb-merge keeps siblings).
|
||||
it('persists autoMergeConflicts via updateGitSyncSettings(autoMergeConflicts)', async () => {
|
||||
const { svc, spaceRepo } = buildService({});
|
||||
|
||||
await svc.updateSpace(
|
||||
{ spaceId, autoMergeConflicts: true } as any,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(spaceRepo.updateGitSyncSettings).toHaveBeenCalledWith(
|
||||
spaceId,
|
||||
workspaceId,
|
||||
'autoMergeConflicts',
|
||||
true,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not call updateGitSyncSettings when autoMergeConflicts is undefined', async () => {
|
||||
const { svc, spaceRepo } = buildService({});
|
||||
|
||||
await svc.updateSpace({ spaceId } as any, workspaceId);
|
||||
|
||||
expect(spaceRepo.updateGitSyncSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes a SPACE_UPDATED audit delta on a REAL autoMergeConflicts change (false -> true)', async () => {
|
||||
// Prior persisted state: gitSync.autoMergeConflicts = false; flip it on.
|
||||
const { svc, auditService } = buildService({
|
||||
gitSync: { autoMergeConflicts: false },
|
||||
});
|
||||
|
||||
await svc.updateSpace(
|
||||
{ spaceId, autoMergeConflicts: true } as any,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(auditService.log).toHaveBeenCalledTimes(1);
|
||||
expect(auditService.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resourceId: spaceId,
|
||||
spaceId,
|
||||
changes: {
|
||||
before: expect.objectContaining({ autoMergeConflicts: false }),
|
||||
after: expect.objectContaining({ autoMergeConflicts: true }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT write an audit delta on a no-op autoMergeConflicts (same value true -> true)', async () => {
|
||||
const { svc, spaceRepo, auditService } = buildService({
|
||||
gitSync: { autoMergeConflicts: true },
|
||||
});
|
||||
|
||||
await svc.updateSpace(
|
||||
{ spaceId, autoMergeConflicts: true } as any,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(spaceRepo.updateGitSyncSettings).toHaveBeenCalledTimes(1);
|
||||
expect(auditService.log).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -213,6 +213,41 @@ export class SpaceService {
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateSpaceDto.gitSyncEnabled !== 'undefined') {
|
||||
const prev = settingsBefore?.gitSync?.enabled ?? false;
|
||||
if (prev !== updateSpaceDto.gitSyncEnabled) {
|
||||
before.gitSyncEnabled = prev;
|
||||
after.gitSyncEnabled = updateSpaceDto.gitSyncEnabled;
|
||||
}
|
||||
|
||||
await this.spaceRepo.updateGitSyncSettings(
|
||||
updateSpaceDto.spaceId,
|
||||
workspaceId,
|
||||
'enabled',
|
||||
updateSpaceDto.gitSyncEnabled,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateSpaceDto.autoMergeConflicts !== 'undefined') {
|
||||
const prev = settingsBefore?.gitSync?.autoMergeConflicts ?? false;
|
||||
if (prev !== updateSpaceDto.autoMergeConflicts) {
|
||||
before.autoMergeConflicts = prev;
|
||||
after.autoMergeConflicts = updateSpaceDto.autoMergeConflicts;
|
||||
}
|
||||
|
||||
// Merges into the SAME `gitSync` jsonb object as `enabled` (the repo's
|
||||
// jsonb-merge preserves sibling keys), so toggling one never clobbers the
|
||||
// other.
|
||||
await this.spaceRepo.updateGitSyncSettings(
|
||||
updateSpaceDto.spaceId,
|
||||
workspaceId,
|
||||
'autoMergeConflicts',
|
||||
updateSpaceDto.autoMergeConflicts,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
updatedSpace = await this.spaceRepo.updateSpace(
|
||||
{
|
||||
name: updateSpaceDto.name,
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
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] : [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
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,14 +55,6 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
||||
@IsBoolean()
|
||||
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.
|
||||
// Persisted at settings.htmlEmbed. ABSENT/false => OFF (default). The block
|
||||
// itself renders in a sandboxed iframe, so this is a feature switch, not a
|
||||
|
||||
@@ -526,20 +526,6 @@ 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') {
|
||||
const prev = settingsBefore?.htmlEmbed ?? false;
|
||||
if (prev !== updateWorkspaceDto.htmlEmbed) {
|
||||
@@ -593,7 +579,6 @@ export class WorkspaceService {
|
||||
delete updateWorkspaceDto.aiChat;
|
||||
delete updateWorkspaceDto.aiDictation;
|
||||
delete updateWorkspaceDto.aiDictationStreaming;
|
||||
delete updateWorkspaceDto.autonomousRuns;
|
||||
delete updateWorkspaceDto.htmlEmbed;
|
||||
delete updateWorkspaceDto.trackerHead;
|
||||
delete updateWorkspaceDto.aiPublicShareAssistant;
|
||||
|
||||
@@ -31,7 +31,6 @@ import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
|
||||
import { TemplateRepo } from '@docmost/db/repos/template/template.repo';
|
||||
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
||||
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||
import { AiChatRunRepo } from '@docmost/db/repos/ai-chat/ai-chat-run.repo';
|
||||
import { AiChatPageSnapshotRepo } from '@docmost/db/repos/ai-chat/ai-chat-page-snapshot.repo';
|
||||
import { AiProviderCredentialsRepo } from '@docmost/db/repos/ai-chat/ai-provider-credentials.repo';
|
||||
import { AiMcpServerRepo } from '@docmost/db/repos/ai-chat/ai-mcp-server.repo';
|
||||
@@ -41,11 +40,6 @@ import { PageListener } from '@docmost/db/listeners/page.listener';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import * as postgres from 'postgres';
|
||||
import { normalizePostgresUrl } from '../common/helpers';
|
||||
import {
|
||||
observeDbQuery,
|
||||
isMetricsEnabled,
|
||||
} from '../integrations/metrics/metrics.registry';
|
||||
import { firstSqlToken } from '../integrations/metrics/metrics.constants';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
@@ -73,18 +67,6 @@ import { firstSqlToken } from '../integrations/metrics/metrics.constants';
|
||||
}),
|
||||
plugins: [new CamelCasePlugin()],
|
||||
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;
|
||||
const logger = new Logger(DatabaseModule.name);
|
||||
if (process.env.DEBUG_DB?.toLowerCase() === 'true') {
|
||||
@@ -123,7 +105,6 @@ import { firstSqlToken } from '../integrations/metrics/metrics.constants';
|
||||
TemplateRepo,
|
||||
AiChatRepo,
|
||||
AiChatMessageRepo,
|
||||
AiChatRunRepo,
|
||||
AiChatPageSnapshotRepo,
|
||||
AiProviderCredentialsRepo,
|
||||
AiMcpServerRepo,
|
||||
@@ -158,7 +139,6 @@ import { firstSqlToken } from '../integrations/metrics/metrics.constants';
|
||||
TemplateRepo,
|
||||
AiChatRepo,
|
||||
AiChatMessageRepo,
|
||||
AiChatRunRepo,
|
||||
AiChatPageSnapshotRepo,
|
||||
AiProviderCredentialsRepo,
|
||||
AiMcpServerRepo,
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
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,23 +121,6 @@ export class AiChatMessageRepo {
|
||||
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(
|
||||
insertable: InsertableAiChatMessage,
|
||||
trx?: KyselyTransaction,
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,212 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
Kysely,
|
||||
CamelCasePlugin,
|
||||
DummyDriver,
|
||||
PostgresAdapter,
|
||||
PostgresIntrospector,
|
||||
PostgresQueryCompiler,
|
||||
CompiledQuery,
|
||||
} from 'kysely';
|
||||
import { PageRepo } from './page.repo';
|
||||
import type { KyselyDB } from '../../types/kysely.types';
|
||||
|
||||
/**
|
||||
* SQL-builder unit test for the git-sync provenance stamp on PageRepo's
|
||||
* soft-delete / restore paths (PR #119 review). Both `removePage` and
|
||||
* `restorePage` take an optional `lastUpdatedSource` arg and conditionally fold
|
||||
* it into the recursive-subtree `UPDATE pages SET ...` via
|
||||
* `...(lastUpdatedSource ? { lastUpdatedSource } : {})`. The change-listener
|
||||
* loop-guard reads `last_updated_source = 'git-sync'` to recognize git-sync's own
|
||||
* writes and skip the echo cycle; this test guards that the stamp is present when
|
||||
* the arg is supplied and ABSENT when it is omitted (an ordinary user delete must
|
||||
* not clobber the column).
|
||||
*
|
||||
* Harness: the same compile-only Kysely/DummyDriver pattern as
|
||||
* space.repo.spec.ts, plus the production `CamelCasePlugin` (so the compiled SQL
|
||||
* carries the real snake_case column names, e.g. `last_updated_source`) and a
|
||||
* thin driver that returns ONE fixed row for every query. The fixed row is what
|
||||
* lets the repo's guard reads (root snapshot / recursive descendants / restore
|
||||
* target) resolve non-empty so execution reaches the subtree UPDATE we assert on
|
||||
* — a bare DummyDriver returns no rows and both methods short-circuit before the
|
||||
* update. We never hit a real database; we capture each compiled statement via
|
||||
* Kysely's `log` hook and inspect the `update "pages" set ...` SQL.
|
||||
*/
|
||||
describe('PageRepo — git-sync provenance on soft-delete / restore SQL', () => {
|
||||
// A single row shaped to satisfy every column the repo reads off its guard
|
||||
// queries. `parentPageId: null` keeps restorePage on the simple path (no
|
||||
// parent-detach UPDATE), so the only `update "pages"` statement is the one we
|
||||
// assert on.
|
||||
const FIXED_ROW = {
|
||||
id: 'p1',
|
||||
slugId: 's1',
|
||||
title: 'Doc',
|
||||
icon: null,
|
||||
position: 'a0',
|
||||
spaceId: 'space-1',
|
||||
parentPageId: null,
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
class FixedRowDriver extends DummyDriver {
|
||||
async acquireConnection(): Promise<any> {
|
||||
return {
|
||||
async executeQuery() {
|
||||
return { rows: [{ ...FIXED_ROW }] };
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
async *streamQuery() {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Captured {
|
||||
sql: string;
|
||||
parameters: readonly unknown[];
|
||||
}
|
||||
|
||||
// Compile-only Kysely on the Postgres dialect (CamelCasePlugin for real column
|
||||
// names) whose `log` hook records every executed statement's compiled SQL.
|
||||
function makeRepoCapturingSql() {
|
||||
const captured: Captured[] = [];
|
||||
const db = new Kysely<any>({
|
||||
dialect: {
|
||||
createAdapter: () => new PostgresAdapter(),
|
||||
createDriver: () => new FixedRowDriver(),
|
||||
createIntrospector: (d) => new PostgresIntrospector(d),
|
||||
createQueryCompiler: () => new PostgresQueryCompiler(),
|
||||
},
|
||||
plugins: [new CamelCasePlugin()],
|
||||
log: (event) => {
|
||||
if (event.level === 'query') {
|
||||
const q = event.query as CompiledQuery;
|
||||
captured.push({ sql: q.sql, parameters: q.parameters });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const repo = new PageRepo(
|
||||
db as unknown as KyselyDB,
|
||||
{} as any,
|
||||
{ emit: jest.fn() } as any,
|
||||
);
|
||||
// Find the single subtree UPDATE on pages (collapse whitespace for matching).
|
||||
const getUpdatePagesSql = (): Captured | undefined =>
|
||||
captured
|
||||
.map((c) => ({ ...c, sql: c.sql.replace(/\s+/g, ' ') }))
|
||||
.find((c) => /update "pages" set/i.test(c.sql));
|
||||
return { repo, getUpdatePagesSql };
|
||||
}
|
||||
|
||||
describe('removePage', () => {
|
||||
it("stamps last_updated_source = 'git-sync' on the subtree soft-delete when the provenance arg is supplied", async () => {
|
||||
const { repo, getUpdatePagesSql } = makeRepoCapturingSql();
|
||||
|
||||
await repo.removePage('p1', 'user-1', 'ws-1', 'git-sync');
|
||||
|
||||
const update = getUpdatePagesSql();
|
||||
expect(update).toBeDefined();
|
||||
// The provenance column is in the UPDATE's SET clause...
|
||||
expect(update!.sql).toContain('"last_updated_source" =');
|
||||
// ...with the 'git-sync' marker as the bound value.
|
||||
expect(update!.parameters).toContain('git-sync');
|
||||
// Sanity: it is still the soft-delete UPDATE (sets deleted_at too).
|
||||
expect(update!.sql).toContain('"deleted_at" =');
|
||||
});
|
||||
|
||||
it('OMITS last_updated_source from the soft-delete when the provenance arg is undefined', async () => {
|
||||
const { repo, getUpdatePagesSql } = makeRepoCapturingSql();
|
||||
|
||||
await repo.removePage('p1', 'user-1', 'ws-1');
|
||||
|
||||
const update = getUpdatePagesSql();
|
||||
expect(update).toBeDefined();
|
||||
// Ordinary user delete: the column must NOT be touched (keeps prior value).
|
||||
expect(update!.sql).not.toContain('last_updated_source');
|
||||
expect(update!.parameters).not.toContain('git-sync');
|
||||
// It is still the soft-delete UPDATE.
|
||||
expect(update!.sql).toContain('"deleted_at" =');
|
||||
});
|
||||
});
|
||||
|
||||
describe('restorePage', () => {
|
||||
it("stamps last_updated_source = 'git-sync' on the subtree restore when the provenance arg is supplied", async () => {
|
||||
const { repo, getUpdatePagesSql } = makeRepoCapturingSql();
|
||||
|
||||
await repo.restorePage('p1', 'ws-1', 'git-sync');
|
||||
|
||||
const update = getUpdatePagesSql();
|
||||
expect(update).toBeDefined();
|
||||
expect(update!.sql).toContain('"last_updated_source" =');
|
||||
expect(update!.parameters).toContain('git-sync');
|
||||
// Sanity: it is the restore UPDATE (clears deleted_at).
|
||||
expect(update!.sql).toContain('"deleted_at" =');
|
||||
});
|
||||
|
||||
it('OMITS last_updated_source from the restore when the provenance arg is undefined', async () => {
|
||||
const { repo, getUpdatePagesSql } = makeRepoCapturingSql();
|
||||
|
||||
await repo.restorePage('p1', 'ws-1');
|
||||
|
||||
const update = getUpdatePagesSql();
|
||||
expect(update).toBeDefined();
|
||||
expect(update!.sql).not.toContain('last_updated_source');
|
||||
expect(update!.parameters).not.toContain('git-sync');
|
||||
expect(update!.sql).toContain('"deleted_at" =');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -349,6 +349,11 @@ export class PageRepo {
|
||||
pageId: string,
|
||||
deletedById: string,
|
||||
workspaceId: string,
|
||||
// Optional provenance marker. When the soft-delete is driven by an automated
|
||||
// data plane (e.g. git-sync), stamp `lastUpdatedSource` so the change-listener
|
||||
// loop-guard recognizes it as its own write and does not schedule an echo
|
||||
// cycle. Omitted for ordinary user deletes (column keeps its prior value).
|
||||
lastUpdatedSource?: string,
|
||||
): Promise<void> {
|
||||
const currentDate = new Date();
|
||||
|
||||
@@ -399,6 +404,7 @@ export class PageRepo {
|
||||
.set({
|
||||
deletedById: deletedById,
|
||||
deletedAt: currentDate,
|
||||
...(lastUpdatedSource ? { lastUpdatedSource } : {}),
|
||||
})
|
||||
.where('id', 'in', pageIds)
|
||||
.where('deletedAt', 'is', null)
|
||||
@@ -429,7 +435,14 @@ export class PageRepo {
|
||||
}
|
||||
}
|
||||
|
||||
async restorePage(pageId: string, workspaceId: string): Promise<void> {
|
||||
async restorePage(
|
||||
pageId: string,
|
||||
workspaceId: string,
|
||||
// See removePage: stamp `lastUpdatedSource` for automated (git-sync) restores
|
||||
// so the change-listener loop-guard skips the echo cycle. Omitted for
|
||||
// ordinary user restores.
|
||||
lastUpdatedSource?: string,
|
||||
): Promise<void> {
|
||||
// First, check if the page being restored has a deleted parent
|
||||
const pageToRestore = await this.db
|
||||
.selectFrom('pages')
|
||||
@@ -480,7 +493,12 @@ export class PageRepo {
|
||||
// On restore, disarm the death timer: pulling a note out of trash means
|
||||
// "keep it". Otherwise a deadline now in the past would re-trash it on the
|
||||
// next cleanup sweep.
|
||||
.set({ deletedById: null, deletedAt: null, temporaryExpiresAt: null })
|
||||
.set({
|
||||
deletedById: null,
|
||||
deletedAt: null,
|
||||
temporaryExpiresAt: null,
|
||||
...(lastUpdatedSource ? { lastUpdatedSource } : {}),
|
||||
})
|
||||
.where('id', 'in', pageIds)
|
||||
.execute();
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
Kysely,
|
||||
DummyDriver,
|
||||
PostgresAdapter,
|
||||
PostgresIntrospector,
|
||||
PostgresQueryCompiler,
|
||||
CompiledQuery,
|
||||
} from 'kysely';
|
||||
import { SpaceRepo } from './space.repo';
|
||||
import type { KyselyDB } from '../../types/kysely.types';
|
||||
|
||||
/**
|
||||
* SQL-builder unit test for the jsonb-merge invariant of
|
||||
* SpaceRepo.updateGitSyncSettings (review comment #694 / test-strategy item #6).
|
||||
*
|
||||
* The merge is RAW SQL, so a behavioural test would need a live Postgres — which
|
||||
* is intentionally out of scope here (the reviewer's own §13.3 was deferred for
|
||||
* the same reason). Instead we follow the existing repo-spec convention
|
||||
* (ai-agent-roles.repo.spec.ts) of NOT executing: we compile the query with a
|
||||
* DummyDriver Postgres dialect and assert the generated SQL preserves sibling
|
||||
* keys. The structural invariant the SQL must encode:
|
||||
*
|
||||
* settings := COALESCE(settings, '{}') || jsonb_build_object('gitSync', ...)
|
||||
* gitSync := COALESCE(settings->'gitSync', '{}') || jsonb_build_object(key, value)
|
||||
*
|
||||
* The OUTER `||` merges into the existing top-level `settings`, so a sibling
|
||||
* top-level key (e.g. `sharing`) is preserved. The INNER COALESCE merges into
|
||||
* the existing `gitSync` object, so a sibling key inside gitSync (e.g. `other`)
|
||||
* is preserved. A naive `set settings = jsonb_build_object('gitSync', ...)`
|
||||
* would clobber both — this test guards exactly that regression.
|
||||
*/
|
||||
describe('SpaceRepo.updateGitSyncSettings — jsonb merge SQL', () => {
|
||||
// A real Kysely on the Postgres dialect, but with a DummyDriver: it compiles
|
||||
// queries to real Postgres SQL without ever opening a connection.
|
||||
function makeCompileOnlyDb() {
|
||||
return new Kysely<any>({
|
||||
dialect: {
|
||||
createAdapter: () => new PostgresAdapter(),
|
||||
createDriver: () => new DummyDriver(),
|
||||
createIntrospector: (db) => new PostgresIntrospector(db),
|
||||
createQueryCompiler: () => new PostgresQueryCompiler(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Build the repo over the compile-only db. The repo terminates the query with
|
||||
// `.executeTakeFirst()`, so we wrap every kysely builder in a Proxy: when the
|
||||
// repo finally calls `executeTakeFirst`, we `.compile()` that same builder
|
||||
// ourselves to capture the exact SQL it was about to run, then delegate.
|
||||
function makeRepoCapturingSql() {
|
||||
const db = makeCompileOnlyDb();
|
||||
let captured: CompiledQuery | undefined;
|
||||
|
||||
// kysely builders are immutable — each .set()/.where()/.returningAll()
|
||||
// returns a NEW builder — so re-wrap any chainable result.
|
||||
const wrap = (b: any): any =>
|
||||
new Proxy(b, {
|
||||
get(target, prop, receiver) {
|
||||
const value = Reflect.get(target, prop, receiver);
|
||||
if (typeof value !== 'function') return value;
|
||||
return (...callArgs: unknown[]) => {
|
||||
// Capture the SQL at the terminal execute call.
|
||||
if (
|
||||
(prop === 'executeTakeFirst' || prop === 'execute') &&
|
||||
typeof target.compile === 'function'
|
||||
) {
|
||||
captured = target.compile();
|
||||
}
|
||||
const result = value.apply(target, callArgs);
|
||||
if (
|
||||
result &&
|
||||
typeof result === 'object' &&
|
||||
typeof (result as any).compile === 'function'
|
||||
) {
|
||||
return wrap(result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const originalUpdateTable = db.updateTable.bind(db);
|
||||
jest
|
||||
.spyOn(db, 'updateTable')
|
||||
.mockImplementation((...args: Parameters<typeof originalUpdateTable>) =>
|
||||
wrap(originalUpdateTable(...args)),
|
||||
);
|
||||
|
||||
const repo = new SpaceRepo(db as unknown as KyselyDB, {} as any);
|
||||
return { repo, getCaptured: () => captured };
|
||||
}
|
||||
|
||||
it("compiles a jsonb merge that preserves sibling top-level and gitSync keys", async () => {
|
||||
const { repo, getCaptured } = makeRepoCapturingSql();
|
||||
|
||||
// DummyDriver yields no rows; executeTakeFirst resolves to undefined. The
|
||||
// SQL is fully compiled by then, which is all we assert.
|
||||
await repo.updateGitSyncSettings('space-1', 'ws-1', 'enabled', true);
|
||||
|
||||
const compiled = getCaptured();
|
||||
expect(compiled).toBeDefined();
|
||||
// The raw SQL template carries newlines/indentation; collapse whitespace so
|
||||
// the structural assertions are not coupled to source formatting.
|
||||
const sql = compiled!.sql.replace(/\s+/g, ' ');
|
||||
|
||||
// OUTER merge into the existing settings object -> sibling top-level keys
|
||||
// (e.g. `sharing`) survive (NOT a bare jsonb_build_object assignment).
|
||||
expect(sql).toContain(`set "settings" = COALESCE(settings, '{}'::jsonb) ||`);
|
||||
// INNER merge into the existing gitSync object -> sibling gitSync keys
|
||||
// (e.g. `other`) survive.
|
||||
expect(sql).toContain(
|
||||
`jsonb_build_object('gitSync', COALESCE(settings->'gitSync', '{}'::jsonb) ||`,
|
||||
);
|
||||
// The pref key is set via jsonb_build_object on the inner object, with the
|
||||
// key as a BOUND, ::text-cast PARAMETER (not sql.raw) — security fix #5.
|
||||
expect(sql).toMatch(/jsonb_build_object\(\$\d+::text,/);
|
||||
// Scoped to the row + workspace.
|
||||
expect(sql).toContain(`where "id" =`);
|
||||
expect(sql).toContain(`and "workspaceId" =`);
|
||||
|
||||
// Sanity: this is NOT a clobbering assignment (no top-level
|
||||
// `set "settings" = jsonb_build_object(` without the COALESCE/merge).
|
||||
expect(sql).not.toContain(`set "settings" = jsonb_build_object(`);
|
||||
|
||||
// The pref VALUE stays inlined via sql.lit, but the KEY is now a bound
|
||||
// parameter, so id + workspaceId + the key are all bound (updatedAt is a Date).
|
||||
expect(compiled!.parameters).toContain('space-1');
|
||||
expect(compiled!.parameters).toContain('ws-1');
|
||||
expect(compiled!.parameters).toContain('enabled');
|
||||
});
|
||||
|
||||
it('binds the prefKey as a ::text parameter (no sql.raw splice) and inlines prefValue via sql.lit', async () => {
|
||||
const { repo, getCaptured } = makeRepoCapturingSql();
|
||||
|
||||
await repo.updateGitSyncSettings('space-1', 'ws-1', 'enabled', false);
|
||||
|
||||
const compiled = getCaptured()!;
|
||||
const sql = compiled.sql.replace(/\s+/g, ' ');
|
||||
// The key is a bound `$N::text` parameter; the value is the sql.lit literal.
|
||||
expect(sql).toMatch(/jsonb_build_object\(\$\d+::text, false\)/);
|
||||
// The literal key must NOT be spliced into the statement text (the footgun).
|
||||
expect(sql).not.toContain(`'enabled'`);
|
||||
// The key rides as a bound parameter instead.
|
||||
expect(compiled.parameters).toContain('enabled');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user