Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a53be9e81 | |||
| 95c0d813b0 | |||
| 9004de60e3 |
+3
-75
@@ -225,42 +225,17 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
|
|
||||||
# Silence timeout (ms) for EXTERNAL-MCP transport ONLY (not the chat provider).
|
# Silence timeout (ms) for EXTERNAL-MCP transport ONLY (not the chat provider).
|
||||||
# Tighter than AI_STREAM_TIMEOUT_MS so a byte-silent/hung MCP server is broken in
|
# Tighter than AI_STREAM_TIMEOUT_MS so a byte-silent/hung MCP server is broken in
|
||||||
# ~1 min instead of 15. It cuts a legitimately long but byte-silent single tool
|
# ~1 min instead of 15. Note it also cuts a legitimately long but byte-silent
|
||||||
# call (a slow crawl that emits nothing until done) on the HTTP (streamable)
|
# single tool call (a slow crawl that emits nothing until done) and an SSE
|
||||||
# transport, which opens a fresh request per call. The SSE transport — one
|
# transport idling >1 min BETWEEN tool calls. Default 60000 (1 min).
|
||||||
# long-lived body across many calls — is NO LONGER governed by this timeout
|
|
||||||
# (as of #489): its idle-BETWEEN-calls window has its own, raised bodyTimeout,
|
|
||||||
# AI_MCP_SSE_BODY_TIMEOUT_MS below. Default 60000 (1 min).
|
|
||||||
# AI_MCP_STREAM_TIMEOUT_MS=60000
|
# AI_MCP_STREAM_TIMEOUT_MS=60000
|
||||||
|
|
||||||
# bodyTimeout (ms) for the EXTERNAL-MCP SSE transport ONLY (#489). The SSE
|
|
||||||
# transport holds ONE response body open across many tool calls, so undici's
|
|
||||||
# bodyTimeout (time between body bytes) counts the LEGITIMATE silence BETWEEN the
|
|
||||||
# model's tool calls, not just a hung single call. At the tight 1-min silence
|
|
||||||
# timeout above, a normal >1-min gap between calls would break the SSE socket and
|
|
||||||
# the cache would serve a dead client until TTL — so the SSE transport gets its
|
|
||||||
# OWN, RAISED bodyTimeout. A single stuck call is still bounded by the per-call
|
|
||||||
# cap (AI_MCP_CALL_TIMEOUT_MS), and a socket that does break is healed by the
|
|
||||||
# in-run transport-error retry. The HTTP (streamable) transport keeps the tight
|
|
||||||
# timeout. Default 600000 (10 min).
|
|
||||||
# AI_MCP_SSE_BODY_TIMEOUT_MS=600000
|
|
||||||
|
|
||||||
# Total wall-clock cap (ms) for ONE external MCP tool call (app-level, not
|
# Total wall-clock cap (ms) for ONE external MCP tool call (app-level, not
|
||||||
# transport). Aborts a tool that keeps the socket warm (SSE heartbeats / trickle)
|
# transport). Aborts a tool that keeps the socket warm (SSE heartbeats / trickle)
|
||||||
# but never returns a result — which the silence timeout above never breaks.
|
# but never returns a result — which the silence timeout above never breaks.
|
||||||
# Default 120000 (2 min).
|
# Default 120000 (2 min).
|
||||||
# AI_MCP_CALL_TIMEOUT_MS=120000
|
# AI_MCP_CALL_TIMEOUT_MS=120000
|
||||||
|
|
||||||
# Kill-switch for the agent API-key feature (#501). Default ON when unset — a
|
|
||||||
# deploy that never sets it must NOT silently kill every agent. STRICT parse:
|
|
||||||
# only the literals `true` / `false` are accepted; a typo like `=0`/`=off`/`=False`
|
|
||||||
# FAILS AT BOOT by design (never silently read as "enabled"), so the switch is
|
|
||||||
# guaranteed to actually flip when an operator flips it during an incident. When
|
|
||||||
# set to `false`: all api-key auth is DENIED (every api-key token is rejected) and
|
|
||||||
# the api-key management endpoints return 404. The resolved state is logged at boot
|
|
||||||
# (`API keys: ENABLED/DISABLED (API_KEYS_ENABLED=...)`) so it is verifiable per deploy.
|
|
||||||
# API_KEYS_ENABLED=true
|
|
||||||
|
|
||||||
# Max JSON/urlencoded request body size (bytes). Fastify's 1 MiB default is too
|
# Max JSON/urlencoded request body size (bytes). Fastify's 1 MiB default is too
|
||||||
# small for a long AI-chat research turn: the client resends the FULL message
|
# small for a long AI-chat research turn: the client resends the FULL message
|
||||||
# history (every tool call + search result) on each turn, so a deep conversation's
|
# history (every tool call + search result) on each turn, so a deep conversation's
|
||||||
@@ -312,39 +287,6 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# enabled for a workspace, and the same single-instance constraint applies (the
|
# enabled for a workspace, and the same single-instance constraint applies (the
|
||||||
# registry is process-local).
|
# registry is process-local).
|
||||||
# AI_CHAT_RESUMABLE_STREAM=false
|
# AI_CHAT_RESUMABLE_STREAM=false
|
||||||
#
|
|
||||||
# Per-run replay ring cap (#491), in BYTES, for the resumable-stream registry
|
|
||||||
# above. The registry buffers the run's recent SSE tail so a reopened tab can
|
|
||||||
# attach and continue from the step it already persisted; the ring is bounded and
|
|
||||||
# rotates on every confirmed step-persist. This caps the un-persisted tail between
|
|
||||||
# rotations — an overflow evicts the oldest frames and a late attach falls back to
|
|
||||||
# 204 -> degraded poll, so correctness never depends on the size. Default 4194304
|
|
||||||
# (4MB); a 0/invalid value falls back to the default. The per-subscriber backpressure
|
|
||||||
# cap is derived as 2x this value. Only meaningful with AI_CHAT_RESUMABLE_STREAM on.
|
|
||||||
# AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES=4194304
|
|
||||||
|
|
||||||
# --- Run lifecycle tunables (#487) ---
|
|
||||||
# These govern the universal run machinery (every turn is now a first-class run,
|
|
||||||
# both modes) and rarely need changing.
|
|
||||||
#
|
|
||||||
# How long a server-side SUPERSEDE ("interrupt and send now") waits for the target
|
|
||||||
# run to settle after issuing Stop before it degrades to a 409 SUPERSEDE_TIMEOUT
|
|
||||||
# (nothing sent, the composer keeps the user's text). 10s is generous under a
|
|
||||||
# healthy DB; do NOT raise it to paper over a slow DB — a SUPERSEDE_TIMEOUT is the
|
|
||||||
# honest signal. Default 10000 (10s).
|
|
||||||
# AI_CHAT_SUPERSEDE_TIMEOUT_MS=10000
|
|
||||||
#
|
|
||||||
# How often the periodic bidirectional reconcile job runs (heals runs/messages
|
|
||||||
# left dangling by a crash or a lost terminal write). Default 120000 (2 min).
|
|
||||||
# AI_CHAT_RECONCILE_INTERVAL_MS=120000
|
|
||||||
#
|
|
||||||
# Wall-clock cap for a SINGLE in-app tool call (a long paginated read, or a content
|
|
||||||
# write whose collab commit hangs) — the per-call half of the composite abort
|
|
||||||
# signal every in-app tool is wrapped with (the other half is the turn's Stop).
|
|
||||||
# The reconcile staleness floor is derived as max(2 x this cap, 15min), so a very
|
|
||||||
# high value delays stale-run recovery (the server boot-warns above 30min). Default
|
|
||||||
# 120000 (2 min).
|
|
||||||
# AI_CHAT_INAPP_TOOL_CALL_CAP_MS=120000
|
|
||||||
|
|
||||||
# --- Anonymous public-share AI assistant ---
|
# --- Anonymous public-share AI assistant ---
|
||||||
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
||||||
@@ -393,20 +335,6 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# VictoriaMetrics/Prometheus reaching it as <host>:<port>/metrics.
|
# VictoriaMetrics/Prometheus reaching it as <host>:<port>/metrics.
|
||||||
# METRICS_PORT=9464
|
# METRICS_PORT=9464
|
||||||
#
|
#
|
||||||
# METRICS_BIND — interface the /metrics listener binds to. DEFAULT 127.0.0.1
|
|
||||||
# (loopback only), so the unauthenticated endpoint is NOT exposed on all
|
|
||||||
# interfaces. If the scraper runs in a SEPARATE container and reaches this as
|
|
||||||
# docmost:9464, set METRICS_BIND=0.0.0.0 — but then also set METRICS_TOKEN
|
|
||||||
# and/or keep the port on a private network, since /metrics is otherwise open.
|
|
||||||
# METRICS_BIND=127.0.0.1
|
|
||||||
#
|
|
||||||
# METRICS_TOKEN — optional Bearer token guarding /metrics. When set, every
|
|
||||||
# scrape MUST send `Authorization: Bearer <token>` (others get 401). Configure
|
|
||||||
# the scraper with the same bearer token (e.g. VictoriaMetrics/vmagent
|
|
||||||
# `bearer_token`, Prometheus `authorization.credentials`). Leave unset only
|
|
||||||
# when the endpoint is bound to loopback or an otherwise-trusted network.
|
|
||||||
# METRICS_TOKEN=
|
|
||||||
#
|
|
||||||
# 2) CLIENT_TELEMETRY_ENABLED — the public client perf-telemetry sink.
|
# 2) CLIENT_TELEMETRY_ENABLED — the public client perf-telemetry sink.
|
||||||
# OFF by default. When true, the unauthenticated POST /api/telemetry/vitals
|
# OFF by default. When true, the unauthenticated POST /api/telemetry/vitals
|
||||||
# endpoint is registered and browsers collect + send web-vitals / editor
|
# endpoint is registered and browsers collect + send web-vitals / editor
|
||||||
|
|||||||
@@ -62,38 +62,6 @@ jobs:
|
|||||||
needs: [test, e2e-server, e2e-mcp, build]
|
needs: [test, e2e-server, e2e-mcp, build]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
# Image boot-smoke (issue #476): every other job tests code from the working
|
|
||||||
# tree, but the :develop IMAGE that watchtower pulls was never actually
|
|
||||||
# started anywhere (incident classes #353/#452/#361-boot: startup-migrator
|
|
||||||
# crash-loop, runtime module missing from the image, wrong static-asset
|
|
||||||
# headers). The services below back a smoke boot of the exact image right
|
|
||||||
# before it is pushed; a smoke failure blocks the push.
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
# via mirror.gcr.io (Docker Hub pull-through cache; avoids Hub anonymous
|
|
||||||
# pull rate-limit that randomly fails on shared GitHub runner IPs).
|
|
||||||
image: mirror.gcr.io/pgvector/pgvector:pg18
|
|
||||||
env:
|
|
||||||
POSTGRES_DB: docmost
|
|
||||||
POSTGRES_USER: docmost
|
|
||||||
POSTGRES_PASSWORD: docmost
|
|
||||||
ports:
|
|
||||||
- 5432:5432
|
|
||||||
options: >-
|
|
||||||
--health-cmd "pg_isready -U docmost"
|
|
||||||
--health-interval 5s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 20
|
|
||||||
redis:
|
|
||||||
# via mirror.gcr.io (see postgres note above).
|
|
||||||
image: mirror.gcr.io/library/redis:7
|
|
||||||
ports:
|
|
||||||
- 6379:6379
|
|
||||||
options: >-
|
|
||||||
--health-cmd "redis-cli ping"
|
|
||||||
--health-interval 5s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 20
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -114,37 +82,6 @@ jobs:
|
|||||||
id: version
|
id: version
|
||||||
run: echo "value=$(git describe --tags --always)" >> "$GITHUB_OUTPUT"
|
run: echo "value=$(git describe --tags --always)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# Load the image into the local docker daemon so it can be booted (the
|
|
||||||
# push step below exports straight to the registry and leaves nothing
|
|
||||||
# runnable locally). CONVENTION: build-args here must stay TEXTUALLY
|
|
||||||
# IDENTICAL to the push step's build-args — same cache scope + same args
|
|
||||||
# means the layers are reused and the image we smoke IS the image we push.
|
|
||||||
- name: Build image for smoke (load, no push)
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
platforms: linux/amd64
|
|
||||||
build-args: |
|
|
||||||
APP_VERSION=${{ steps.version.outputs.value }}
|
|
||||||
AI_AGENT_ROLES_CATALOG_URL=https://raw.githubusercontent.com/vvzvlad/gitmost/develop/agent-roles-catalog
|
|
||||||
load: true
|
|
||||||
push: false
|
|
||||||
tags: gitmost:smoke
|
|
||||||
cache-from: type=gha,scope=develop-amd64
|
|
||||||
|
|
||||||
# Boot-smoke the exact image against the job services (see the comment on
|
|
||||||
# `services:` above): health (startup migrator), auth/setup, client dist
|
|
||||||
# served, immutable + brotli asset headers. Fails the job (and therefore
|
|
||||||
# the push) on any miss.
|
|
||||||
- name: Smoke the built image
|
|
||||||
run: bash scripts/ci/image-smoke.sh gitmost:smoke
|
|
||||||
|
|
||||||
# The smoke script leaves the container running on failure precisely so
|
|
||||||
# the boot error (migration mismatch, stack trace) is diagnosable here.
|
|
||||||
- name: Dump smoke container log on failure
|
|
||||||
if: failure()
|
|
||||||
run: docker logs gitmost-smoke 2>&1 | tail -200 || true
|
|
||||||
|
|
||||||
- name: Build and push develop image
|
- name: Build and push develop image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
@@ -226,13 +163,6 @@ jobs:
|
|||||||
- name: Build mcp
|
- name: Build mcp
|
||||||
run: pnpm --filter @docmost/mcp build
|
run: pnpm --filter @docmost/mcp build
|
||||||
|
|
||||||
# apps/server imports @docmost/token-estimate at runtime (history-budget.ts,
|
|
||||||
# #490); its dist/ is gitignored and `test:e2e` type-checks + runs the code,
|
|
||||||
# so build it here or tsc fails with TS2307 Cannot find module
|
|
||||||
# '@docmost/token-estimate' (mirrors the editor-ext / mcp build steps above).
|
|
||||||
- name: Build token-estimate
|
|
||||||
run: pnpm --filter @docmost/token-estimate build
|
|
||||||
|
|
||||||
- name: Run migrations
|
- name: Run migrations
|
||||||
run: pnpm --filter ./apps/server migration:latest
|
run: pnpm --filter ./apps/server migration:latest
|
||||||
|
|
||||||
|
|||||||
@@ -124,17 +124,9 @@ jobs:
|
|||||||
exit "$FAILED"
|
exit "$FAILED"
|
||||||
|
|
||||||
# A GENUINE counterexample: fast-check printed a shrunk minimal case and its
|
# A GENUINE counterexample: fast-check printed a shrunk minimal case and its
|
||||||
# reproducing seed into property-output.txt. File a dedup-guarded issue.
|
# reproducing seed into property-output.txt. File a dedup-guarded issue whose
|
||||||
#
|
# title prefix is UNIQUE to counterexamples, so an infra failure (handled by
|
||||||
# Dedup is keyed on a HASH of the SHRUNK COUNTEREXAMPLE (the minimal failing
|
# the next step under a different title) can never poison this dedup.
|
||||||
# input), NOT on the issue title prefix. Keying on the prefix would let a
|
|
||||||
# single open issue swallow every OTHER counterexample (a different bug B whose
|
|
||||||
# title shares the prefix would be treated as a duplicate and stay silent until
|
|
||||||
# the first issue is closed). Hashing the shrunk example instead means two
|
|
||||||
# DIFFERENT counterexamples get two DIFFERENT issues, while a re-find of the
|
|
||||||
# SAME counterexample still dedupes onto the existing one. The infra-failure
|
|
||||||
# step (below) still keys on its own distinct title, so it can never poison
|
|
||||||
# this dedup either.
|
|
||||||
- name: File counterexample issue
|
- name: File counterexample issue
|
||||||
# always() is REQUIRED: the fuzz step exits nonzero on a failing shard,
|
# always() is REQUIRED: the fuzz step exits nonzero on a failing shard,
|
||||||
# so a bare `if:` (implicitly success() && ...) would skip this step
|
# so a bare `if:` (implicitly success() && ...) would skip this step
|
||||||
@@ -154,48 +146,25 @@ jobs:
|
|||||||
echo "No fast-check counterexample signature — infra failure, handled by the next step."
|
echo "No fast-check counterexample signature — infra failure, handled by the next step."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
# Extract the SHRUNK counterexample block: the "Counterexample:" line(s)
|
TITLE="${TITLE_PREFIX} (seed=${FAIL_SEED})"
|
||||||
# up to (but excluding) the "Shrunk N time(s)" / "Got error" line. This is
|
|
||||||
# the minimal failing INPUT and is STABLE across the different seeds/paths
|
|
||||||
# that reach the same bug — unlike the seed, path, or shrink count (which
|
|
||||||
# precede/follow this block and vary run-to-run) and unlike the whole
|
|
||||||
# output (which embeds those varying parts). Hashing THIS is what makes the
|
|
||||||
# dedup identity the bug itself rather than an incidental run detail.
|
|
||||||
CE_TEXT=$(awk '/Counterexample:/{c=1} /Shrunk [0-9]+ time|Got error/{c=0} c{print}' property-output.txt)
|
|
||||||
if [ -z "$CE_TEXT" ]; then
|
|
||||||
# No parseable shrunk block (unexpected — the signature check above
|
|
||||||
# already confirmed fast-check output). Fall back to the reproducing
|
|
||||||
# seed so we still emit a stable identity instead of silently deduping.
|
|
||||||
CE_TEXT="seed:${FAIL_SEED}"
|
|
||||||
fi
|
|
||||||
# Stable short id: first 12 hex chars of sha256 over the counterexample.
|
|
||||||
CE_HASH=$(printf '%s' "$CE_TEXT" | sha256sum | cut -c1-12)
|
|
||||||
# Machine-readable marker embedded in the issue body; the open-issue search
|
|
||||||
# below matches on it (and on the hash in the title) so identity travels
|
|
||||||
# with the issue regardless of any human title edits.
|
|
||||||
CE_MARKER="<!-- counterexample-hash: ${CE_HASH} -->"
|
|
||||||
export CE_HASH CE_MARKER
|
|
||||||
TITLE="${TITLE_PREFIX} [${CE_HASH}] (seed=${FAIL_SEED})"
|
|
||||||
|
|
||||||
# Dedup on the counterexample hash: skip only if an OPEN issue already
|
# Best-effort dedup: skip if an open issue with the counterexample title
|
||||||
# carries this exact hash (in its title or its body marker). A different
|
# prefix already exists. A failure of this check must NOT block creation.
|
||||||
# counterexample has a different hash and is NOT deduped. A failure of this
|
|
||||||
# check must NOT block creation.
|
|
||||||
EXISTING=""
|
EXISTING=""
|
||||||
if EXISTING=$(curl -sS \
|
if EXISTING=$(curl -sS \
|
||||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues?state=open&limit=100"); then
|
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues?state=open&limit=100"); then
|
||||||
if printf '%s' "$EXISTING" \
|
if printf '%s' "$EXISTING" \
|
||||||
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let a;try{a=JSON.parse(s)}catch{process.exit(1)}if(!Array.isArray(a))process.exit(1);const h=process.env.CE_HASH,m=process.env.CE_MARKER;process.exit(a.some(i=>(typeof i.title==="string"&&i.title.includes(h))||(typeof i.body==="string"&&i.body.includes(m)))?0:1)})'; then
|
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let a;try{a=JSON.parse(s)}catch{process.exit(1)}if(!Array.isArray(a))process.exit(1);const p=process.env.TITLE_PREFIX;process.exit(a.some(i=>typeof i.title==="string"&&i.title.startsWith(p))?0:1)})'; then
|
||||||
echo "An open issue for counterexample ${CE_HASH} already exists — skipping creation."
|
echo "An open '${TITLE_PREFIX}' issue already exists — skipping creation."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Build the JSON body with the test output SAFELY escaped (never hand-
|
# Build the JSON body with the test output SAFELY escaped (never hand-
|
||||||
# interpolate the counterexample into JSON).
|
# interpolate the counterexample into JSON).
|
||||||
BODY_TEXT=$(printf 'A nightly property fuzz SHARD failed with a fast-check counterexample.\n\n- counterexample hash: `%s`\n- failing shard seed: `%s`\n- NUM_RUNS (per shard): `%s`\n- run: %s\n\nReproduce locally:\n\n```\nPROPERTY_SEED=%s PROPERTY_NUM_RUNS=%s pnpm --filter @docmost/prosemirror-markdown exec vitest run test/generative/\n```\n\nfast-check shrinks the failure to a minimal counterexample. Commit it as a permanent fixture under `packages/prosemirror-markdown/test/fixtures/counterexamples/` + a case in `counterexamples.test.ts`, then FIX the converter (do not weaken a property). See `packages/prosemirror-markdown/README.md`.\n\nTail of the test output (contains the shrunk counterexample):\n\n```\n%s\n```\n\n%s\n' \
|
BODY_TEXT=$(printf 'A nightly property fuzz SHARD failed with a fast-check counterexample.\n\n- failing shard seed: `%s`\n- NUM_RUNS (per shard): `%s`\n- run: %s\n\nReproduce locally:\n\n```\nPROPERTY_SEED=%s PROPERTY_NUM_RUNS=%s pnpm --filter @docmost/prosemirror-markdown exec vitest run test/generative/\n```\n\nfast-check shrinks the failure to a minimal counterexample. Commit it as a permanent fixture under `packages/prosemirror-markdown/test/fixtures/counterexamples/` + a case in `counterexamples.test.ts`, then FIX the converter (do not weaken a property). See `packages/prosemirror-markdown/README.md`.\n\nTail of the test output (contains the shrunk counterexample):\n\n```\n%s\n```\n' \
|
||||||
"$CE_HASH" "$FAIL_SEED" "$NUM_RUNS" "$RUN_URL" "$FAIL_SEED" "$NUM_RUNS" "$(tail -n 120 property-output.txt)" "$CE_MARKER")
|
"$FAIL_SEED" "$NUM_RUNS" "$RUN_URL" "$FAIL_SEED" "$NUM_RUNS" "$(tail -n 120 property-output.txt)")
|
||||||
|
|
||||||
jq -n --arg title "$TITLE" --arg body "$BODY_TEXT" \
|
jq -n --arg title "$TITLE" --arg body "$BODY_TEXT" \
|
||||||
'{title: $title, body: $body}' > payload.json
|
'{title: $title, body: $body}' > payload.json
|
||||||
|
|||||||
+13
-49
@@ -25,65 +25,37 @@ jobs:
|
|||||||
# filename sorts BEFORE migrations already applied on the target branch (and
|
# filename sorts BEFORE migrations already applied on the target branch (and
|
||||||
# thus in prod). The Kysely startup migrator rejects that as "corrupted
|
# thus in prod). The Kysely startup migrator rejects that as "corrupted
|
||||||
# migrations" and crash-loops the app on boot (incident #361). This gate fails
|
# migrations" and crash-loops the app on boot (incident #361). This gate fails
|
||||||
# the PR so the migration is renamed to a current timestamp before merge.
|
# the PR so the migration is renamed to a current timestamp before merge. Only
|
||||||
# Runs for pull_request (diff against the base branch) AND for push (#476
|
# runs for pull_request events (needs a base branch to diff against).
|
||||||
# retrospective: a DIRECT push to develop used to bypass this PR-only gate
|
|
||||||
# entirely — now the push is diffed against its `before` SHA; workflow_call
|
|
||||||
# from develop.yml inherits the caller's push event). workflow_dispatch has
|
|
||||||
# nothing to diff against and still skips the job.
|
|
||||||
migration-order:
|
migration-order:
|
||||||
if: github.event_name == 'pull_request' || github.event_name == 'push'
|
if: github.event_name == 'pull_request'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout (full history for the base diff)
|
- name: Checkout (full history for the base-branch diff)
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Added migrations must sort after the newest on the base
|
- name: Added migrations must sort after the newest on the base branch
|
||||||
env:
|
env:
|
||||||
TARGET_BRANCH: ${{ github.base_ref }}
|
TARGET_BRANCH: ${{ github.base_ref }}
|
||||||
BEFORE_SHA: ${{ github.event.before }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
MIG_DIR="apps/server/src/database/migrations"
|
MIG_DIR="apps/server/src/database/migrations"
|
||||||
if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then
|
# checkout above already did fetch-depth:0 (full history). Fetch the base
|
||||||
# checkout above already did fetch-depth:0 (full history). Fetch the base
|
# WITHOUT --depth (a shallow graft would truncate the base history and
|
||||||
# WITHOUT --depth (a shallow graft would truncate the base history and
|
# break the merge-base when the base has moved ahead of the PR merge —
|
||||||
# break the merge-base when the base has moved ahead of the PR merge —
|
# exactly the long-branch-vs-moving-base case this gate guards, #361).
|
||||||
# exactly the long-branch-vs-moving-base case this gate guards, #361).
|
git fetch --no-tags origin "$TARGET_BRANCH"
|
||||||
git fetch --no-tags origin "$TARGET_BRANCH"
|
newest_on_target=$(git ls-tree -r --name-only "origin/${TARGET_BRANCH}" "$MIG_DIR" | sort | tail -1)
|
||||||
BASE="origin/${TARGET_BRANCH}"
|
|
||||||
else
|
|
||||||
# push event: compare against the pre-push tip of the branch.
|
|
||||||
if [ "$BEFORE_SHA" = "0000000000000000000000000000000000000000" ]; then
|
|
||||||
echo "::notice::branch creation push — nothing to compare"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
if ! git cat-file -e "${BEFORE_SHA}^{commit}" 2>/dev/null; then
|
|
||||||
# The before-SHA is not in the clone (a force-push rewrote history).
|
|
||||||
# One recovery attempt — refresh every remote head (cheap: the
|
|
||||||
# checkout is already fetch-depth:0); a fetch failure aborts via
|
|
||||||
# `set -e`, which is fail-closed too.
|
|
||||||
git fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*'
|
|
||||||
fi
|
|
||||||
if ! git cat-file -e "${BEFORE_SHA}^{commit}" 2>/dev/null; then
|
|
||||||
# FAIL-CLOSED: without the before-SHA there is no base to prove the
|
|
||||||
# ordering against, and a gate whose job is to BLOCK must not guess.
|
|
||||||
echo "::error::force-push detected — verify migration order manually, then re-run via workflow_dispatch"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
BASE="$BEFORE_SHA"
|
|
||||||
fi
|
|
||||||
newest_on_target=$(git ls-tree -r --name-only "$BASE" "$MIG_DIR" | sort | tail -1)
|
|
||||||
# NO `|| true`: a diff failure (e.g. an unresolved merge-base) must fail
|
# NO `|| true`: a diff failure (e.g. an unresolved merge-base) must fail
|
||||||
# the job CLOSED — a gate whose job is to BLOCK must never pass on error.
|
# the job CLOSED — a gate whose job is to BLOCK must never pass on error.
|
||||||
# `set -e` above already aborts on a non-zero diff exit.
|
# `set -e` above already aborts on a non-zero diff exit.
|
||||||
added=$(git diff --diff-filter=A --name-only "${BASE}...HEAD" -- "$MIG_DIR")
|
added=$(git diff --diff-filter=A --name-only "origin/${TARGET_BRANCH}...HEAD" -- "$MIG_DIR")
|
||||||
bad=0
|
bad=0
|
||||||
for f in $added; do
|
for f in $added; do
|
||||||
if [[ "$f" < "$newest_on_target" || "$f" == "$newest_on_target" ]]; then
|
if [[ "$f" < "$newest_on_target" || "$f" == "$newest_on_target" ]]; then
|
||||||
echo "::error::Migration $f sorts at or before the newest on the base ($newest_on_target) — rename it with a CURRENT timestamp before merge (do not change its contents). See incident #361."
|
echo "::error::Migration $f sorts at or before the newest on ${TARGET_BRANCH} ($newest_on_target) — rename it with a CURRENT timestamp before merge (do not change its contents). See incident #361."
|
||||||
bad=1
|
bad=1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -159,14 +131,6 @@ jobs:
|
|||||||
- name: Build prosemirror-markdown
|
- name: Build prosemirror-markdown
|
||||||
run: pnpm --filter @docmost/prosemirror-markdown build
|
run: pnpm --filter @docmost/prosemirror-markdown build
|
||||||
|
|
||||||
# @docmost/token-estimate is a shared workspace package the client vitest
|
|
||||||
# suite resolves via its dist build (main: ./dist/index.js); dist/ is
|
|
||||||
# gitignored and `pnpm -r test` does NOT honour nx `dependsOn: ^build`, so
|
|
||||||
# build it before the recursive test run or the client suite fails with
|
|
||||||
# "Failed to resolve import '@docmost/token-estimate'" (#490).
|
|
||||||
- name: Build token-estimate
|
|
||||||
run: pnpm --filter @docmost/token-estimate build
|
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: pnpm -r test
|
run: pnpm -r test
|
||||||
|
|
||||||
|
|||||||
@@ -29,10 +29,6 @@ packages/mcp/build/
|
|||||||
# is a build artifact like build/ — never committed, always fresh.
|
# is a build artifact like build/ — never committed, always fresh.
|
||||||
packages/mcp/src/registry-stamp.generated.ts
|
packages/mcp/src/registry-stamp.generated.ts
|
||||||
|
|
||||||
# token-estimate compiled output (#490; built in CI/Docker via `pnpm build` /
|
|
||||||
# the server `pretest`, never committed, so src/ and prod can never diverge).
|
|
||||||
packages/token-estimate/dist/
|
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -455,7 +455,7 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
|
|||||||
- `core/ai-chat/tools/` — the agent's ~40 read+write tools. Every tool runs under the **calling user's** CASL permissions via a per-user loopback access token (`docmost-client.loader.ts`), so the agent can never exceed what the user could do. Only **reversible** operations are exposed (page history + trash; no permanent delete). Agent edits get an "AI agent" provenance badge in page history (`20260616T130000-agent-provenance` migration).
|
- `core/ai-chat/tools/` — the agent's ~40 read+write tools. Every tool runs under the **calling user's** CASL permissions via a per-user loopback access token (`docmost-client.loader.ts`), so the agent can never exceed what the user could do. Only **reversible** operations are exposed (page history + trash; no permanent delete). Agent edits get an "AI agent" provenance badge in page history (`20260616T130000-agent-provenance` migration).
|
||||||
- `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint.
|
- `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint.
|
||||||
- `core/ai-chat/external-mcp/` — admins can attach external MCP servers (e.g. Tavily) to give the agent web access. **`ssrf-guard.ts` validates outbound MCP URLs against SSRF** — keep that guard in the path when touching external-MCP connection logic.
|
- `core/ai-chat/external-mcp/` — admins can attach external MCP servers (e.g. Tavily) to give the agent web access. **`ssrf-guard.ts` validates outbound MCP URLs against SSRF** — keep that guard in the path when touching external-MCP connection logic.
|
||||||
- `core/ai-chat/ai-chat-run.service.ts` + `ai_chat_runs` — **every agent turn is now a first-class server-side RUN** (`#184`, universalized in `#487`): its lifecycle is tracked in `ai_chat_runs` in **both** modes, and the single-active-run-per-chat concurrency gate is enforced universally (a legacy second tab now gets a clean `409 A_RUN_ALREADY_ACTIVE` instead of a second parallel stream that interleaved history). The per-workspace `settings.ai.autonomousRuns` flag (off by default) **no longer gates whether a turn is a run** — it now controls **only the browser-disconnect semantics**: when ON the run is *detached* (a disconnect leaves it executing server-side; only an explicit `POST /ai-chat/stop` ends it, and a client reconnects/live-follows via `POST /ai-chat/run`); when OFF (legacy) a disconnect ends the turn by stopping its run via the run's stop lever. `#487` also adds a server-side **supersede** CAS ("interrupt and send now") to `POST /ai-chat/stream` (`supersede: { runId }`): it atomically stops the chat's currently-active run and waits for it to settle before the new turn claims the slot, returning `SUPERSEDE_INVALID` / `SUPERSEDE_TARGET_MISMATCH` / `SUPERSEDE_TIMEOUT` on the non-proceed branches. **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.
|
- `core/ai-chat/ai-chat-run.service.ts` + `ai_chat_runs` — **detached/autonomous agent runs** (`#184`), behind the per-workspace `settings.ai.autonomousRuns` flag (off by default). When on, a turn becomes a server-side RUN that survives a browser disconnect; only an explicit `POST /ai-chat/stop` ends it, and a client reconnects/live-follows via `POST /ai-chat/run`. **DEPLOY CONSTRAINT — single-instance only in phase 1:** Stop and the AbortController that backs it are process-local, so a Stop only aborts a run executing on the **same** replica that owns it (cross-instance pub/sub stop is phase 2). Do **not** enable `autonomousRuns` on a horizontally-scaled deployment (multiple replicas behind a load balancer, or Docmost cloud `CLOUD=true`) — run a single instance instead. The server logs a startup WARNING when it detects a multi-instance deployment (`CLOUD=true`) so the constraint is visible. The startup sweep settles any run left dangling by a restart.
|
||||||
|
|
||||||
### Client structure
|
### Client structure
|
||||||
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
||||||
@@ -463,7 +463,6 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
|||||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, `apps/server` (#345), and `apps/client` (#347) — do NOT reintroduce a per-package copy. The client uses the package's `browser` entry (`@docmost/prosemirror-markdown/browser`): markdown paste (`markdown-clipboard.ts`), copy-as-markdown, and AI-chat rendering now all go through the canonical converter, so the hand-written `marked`/`turndown` markdown layer that used to live in `editor-ext` was deleted (#347). The browser entry runs the HTML→DOM stage on the native `DOMParser`, so jsdom stays out of the client bundle. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence. For the converter's property-testing and counterexample→fixture process (P1–P4 invariants, the `PROPERTY_SEED`/`PROPERTY_NUM_RUNS` knobs, and the nightly fuzz workflow), see `packages/prosemirror-markdown/README.md`.
|
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, `apps/server` (#345), and `apps/client` (#347) — do NOT reintroduce a per-package copy. The client uses the package's `browser` entry (`@docmost/prosemirror-markdown/browser`): markdown paste (`markdown-clipboard.ts`), copy-as-markdown, and AI-chat rendering now all go through the canonical converter, so the hand-written `marked`/`turndown` markdown layer that used to live in `editor-ext` was deleted (#347). The browser entry runs the HTML→DOM stage on the native `DOMParser`, so jsdom stays out of the client bundle. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence. For the converter's property-testing and counterexample→fixture process (P1–P4 invariants, the `PROPERTY_SEED`/`PROPERTY_NUM_RUNS` knobs, and the nightly fuzz workflow), see `packages/prosemirror-markdown/README.md`.
|
||||||
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
||||||
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
||||||
- The build also emits `client/dist/version.json` (`{"version": …}`) from a small `vite.config.ts` plugin using the **same** `appVersion` that feeds `define.APP_VERSION`, so the file and the baked-in bundle version are identical by construction. The server reads it at startup (`ws.gateway.ts` via `readClientBuildVersion`/`resolveClientDistPath`) and announces it to each socket on connect (`app-version` event) so a tab left open across a redeploy can guard-reload before hitting a stale chunk (version-coherence). No runtime env / Dockerfile change — the file already ships in `client/dist`; missing/empty file ⇒ feature inert.
|
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
@@ -471,9 +470,7 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
|||||||
- **Errors must never be swallowed or shown as generic messages.** Every caught error MUST (1) be logged in full to the console/logger — error name, message, stack, `cause`, and (for HTTP/provider failures) the status code and response body — and (2) be surfaced to the user with a *specific, human-readable explanation of what actually went wrong*, never a bare generic string like "Something went wrong" / "Could not start recording" / "Transcription failed". Include the real reason (the underlying error/provider message) in the user-facing text. On the server, wrap third-party/provider failures with `describeProviderError` (or equivalent) and rethrow as a meaningful HTTP status + message — never let them collapse into an opaque 500. On the client, `console.error(<context>, err)` the raw error AND show the extracted reason (e.g. `err.response?.data?.message`, or the error `name: message`) in the notification.
|
- **Errors must never be swallowed or shown as generic messages.** Every caught error MUST (1) be logged in full to the console/logger — error name, message, stack, `cause`, and (for HTTP/provider failures) the status code and response body — and (2) be surfaced to the user with a *specific, human-readable explanation of what actually went wrong*, never a bare generic string like "Something went wrong" / "Could not start recording" / "Transcription failed". Include the real reason (the underlying error/provider message) in the user-facing text. On the server, wrap third-party/provider failures with `describeProviderError` (or equivalent) and rethrow as a meaningful HTTP status + message — never let them collapse into an opaque 500. On the client, `console.error(<context>, err)` the raw error AND show the extracted reason (e.g. `err.response?.data?.message`, or the error `name: message`) in the notification.
|
||||||
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
|
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
|
||||||
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
|
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
|
||||||
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`, `ai`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons. The `ai@6.0.134` patch carries TWO independent server fixes, each with its own tripwire test: (1) it disables the SDK's O(n²) cumulative `partialOutput` accumulation when no output strategy is requested (server heap OOM on long agent runs, #184; tripwire: `apps/server/src/integrations/ai/ai-sdk-partial-output.patch.spec.ts`); (2) it fixes `writeToServerResponse`'s drain-hang — the loop awaited only `"drain"` under backpressure, so a mid-write client disconnect parked the pipe forever and leaked the reader/buffers until restart; it now races `"drain"` against `"close"`/`"error"`, cancels the reader on disconnect, and swallows the fire-and-forget read rejection (#486; tripwire: `apps/server/src/integrations/ai/ai-sdk-drain-hang.patch.spec.ts`). Both tripwires assert BOTH installed dist builds carry their patch marker. The patch MUST be re-created via `pnpm patch` when bumping `ai`.
|
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`, `ai`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons. The `ai@6.0.134` patch disables the SDK's O(n²) cumulative `partialOutput` accumulation when no output strategy is requested (server heap OOM on long agent runs, #184; tripwire test: `apps/server/src/integrations/ai/ai-sdk-partial-output.patch.spec.ts`) — it MUST be re-created via `pnpm patch` when bumping `ai`.
|
||||||
- **Upstream tracking (report the analysis upstream, don't just carry it):** both `ai` fixes and the hocuspocus one are candidates for upstreaming so we can eventually drop the local patch — the analysis is already written up in each patch's `PATCH(...)` header comments. File (a) an upstream **issue** on `vercel/ai` for the O(n²) cumulative `partialOutput` accumulation (heap OOM), (b) an upstream **issue** on `vercel/ai` for the `writeToServerResponse` drain-hang, and (c) an upstream **PR** on `@hocuspocus/server` for the connect-vs-unload race (local marker `PATCH(gitmost #401)` in `patches/@hocuspocus__server@3.4.4.patch`). Do NOT edit the patch files to add links — the patch bytes feed `patch_hash` in `pnpm-lock.yaml` (`ai@6.0.134` → `e8c599b3…`), so any content change there desyncs the lockfile pin and breaks `pnpm install`; keep upstream references here instead.
|
|
||||||
- **`ai` version is split across the monorepo and MUST be aligned deliberately, NOT casually:** the server pins `ai@6.0.134` (patched, exact — the `patchedDependencies` key forces that version), while the client declares `ai@6.0.207` (unpatched — the server-side `writeToServerResponse`/`partialOutput` fixes are dead code in the browser, so the mismatch is currently benign but is real drift). Alignment is a **planned, install-gated step**, never a bare `package.json` edit: (1) choose the target version; (2) re-create ALL THREE patch hunks (partialOutput publish-each, the `DefaultStreamTextResult` lazy-`output` wiring, and the drain-hang race) against the target dist via `pnpm patch` — the line offsets shift between versions, so the current patch WILL fail to apply as-is; (3) run a full `pnpm install` so the lockfile + new `patch_hash` regenerate together; (4) confirm both tripwire specs still find their markers. `pnpm install` FAILS HARD on an unapplied patch — that failure is the guardrail, so treat the port as a deliberate plan rather than discovering it as a deploy-time surprise.
|
|
||||||
- **The MCP tool inventory in `SERVER_INSTRUCTIONS` is GENERATED from the registry** (`packages/mcp/src/server-instructions.ts`: `buildToolInventory()` over `SHARED_TOOL_SPECS`) and spliced into the hand-written routing prose (`ROUTING_PROSE`). So adding/renaming/removing a **shared** spec in `packages/mcp/src/tool-specs.ts` auto-updates the `<tool_inventory>` — no manual `SERVER_INSTRUCTIONS` edit needed. Only an **inline** MCP-only tool (those registered via `server.registerTool(...)` in `index.ts`, not through the registry) needs a one-line entry in `INLINE_MCP_INVENTORY`. Enforced by `packages/mcp/test/unit/tool-inventory.test.mjs`, which fails when a registered tool is missing from the generated inventory (there is no `EXCEPTIONS` opt-out anymore — every tool must appear). Update `ROUTING_PROSE` when a tool's *intent guidance* (when-to-use) changes. `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
|
- **The MCP tool inventory in `SERVER_INSTRUCTIONS` is GENERATED from the registry** (`packages/mcp/src/server-instructions.ts`: `buildToolInventory()` over `SHARED_TOOL_SPECS`) and spliced into the hand-written routing prose (`ROUTING_PROSE`). So adding/renaming/removing a **shared** spec in `packages/mcp/src/tool-specs.ts` auto-updates the `<tool_inventory>` — no manual `SERVER_INSTRUCTIONS` edit needed. Only an **inline** MCP-only tool (those registered via `server.registerTool(...)` in `index.ts`, not through the registry) needs a one-line entry in `INLINE_MCP_INVENTORY`. Enforced by `packages/mcp/test/unit/tool-inventory.test.mjs`, which fails when a registered tool is missing from the generated inventory (there is no `EXCEPTIONS` opt-out anymore — every tool must appear). Update `ROUTING_PROSE` when a tool's *intent guidance* (when-to-use) changes. `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
|
||||||
|
|
||||||
## CI / release
|
## CI / release
|
||||||
|
|||||||
-215
@@ -115,46 +115,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
the old ProseMirror-JSON output. Released together with the `#411`/`#412`
|
the old ProseMirror-JSON output. Released together with the `#411`/`#412`
|
||||||
breaking window so external configs break exactly once. (#413)
|
breaking window so external configs break exactly once. (#413)
|
||||||
|
|
||||||
- **The Prometheus `/metrics` listener now binds to `127.0.0.1` (loopback) by
|
|
||||||
default instead of `0.0.0.0` (all interfaces).** This closes an unauthenticated
|
|
||||||
endpoint that was previously reachable on every interface. **DEPLOY MIGRATION —
|
|
||||||
cross-container scraping breaks silently otherwise:** if your scraper runs in a
|
|
||||||
SEPARATE container and reaches the app as `docmost:9464` (the exact topology the
|
|
||||||
old `0.0.0.0` hardcode served), you MUST now set `METRICS_BIND=0.0.0.0` — and,
|
|
||||||
because that re-exposes the endpoint, also set `METRICS_TOKEN=<secret>` and
|
|
||||||
configure the scraper with a matching Bearer token. Without `METRICS_BIND`, the
|
|
||||||
scraper can no longer connect and metrics go dark with no error. See the
|
|
||||||
`METRICS_BIND` / `METRICS_TOKEN` block in `.env.example` for the migration.
|
|
||||||
Same-host (loopback) scrapers need no change. (#486)
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **A drifted comment suggestion can be re-synced instead of failing forever
|
|
||||||
with a 409.** A suggestion whose stored anchor no longer matched the live
|
|
||||||
document used to reject every apply attempt with an unrecoverable conflict; a
|
|
||||||
new resync path re-reads the live anchor so the suggestion applies against the
|
|
||||||
current text, and orphaned anchors (whose marked run was deleted) are
|
|
||||||
reconciled rather than left blocking. (#496)
|
|
||||||
- **Save intentional page versions.** Press `Cmd/Ctrl+S` (or use the page menu)
|
|
||||||
to save a named version of a page. The history panel now distinguishes
|
|
||||||
intentional versions (a "Saved" / "Agent version" badge) from automatic
|
|
||||||
snapshots, dims autosaves, and offers an "Only versions" filter. Automatic
|
|
||||||
snapshots switched from a fixed interval to a trailing idle-flush with a
|
|
||||||
max-wait ceiling, and a boundary snapshot is pinned whenever the editing source
|
|
||||||
changes (e.g. a person's edits followed by the AI agent). (#370)
|
|
||||||
- **Open tabs pick up a new deploy on their own.** After the server is
|
|
||||||
redeployed while a tab is left open for hours, the tab now learns the new
|
|
||||||
build version over the existing WebSocket (announced per-connect, so a natural
|
|
||||||
reconnect delivers it) and shows a "A new version is available" banner with an
|
|
||||||
Update button. To avoid dropping a half-written comment or form, the tab is
|
|
||||||
not reloaded when you merely switch away from it; instead it auto-reloads at
|
|
||||||
the next safe point — the next in-app navigation (or immediately if you click
|
|
||||||
Update) — before it can hit a stale lazy-loaded chunk. At most one automatic
|
|
||||||
reload happens per 5-minute window, shared with the existing chunk-load
|
|
||||||
recovery, so a permanent version skew degrades to the banner rather than a
|
|
||||||
reload loop while a second deploy in the same tab still recovers. When the
|
|
||||||
build carries no version info the feature stays inert. (#481)
|
|
||||||
|
|
||||||
- **Place several images side by side in a row.** A new "Inline (side by
|
- **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
|
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
|
images as a row that wraps onto the next line on narrow screens. The row is
|
||||||
@@ -228,17 +190,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
dangling by a restart. Phase 1 is single-instance-only (cross-instance Stop is
|
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
|
not yet reliable); the server warns at startup on a horizontally-scaled
|
||||||
deployment. (#184)
|
deployment. (#184)
|
||||||
- **Server-side "interrupt and send now" (supersede) for AI chat.** `POST
|
|
||||||
/ai-chat/stream` now accepts a `supersede: { runId }` field: when the user sends
|
|
||||||
a new message while a run is active, the server atomically stops that run and
|
|
||||||
waits for it to settle before the new turn claims the chat's single run slot,
|
|
||||||
instead of the send being rejected as concurrent. The compare-and-set surfaces
|
|
||||||
three codes on its non-proceed branches — `SUPERSEDE_INVALID` (the targeted run
|
|
||||||
is malformed / belongs to another chat), `SUPERSEDE_TARGET_MISMATCH` (a
|
|
||||||
different run is now active; carries the current `activeRunId`), and
|
|
||||||
`SUPERSEDE_TIMEOUT` (the previous run did not stop within the settle window, so
|
|
||||||
nothing was sent and the composer keeps the text). Tunable via
|
|
||||||
`AI_CHAT_SUPERSEDE_TIMEOUT_MS` (default 10s). (#487)
|
|
||||||
- **Out-of-band page transfer via an in-RAM blob sandbox (`stash_page`).** A
|
- **Out-of-band page transfer via an in-RAM blob sandbox (`stash_page`).** A
|
||||||
new MCP tool serializes a whole page (its full ProseMirror JSON, with every
|
new MCP tool serializes a whole page (its full ProseMirror JSON, with every
|
||||||
internal image/file mirrored) into an ephemeral in-RAM blob and returns only
|
internal image/file mirrored) into an ephemeral in-RAM blob and returns only
|
||||||
@@ -319,25 +270,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Every AI-chat turn is now a first-class server-side run, and one run per chat
|
|
||||||
is enforced in both modes.** The run machinery from `#184` was universalized: a
|
|
||||||
turn is tracked in `ai_chat_runs` and gated by the single-active-run-per-chat
|
|
||||||
index regardless of the `settings.ai.autonomousRuns` flag. **Behavior change:**
|
|
||||||
a second tab (or a double-submit) that starts a turn while one is already active
|
|
||||||
on the chat is now rejected up front with `409 A_RUN_ALREADY_ACTIVE` (carrying
|
|
||||||
the `activeRunId`); previously, on the legacy path, it opened a second parallel
|
|
||||||
stream on the same chat that interleaved history. The `autonomousRuns` flag no
|
|
||||||
longer controls whether a turn is a run — it now governs **only** the
|
|
||||||
browser-disconnect semantics (ON = detached/survives a disconnect; OFF = a
|
|
||||||
disconnect stops the run). (#487)
|
|
||||||
- **Vendor `ai` patch: upstream-tracking + version-alignment plan documented.**
|
|
||||||
The two local `ai@6.0.134` fixes (O(n²) `partialOutput` heap-OOM; the
|
|
||||||
`writeToServerResponse` drain-hang) and the hocuspocus connect-vs-unload race
|
|
||||||
now have explicit upstream-reporting and `ai`-version-alignment steps recorded
|
|
||||||
in `AGENTS.md` (client `ai@6.0.207` vs server `ai@6.0.134`-patched drift). The
|
|
||||||
patch bytes are unchanged — they feed the lockfile `patch_hash`, so the
|
|
||||||
alignment is called out as an install-gated plan rather than a bare version
|
|
||||||
bump. No runtime change.
|
|
||||||
- **Client markdown paste/copy and AI-chat rendering now go through the canonical
|
- **Client markdown paste/copy and AI-chat rendering now go through the canonical
|
||||||
converter.** Pasting markdown into the editor, "Copy as markdown", the AI title
|
converter.** Pasting markdown into the editor, "Copy as markdown", the AI title
|
||||||
generator, and the AI-chat markdown renderer all now use
|
generator, and the AI-chat markdown renderer all now use
|
||||||
@@ -370,89 +302,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **MCP write tools no longer report a false failure that provokes a duplicate
|
|
||||||
write.** `drawioCreate` used to throw when the diagram landed as a NESTED block
|
|
||||||
(anchored inside a callout or table cell) because there is no `#<index>` handle
|
|
||||||
for it — but the diagram was already written, so a retry-prone agent re-created
|
|
||||||
it and produced a duplicate. It now returns success with `nodeId: null` plus a
|
|
||||||
warning that explains the write landed and how to re-read it (via
|
|
||||||
`getOutline` / `getPageJson` by `attachmentId`). Separately, when the live
|
|
||||||
collaboration-session cache hits its LRU entry cap, evicting a session whose
|
|
||||||
write is still in flight no longer rejects that write as a hard failure — it is
|
|
||||||
reported as INDETERMINATE ("the update may already have persisted; verify
|
|
||||||
before retry") so the agent re-reads instead of blind-retrying, and a
|
|
||||||
still-connecting session is no longer picked as an idle eviction victim by a
|
|
||||||
parallel acquire. (#494)
|
|
||||||
- **A long AI chat no longer bricks on the model's context window, and each turn
|
|
||||||
stops re-persisting the whole tool-output history.** Tool outputs are now
|
|
||||||
stored ONCE, in `metadata.parts`; the `tool_calls` trace keeps only per-step
|
|
||||||
outcome flags (a v2 trace shape), ending the O(N²) write amplification that
|
|
||||||
re-wrote every prior output on every step (measured on a live Postgres via the
|
|
||||||
`pg_current_wal_lsn()` delta: the trace column shrank ~3200×, the full
|
|
||||||
assistant row ~51%). The persisted record is unchanged in content — the full
|
|
||||||
history still lives in `metadata.parts`. At REPLAY time only, the history sent
|
|
||||||
to the provider is now bounded by a deterministic, prompt-cache-friendly token
|
|
||||||
budget: `floor(0.7 × chatContextWindow)` when a window is configured (no cap —
|
|
||||||
anti-brick protection, not a cost limiter), a flat 100k fallback for installs
|
|
||||||
with no window set (exactly the ones that hit terminal overflow), or off when
|
|
||||||
the window is explicitly `0`. Trimming truncates old tool outputs first, then
|
|
||||||
mechanically collapses the oldest turns, always keeping the recent turns full
|
|
||||||
and the tool-call/result pairing balanced. A provider context-overflow 400 is
|
|
||||||
now classified and used as a reactive signal: the row is stamped so the NEXT
|
|
||||||
turn re-trims aggressively (0.5×), which un-bricks a chat that just 400'd. The
|
|
||||||
client token badge and the server budgeter now share one estimator (new
|
|
||||||
`@docmost/token-estimate` package) so they can never diverge. Deferred-tool
|
|
||||||
activation is also cached in the chat metadata to avoid re-resolving it each
|
|
||||||
turn. (#490)
|
|
||||||
- **Cyrillic (and any non-ASCII) draw.io labels no longer turn into mojibake
|
|
||||||
when a diagram is opened in the draw.io editor.** Agent-created diagrams
|
|
||||||
(`drawioCreate`) and Confluence-imported diagrams stored their model in the
|
|
||||||
SVG's `content=` attribute as base64; the draw.io editor decodes that via
|
|
||||||
Latin-1 `atob` (no UTF-8 step), so every non-ASCII char (e.g. `Старт-бит`,
|
|
||||||
`ё`, `—`) split into garbage and the editor's autosave then persisted the
|
|
||||||
corrupted model, breaking the page preview too. Both write paths
|
|
||||||
(`buildDrawioSvg`, the import service's `createDrawioSvg`) now write `content=`
|
|
||||||
as XML-entity-escaped mxfile XML — draw.io's own native form, decoded by the
|
|
||||||
DOM as UTF-8 — so labels open intact. The decoder reads both the new
|
|
||||||
entity-encoded form and the old base64 form, so existing diagrams still open.
|
|
||||||
*Healing pre-fix diagrams:* only a diagram that still holds its original
|
|
||||||
(correct-UTF-8) base64 — i.e. one not yet opened/autosaved in the draw.io
|
|
||||||
editor — can be repaired in place by `drawioGet` → `drawioUpdate` with the
|
|
||||||
same XML (rewrites the attachment in the new form); no migration script is
|
|
||||||
needed. A diagram that was already opened in the editor persisted the
|
|
||||||
mojibake at rest, so `drawioGet` reads the already-corrupted text and
|
|
||||||
`drawioUpdate` faithfully rewrites it — that text is lost and is not
|
|
||||||
recoverable by a rewrite. (#507)
|
|
||||||
- **A chat with one malformed message part no longer 500s on every turn, and a
|
|
||||||
failed send no longer duplicates the user's message.** Incoming client parts
|
|
||||||
are now whitelisted to `text` (a forged tool-result part can no longer reach
|
|
||||||
the persisted history or the model context), and the turn is converted BEFORE
|
|
||||||
the user row is inserted, so a mid-flight failure cannot leave a duplicate
|
|
||||||
user row that a retry then compounds. A single part that still fails to convert
|
|
||||||
degrades to a `[tool context omitted]` marker on that one row instead of
|
|
||||||
bricking the whole chat. (#489)
|
|
||||||
- **A transport drop to an external MCP server now heals within the same turn.**
|
|
||||||
On an undici transport error, a read-only MCP tool reconnects its server and
|
|
||||||
retries once within the run; a write is never auto-retried (it may already have
|
|
||||||
applied). One flapping server no longer nulls the shared client cache, so other
|
|
||||||
servers' cached clients are untouched. The SSE transport also gets a raised
|
|
||||||
body-timeout so a legitimate >1-min idle between the model's tool calls no
|
|
||||||
longer breaks a long-lived SSE socket (new `AI_MCP_SSE_BODY_TIMEOUT_MS`, default
|
|
||||||
10 min; see `.env.example`). (#489)
|
|
||||||
- **Decisions on comment suggestions now leave a durable audit record.**
|
|
||||||
Applying or dismissing a comment suggestion hard-deletes the (childless)
|
|
||||||
subject comment, so the only surviving trace of who decided what is the audit
|
|
||||||
event — but the audit trail was wired to a Noop service that silently
|
|
||||||
swallowed every event. The trail is now DB-backed, so
|
|
||||||
`comment.suggestion_applied` / `comment.suggestion_dismissed` (and the other
|
|
||||||
comment-decision events) persist to the `audit` table and can be reviewed
|
|
||||||
after the comment is gone. A persistence failure is still swallowed with a
|
|
||||||
warning so it never breaks the originating request. (#496)
|
|
||||||
- **Applying a comment suggestion no longer strips the replaced run's inline
|
|
||||||
formatting.** The suggested text was re-inserted carrying only the comment
|
|
||||||
anchor mark, silently dropping bold/italic/code/link on the affected run; the
|
|
||||||
prevailing formatting of the replaced run is now carried onto the applied
|
|
||||||
text. (#496)
|
|
||||||
- **Markdown round-trips no longer silently drop a line that opens with a block
|
- **Markdown round-trips no longer silently drop a line that opens with a block
|
||||||
trigger.** When a document is exported to Markdown and re-imported (git-sync
|
trigger.** When a document is exported to Markdown and re-imported (git-sync
|
||||||
stabilize, agent writes), a paragraph or continuation line (after a hard break)
|
stabilize, agent writes), a paragraph or continuation line (after a hard break)
|
||||||
@@ -470,39 +319,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
`tee()` branch of the stream result — a ~20-step, ~28k-chunk agent run
|
`tee()` branch of the stream result — a ~20-step, ~28k-chunk agent run
|
||||||
retained ~1.7 GB and OOM'd the 2 GB JS heap. Streaming granularity is
|
retained ~1.7 GB and OOM'd the 2 GB JS heap. Streaming granularity is
|
||||||
unchanged; the patch must be re-created if `ai` is ever bumped. (#184)
|
unchanged; the patch must be re-created if `ai` is ever bumped. (#184)
|
||||||
|
|
||||||
- **The server no longer leaks a hung stream pipe on every mid-run client
|
|
||||||
disconnect.** The same `ai@6.0.134` pnpm patch now also fixes the SDK's
|
|
||||||
`writeToServerResponse`, which awaited only a `"drain"` event under
|
|
||||||
backpressure: when a client disconnected mid-write the socket never drained, so
|
|
||||||
the write loop parked forever, `response.end()` was unreachable, and the stream
|
|
||||||
reader plus buffered chunks were pinned until process restart (every mid-run
|
|
||||||
disconnect in autonomous mode leaked one). The patch races `"drain"` against
|
|
||||||
`"close"`/`"error"`, cancels the reader and ends the response on disconnect, and
|
|
||||||
swallows the fire-and-forget read rejection instead of crashing on an
|
|
||||||
unhandledRejection. (#486)
|
|
||||||
|
|
||||||
- **A failed autonomous agent-run start no longer becomes an unstoppable ghost
|
|
||||||
run.** When `beginRun` failed for a transient reason (e.g. a DB-pool blip),
|
|
||||||
the turn previously continued with NO run row — invisible to `/stop`, not
|
|
||||||
aborted on disconnect, and able to slip a second run past the one-run-per-chat
|
|
||||||
gate, leaving an unstoppable run until restart. The turn now fails fast with an
|
|
||||||
honest `503 A_RUN_BEGIN_FAILED` before the first byte (no orphan state), and the
|
|
||||||
client shows a "temporary — please try again" message instead of a misleading
|
|
||||||
"provider not configured". (#486)
|
|
||||||
|
|
||||||
- **A pathological draw.io graph can no longer wedge the whole server.** The ELK
|
|
||||||
auto-layout (`layout:"elk"`) ran elkjs synchronously on the main event loop, so
|
|
||||||
a graph at the node/edge cap blocked ALL HTTP/SSE/loopback traffic while it
|
|
||||||
churned — and the old `setTimeout` "timeout" could never fire because the same
|
|
||||||
thread was blocked. Layout now runs in a worker thread with the timeout enforced
|
|
||||||
by `worker.terminate()`; the main loop stays responsive. (#486)
|
|
||||||
|
|
||||||
- **The `/health` Redis probe no longer leaks a client on every tick while Redis
|
|
||||||
is down.** It built a new `ioredis` client per probe and disconnected it only on
|
|
||||||
success, so during an outage each health tick added another forever-reconnecting
|
|
||||||
client (an unbounded handle leak). A single long-lived probe client is now
|
|
||||||
reused and closed on shutdown. (#486)
|
|
||||||
- **Internal links in exported Markdown no longer lose their visible text.** A
|
- **Internal links in exported Markdown no longer lose their visible text.** A
|
||||||
link whose target page name had no file extension (e.g. a bare title) was
|
link whose target page name had no file extension (e.g. a bare title) was
|
||||||
collapsed to empty text during export, producing an unclickable, label-less
|
collapsed to empty text during export, producing an unclickable, label-less
|
||||||
@@ -578,37 +394,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
through that exact share (its own share or an ancestor `includeSubPages`
|
through that exact share (its own share or an ancestor `includeSubPages`
|
||||||
share); any other value now returns the generic "not found" instead of
|
share); any other value now returns the generic "not found" instead of
|
||||||
serving the page. (#218)
|
serving the page. (#218)
|
||||||
- **MCP tool-allowlist semantics flipped: an empty `[]` now means deny-all
|
|
||||||
(previously it was coerced to "no restrictions").** For an external MCP server,
|
|
||||||
a stored `tool_allowlist` of `[]` now denies **every** tool of that server
|
|
||||||
(zero tools reach the agent) instead of being treated as an empty/unset filter
|
|
||||||
that allowed all of them. A corrupt or non-array stored value now **fails
|
|
||||||
closed** to deny-all rather than silently allowing everything. The admin form
|
|
||||||
no longer silently widens an existing deny-all server: leaving its tag field
|
|
||||||
empty preserves `[]` (deny-all) on save instead of NULL-ing the column to
|
|
||||||
allow-all, so a routine rename/toggle can no longer grant the agent every tool.
|
|
||||||
"No restrictions" is still expressible — a genuinely unrestricted server stores
|
|
||||||
NULL, and clearing the field on such a server keeps it NULL. Operationally
|
|
||||||
significant: audit any server that was created or left with a literal `[]`, as
|
|
||||||
it now exposes no tools until an explicit allowlist (or NULL) is set. (#476)
|
|
||||||
|
|
||||||
- **Tool and provider error text no longer leaks to anonymous readers in the
|
|
||||||
public-share AI chat.** A failing tool's raw error (which could carry an
|
|
||||||
internal page title or a stack fragment) and a provider error (which bundles the
|
|
||||||
provider `statusCode` and response body — potentially the internal baseUrl or
|
|
||||||
model name) were streamed verbatim to the anonymous reader over SSE. Errors are
|
|
||||||
now sanitized at the source: the share toolset collapses any unclassified tool
|
|
||||||
error to a safe generic string (safe, classified tool messages still pass
|
|
||||||
through for the model's self-correction), and the anonymous stream `onError`
|
|
||||||
maps provider failures to a fixed set of neutral strings — the full detail goes
|
|
||||||
only to the server log. A UI render gate is layered on top. (closes #394)
|
|
||||||
|
|
||||||
- **The Prometheus `/metrics` endpoint can now require Bearer authentication and
|
|
||||||
is loopback-bound by default.** Previously it listened on all interfaces with no
|
|
||||||
auth. Setting `METRICS_TOKEN` requires every scrape to present
|
|
||||||
`Authorization: Bearer <token>` (compared in constant time), and the listener
|
|
||||||
defaults to `127.0.0.1` (see the Breaking Changes entry for the cross-container
|
|
||||||
migration). (#486)
|
|
||||||
|
|
||||||
## [0.94.0] - 2026-06-26
|
## [0.94.0] - 2026-06-26
|
||||||
|
|
||||||
|
|||||||
@@ -59,14 +59,6 @@ COPY --from=builder /app/packages/mcp/data /app/packages/mcp/data
|
|||||||
COPY --from=builder /app/packages/prosemirror-markdown/build /app/packages/prosemirror-markdown/build
|
COPY --from=builder /app/packages/prosemirror-markdown/build /app/packages/prosemirror-markdown/build
|
||||||
COPY --from=builder /app/packages/prosemirror-markdown/package.json /app/packages/prosemirror-markdown/package.json
|
COPY --from=builder /app/packages/prosemirror-markdown/package.json /app/packages/prosemirror-markdown/package.json
|
||||||
|
|
||||||
# apps/server imports @docmost/token-estimate (workspace:*) at runtime
|
|
||||||
# (history-budget.ts, #490). tsc emits only dist/ and dist/ is gitignored, so the
|
|
||||||
# prod install would resolve a broken workspace symlink and the server would die
|
|
||||||
# with ERR_MODULE_NOT_FOUND on the first history-budget call. Ship the built
|
|
||||||
# package + its manifest, mirroring prosemirror-markdown above.
|
|
||||||
COPY --from=builder /app/packages/token-estimate/dist /app/packages/token-estimate/dist
|
|
||||||
COPY --from=builder /app/packages/token-estimate/package.json /app/packages/token-estimate/package.json
|
|
||||||
|
|
||||||
# Copy root package files
|
# Copy root package files
|
||||||
COPY --from=builder /app/package.json /app/package.json
|
COPY --from=builder /app/package.json /app/package.json
|
||||||
COPY --from=builder /app/pnpm*.yaml /app/
|
COPY --from=builder /app/pnpm*.yaml /app/
|
||||||
|
|||||||
@@ -206,137 +206,6 @@ start the new migrations apply on top of your existing schema (`CREATE EXTENSION
|
|||||||
existing pages are indexed on their next edit. pgvector is still required for the migration to
|
existing pages are indexed on their next edit. pgvector is still required for the migration to
|
||||||
apply at all.
|
apply at all.
|
||||||
|
|
||||||
## Local embeddings server
|
|
||||||
|
|
||||||
The AI agent's semantic (RAG) search needs an **embeddings model**. Instead of paying a cloud
|
|
||||||
provider (e.g. OpenAI `text-embedding-3-*`) to embed every page, you can run a small open-weights
|
|
||||||
model yourself with Hugging Face
|
|
||||||
[Text Embeddings Inference](https://github.com/huggingface/text-embeddings-inference) (TEI), which
|
|
||||||
serves an OpenAI-compatible `/v1/embeddings` endpoint. `intfloat/multilingual-e5-small` is a good
|
|
||||||
default: multilingual, 384-dim, and comfortable on CPU (~1–2 GB RAM, 1–2 vCPU). Point Gitmost at it
|
|
||||||
under **Workspace settings → AI → Embeddings**.
|
|
||||||
|
|
||||||
### Option A — local (same Docker network as Gitmost)
|
|
||||||
|
|
||||||
Run TEI as a container on the network Gitmost is already on. The port is never published, so the
|
|
||||||
endpoint stays internal and needs no authentication.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
embeddings:
|
|
||||||
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.9 # pin version; use a cuda-* tag for GPU
|
|
||||||
container_name: embeddings
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- gitmost_net # same network Gitmost is on
|
|
||||||
command:
|
|
||||||
- "--model-id"
|
|
||||||
- "intfloat/multilingual-e5-small"
|
|
||||||
- "--auto-truncate" # clamp over-long inputs instead of returning 413
|
|
||||||
volumes:
|
|
||||||
- tei-models:/data # weights are downloaded once and cached here
|
|
||||||
|
|
||||||
networks:
|
|
||||||
gitmost_net:
|
|
||||||
external: true # the network Gitmost already uses
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
tei-models:
|
|
||||||
```
|
|
||||||
|
|
||||||
Gitmost settings (**Workspace settings → AI → Embeddings**):
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|-------------------|-----------------------------------|
|
|
||||||
| Model | `intfloat/multilingual-e5-small` |
|
|
||||||
| Base URL | `http://embeddings:80/v1/` |
|
|
||||||
| Embedding API key | — (leave empty) |
|
|
||||||
|
|
||||||
> `embeddings` is the container name — Gitmost resolves it over DNS inside the Docker network.
|
|
||||||
> The port is not published, so the endpoint is reachable only by containers on that network and
|
|
||||||
> no authorization is required.
|
|
||||||
|
|
||||||
### Option B — separate host (public via Traefik + Let's Encrypt)
|
|
||||||
|
|
||||||
This assumes the host already runs Traefik with an ACME resolver (the example below uses
|
|
||||||
`letsEncrypt`, the `websecure` entrypoint and a shared `docker_main_net` network). Replace the
|
|
||||||
domain / network / resolver with your own.
|
|
||||||
|
|
||||||
**DNS:** add an A record `embeddings.example.com` → the IP of your Traefik host (same
|
|
||||||
challenge / port 80 as the rest of your sites).
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
embeddings:
|
|
||||||
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.9 # pin version; cuda-* tag for GPU
|
|
||||||
container_name: embeddings
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- docker_main_net # the network Traefik is attached to
|
|
||||||
command:
|
|
||||||
- "--model-id"
|
|
||||||
- "intfloat/multilingual-e5-small"
|
|
||||||
- "--auto-truncate"
|
|
||||||
- "--api-key"
|
|
||||||
- "sk-emb-REPLACE_WITH_YOUR_KEY"
|
|
||||||
volumes:
|
|
||||||
- tei-models:/data
|
|
||||||
labels:
|
|
||||||
traefik.enable: "true"
|
|
||||||
traefik.http.routers.embeddings.rule: "Host(`embeddings.example.com`)"
|
|
||||||
traefik.http.routers.embeddings.entrypoints: "websecure"
|
|
||||||
traefik.http.routers.embeddings.tls: "true"
|
|
||||||
traefik.http.routers.embeddings.tls.certresolver: "letsEncrypt"
|
|
||||||
traefik.http.routers.embeddings.service: "embeddings"
|
|
||||||
traefik.http.services.embeddings.loadbalancer.server.port: "80"
|
|
||||||
# TEI enforces the Bearer key itself; Traefik only rate-limits to protect the CPU
|
|
||||||
traefik.http.routers.embeddings.middlewares: "embeddings-rl"
|
|
||||||
traefik.http.middlewares.embeddings-rl.ratelimit.average: "20"
|
|
||||||
traefik.http.middlewares.embeddings-rl.ratelimit.burst: "40"
|
|
||||||
traefik.http.middlewares.embeddings-rl.ratelimit.period: "1s"
|
|
||||||
|
|
||||||
networks:
|
|
||||||
docker_main_net:
|
|
||||||
external: true
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
tei-models:
|
|
||||||
```
|
|
||||||
|
|
||||||
Gitmost settings (**Workspace settings → AI → Embeddings**):
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|-------------------|---------------------------------------|
|
|
||||||
| Model | `intfloat/multilingual-e5-small` |
|
|
||||||
| Base URL | `https://embeddings.example.com/v1/` |
|
|
||||||
| Embedding API key | your `sk-emb-…` |
|
|
||||||
|
|
||||||
Check it from outside:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s https://embeddings.example.com/v1/embeddings \
|
|
||||||
-H "Authorization: Bearer sk-emb-REPLACE_WITH_YOUR_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"model":"intfloat/multilingual-e5-small","input":"query: hello"}' \
|
|
||||||
| python3 -c 'import sys,json;print("dims:",len(json.load(sys.stdin)["data"][0]["embedding"]))'
|
|
||||||
# -> dims: 384
|
|
||||||
```
|
|
||||||
|
|
||||||
### Embeddings server notes
|
|
||||||
|
|
||||||
- **Vector dimension is 384.** If this Gitmost was previously embedded with a different model
|
|
||||||
(e.g. `text-embedding-3-large` = 3072-dim), the old pgvector rows won't match the new dimension —
|
|
||||||
clear the existing embeddings / re-index before switching. Gitmost only compares vectors of the
|
|
||||||
same dimension, so mixed-dimension rows are silently ignored rather than searched.
|
|
||||||
- **First start downloads the weights** (hundreds of MB) from `huggingface.co` into the
|
|
||||||
`tei-models` volume; every start after that reads from the volume.
|
|
||||||
- **Pin the version.** Pin the image, and optionally the model: add `--revision <commit-sha>` to
|
|
||||||
`command` (the sha is on the model's page on Hugging Face).
|
|
||||||
- **Air-gapped / no egress:** seed the `tei-models` volume ahead of time and add
|
|
||||||
`environment: [HF_HUB_OFFLINE=1]`.
|
|
||||||
- **GPU:** use the cuda tag of the same release (e.g.
|
|
||||||
`ghcr.io/huggingface/text-embeddings-inference:cuda-1.9`) and start the container with `gpus: all`.
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Real-time collaboration
|
- Real-time collaboration
|
||||||
|
|||||||
-131
@@ -193,137 +193,6 @@ dump/restore, существующий каталог данных переис
|
|||||||
> неизменным и бэкапьте вместе с базой данных.
|
> неизменным и бэкапьте вместе с базой данных.
|
||||||
|
|
||||||
|
|
||||||
## Локальный сервер эмбеддингов
|
|
||||||
|
|
||||||
Семантическому (RAG) поиску AI-агента нужна **модель эмбеддингов**. Вместо оплаты облачного
|
|
||||||
провайдера (например, OpenAI `text-embedding-3-*`) за эмбеддинг каждой страницы можно запустить
|
|
||||||
небольшую open-weights модель у себя через Hugging Face
|
|
||||||
[Text Embeddings Inference](https://github.com/huggingface/text-embeddings-inference) (TEI) — он
|
|
||||||
отдаёт OpenAI-совместимый эндпоинт `/v1/embeddings`. Хороший дефолт — `intfloat/multilingual-e5-small`:
|
|
||||||
многоязычная, 384-мерная, комфортно работает на CPU (~1–2 ГБ RAM, 1–2 vCPU). Пропишите её в
|
|
||||||
**Настройки воркспейса → AI → Эмбеддинги**.
|
|
||||||
|
|
||||||
### Вариант A — локально (та же Docker-сеть, что и Gitmost)
|
|
||||||
|
|
||||||
Запустите TEI контейнером в той же сети, где уже работает Gitmost. Порт наружу не публикуется,
|
|
||||||
поэтому эндпоинт остаётся внутренним и не требует авторизации.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
embeddings:
|
|
||||||
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.9 # pin version; use a cuda-* tag for GPU
|
|
||||||
container_name: embeddings
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- gitmost_net # same network Gitmost is on
|
|
||||||
command:
|
|
||||||
- "--model-id"
|
|
||||||
- "intfloat/multilingual-e5-small"
|
|
||||||
- "--auto-truncate" # clamp over-long inputs instead of returning 413
|
|
||||||
volumes:
|
|
||||||
- tei-models:/data # weights are downloaded once and cached here
|
|
||||||
|
|
||||||
networks:
|
|
||||||
gitmost_net:
|
|
||||||
external: true # the network Gitmost already uses
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
tei-models:
|
|
||||||
```
|
|
||||||
|
|
||||||
Настройки Gitmost (**Настройки воркспейса → AI → Эмбеддинги**):
|
|
||||||
|
|
||||||
| Поле | Значение |
|
|
||||||
|-------------------|-----------------------------------|
|
|
||||||
| Model | `intfloat/multilingual-e5-small` |
|
|
||||||
| Base URL | `http://embeddings:80/v1/` |
|
|
||||||
| Embedding API key | — (оставить пустым) |
|
|
||||||
|
|
||||||
> `embeddings` — имя контейнера, Gitmost резолвит его по DNS внутри Docker-сети.
|
|
||||||
> Наружу порт не публикуется, эндпоинт доступен только контейнерам этой сети, поэтому
|
|
||||||
> авторизация не нужна.
|
|
||||||
|
|
||||||
### Вариант B — на отдельном хосте (наружу через Traefik + Let's Encrypt)
|
|
||||||
|
|
||||||
Предполагается, что на хосте уже есть Traefik с ACME-резолвером (в примере ниже — `letsEncrypt`,
|
|
||||||
entrypoint `websecure`, общая сеть `docker_main_net`). Замените домен / сеть / резолвер на свои.
|
|
||||||
|
|
||||||
**DNS:** заведите A-запись `embeddings.example.com` → IP хоста с Traefik (тот же challenge / порт 80,
|
|
||||||
что и у остальных сайтов).
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
embeddings:
|
|
||||||
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.9 # pin version; cuda-* tag for GPU
|
|
||||||
container_name: embeddings
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- docker_main_net # the network Traefik is attached to
|
|
||||||
command:
|
|
||||||
- "--model-id"
|
|
||||||
- "intfloat/multilingual-e5-small"
|
|
||||||
- "--auto-truncate"
|
|
||||||
- "--api-key"
|
|
||||||
- "sk-emb-REPLACE_WITH_YOUR_KEY"
|
|
||||||
volumes:
|
|
||||||
- tei-models:/data
|
|
||||||
labels:
|
|
||||||
traefik.enable: "true"
|
|
||||||
traefik.http.routers.embeddings.rule: "Host(`embeddings.example.com`)"
|
|
||||||
traefik.http.routers.embeddings.entrypoints: "websecure"
|
|
||||||
traefik.http.routers.embeddings.tls: "true"
|
|
||||||
traefik.http.routers.embeddings.tls.certresolver: "letsEncrypt"
|
|
||||||
traefik.http.routers.embeddings.service: "embeddings"
|
|
||||||
traefik.http.services.embeddings.loadbalancer.server.port: "80"
|
|
||||||
# TEI enforces the Bearer key itself; Traefik only rate-limits to protect the CPU
|
|
||||||
traefik.http.routers.embeddings.middlewares: "embeddings-rl"
|
|
||||||
traefik.http.middlewares.embeddings-rl.ratelimit.average: "20"
|
|
||||||
traefik.http.middlewares.embeddings-rl.ratelimit.burst: "40"
|
|
||||||
traefik.http.middlewares.embeddings-rl.ratelimit.period: "1s"
|
|
||||||
|
|
||||||
networks:
|
|
||||||
docker_main_net:
|
|
||||||
external: true
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
tei-models:
|
|
||||||
```
|
|
||||||
|
|
||||||
Настройки Gitmost (**Настройки воркспейса → AI → Эмбеддинги**):
|
|
||||||
|
|
||||||
| Поле | Значение |
|
|
||||||
|-------------------|---------------------------------------|
|
|
||||||
| Model | `intfloat/multilingual-e5-small` |
|
|
||||||
| Base URL | `https://embeddings.example.com/v1/` |
|
|
||||||
| Embedding API key | ваш `sk-emb-…` |
|
|
||||||
|
|
||||||
Проверка снаружи:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s https://embeddings.example.com/v1/embeddings \
|
|
||||||
-H "Authorization: Bearer sk-emb-REPLACE_WITH_YOUR_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"model":"intfloat/multilingual-e5-small","input":"query: hello"}' \
|
|
||||||
| python3 -c 'import sys,json;print("dims:",len(json.load(sys.stdin)["data"][0]["embedding"]))'
|
|
||||||
# -> dims: 384
|
|
||||||
```
|
|
||||||
|
|
||||||
### Заметки про сервер эмбеддингов
|
|
||||||
|
|
||||||
- **Размерность вектора — 384.** Если раньше этот Gitmost эмбеддился другой моделью
|
|
||||||
(например, `text-embedding-3-large` = 3072-dim), старые строки в pgvector не совпадут по
|
|
||||||
размерности — очистите существующие эмбеддинги / переиндексируйте перед переключением. Gitmost
|
|
||||||
сравнивает только вектора одной размерности, поэтому строки другой размерности не участвуют в
|
|
||||||
поиске, а не ломают его.
|
|
||||||
- **Первый старт тянет веса** (сотни МБ) с `huggingface.co` в том `tei-models`; дальше — из тома.
|
|
||||||
- **Пин версии.** Пиньте образ, а при желании и модель: добавьте в `command` `--revision <commit-sha>`
|
|
||||||
(sha берётся со страницы модели на Hugging Face).
|
|
||||||
- **Без egress (air-gapped):** засейте том `tei-models` заранее и добавьте
|
|
||||||
`environment: [HF_HUB_OFFLINE=1]`.
|
|
||||||
- **GPU:** возьмите cuda-тег того же релиза (например,
|
|
||||||
`ghcr.io/huggingface/text-embeddings-inference:cuda-1.9`) и запустите контейнер с `gpus: all`.
|
|
||||||
|
|
||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
- Совместная работа в реальном времени
|
- Совместная работа в реальном времени
|
||||||
|
|||||||
@@ -22,7 +22,6 @@
|
|||||||
"@casl/react": "5.0.1",
|
"@casl/react": "5.0.1",
|
||||||
"@docmost/editor-ext": "workspace:*",
|
"@docmost/editor-ext": "workspace:*",
|
||||||
"@docmost/prosemirror-markdown": "workspace:*",
|
"@docmost/prosemirror-markdown": "workspace:*",
|
||||||
"@docmost/token-estimate": "workspace:*",
|
|
||||||
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
|
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
|
||||||
"@mantine/core": "8.3.18",
|
"@mantine/core": "8.3.18",
|
||||||
"@mantine/dates": "8.3.18",
|
"@mantine/dates": "8.3.18",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"A new version is available": "A new version is available",
|
|
||||||
"Account": "Account",
|
"Account": "Account",
|
||||||
"Active": "Active",
|
"Active": "Active",
|
||||||
"Add": "Add",
|
"Add": "Add",
|
||||||
@@ -1419,14 +1418,5 @@
|
|||||||
"The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied.",
|
"The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied.",
|
||||||
"Dismiss": "Dismiss",
|
"Dismiss": "Dismiss",
|
||||||
"Suggestion dismissed": "Suggestion dismissed",
|
"Suggestion dismissed": "Suggestion dismissed",
|
||||||
"Failed to dismiss suggestion": "Failed to dismiss suggestion",
|
"Failed to dismiss suggestion": "Failed to dismiss suggestion"
|
||||||
"Save version": "Save version",
|
|
||||||
"Ctrl+S": "Ctrl+S",
|
|
||||||
"Version saved": "Version saved",
|
|
||||||
"Already saved as the latest version": "Already saved as the latest version",
|
|
||||||
"Agent version": "Agent version",
|
|
||||||
"Boundary": "Boundary",
|
|
||||||
"Autosave": "Autosave",
|
|
||||||
"Only versions": "Only versions",
|
|
||||||
"No saved versions yet.": "No saved versions yet."
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"A new version is available": "Доступна новая версия",
|
|
||||||
"Account": "Аккаунт",
|
"Account": "Аккаунт",
|
||||||
"Active": "Активный",
|
"Active": "Активный",
|
||||||
"Add": "Добавить",
|
"Add": "Добавить",
|
||||||
@@ -257,9 +256,6 @@
|
|||||||
"Invite link": "Ссылка для приглашения",
|
"Invite link": "Ссылка для приглашения",
|
||||||
"Copy": "Копировать",
|
"Copy": "Копировать",
|
||||||
"Copy to space": "Копировать в пространство",
|
"Copy to space": "Копировать в пространство",
|
||||||
"Copy chat": "Копировать чат",
|
|
||||||
"Dock to sidebar": "Закрепить в боковой панели",
|
|
||||||
"Undock": "Открепить",
|
|
||||||
"Copied": "Скопировано",
|
"Copied": "Скопировано",
|
||||||
"Failed to export chat": "Не удалось экспортировать чат",
|
"Failed to export chat": "Не удалось экспортировать чат",
|
||||||
"Duplicate": "Дублировать",
|
"Duplicate": "Дублировать",
|
||||||
@@ -289,9 +285,6 @@
|
|||||||
"Alt text": "Альтернативный текст",
|
"Alt text": "Альтернативный текст",
|
||||||
"Describe this for accessibility.": "Опишите это для специальных возможностей.",
|
"Describe this for accessibility.": "Опишите это для специальных возможностей.",
|
||||||
"Add a description": "Добавить описание",
|
"Add a description": "Добавить описание",
|
||||||
"Caption": "Подпись",
|
|
||||||
"Add a caption": "Добавить подпись",
|
|
||||||
"Shown below the image.": "Отображается под изображением.",
|
|
||||||
"Justify": "По ширине",
|
"Justify": "По ширине",
|
||||||
"Merge cells": "Объединить ячейки",
|
"Merge cells": "Объединить ячейки",
|
||||||
"Split cell": "Разделить ячейку",
|
"Split cell": "Разделить ячейку",
|
||||||
@@ -395,6 +388,22 @@
|
|||||||
"Quote": "Цитата",
|
"Quote": "Цитата",
|
||||||
"Image": "Изображение",
|
"Image": "Изображение",
|
||||||
"Audio": "Аудио",
|
"Audio": "Аудио",
|
||||||
|
"Transcribe": "Транскрибировать",
|
||||||
|
"Transcribing…": "Транскрибация…",
|
||||||
|
"No speech detected": "Речь не распознана",
|
||||||
|
"Transcription failed": "Не удалось распознать речь",
|
||||||
|
"Voice dictation is not configured": "Голосовой ввод не настроен",
|
||||||
|
"Start dictation": "Начать диктовку",
|
||||||
|
"Stop recording": "Остановить запись",
|
||||||
|
"Microphone access denied": "Доступ к микрофону запрещён",
|
||||||
|
"No microphone found": "Микрофон не найден",
|
||||||
|
"Microphone is unavailable or already in use": "Микрофон недоступен или уже используется",
|
||||||
|
"Could not start recording": "Не удалось начать запись",
|
||||||
|
"Audio recording is not available in this browser/context": "Запись аудио недоступна в этом браузере/контексте",
|
||||||
|
"Dictation": "Диктовка",
|
||||||
|
"Dictation becomes available once the page finishes connecting": "Диктовка станет доступна после подключения к документу",
|
||||||
|
"No connection to the collaboration server — dictation unavailable": "Нет связи с сервером совместного редактирования — диктовка недоступна",
|
||||||
|
"This page is read-only": "Страница открыта только для чтения",
|
||||||
"Embed PDF": "Встроить PDF",
|
"Embed PDF": "Встроить PDF",
|
||||||
"Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.",
|
"Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.",
|
||||||
"Embed as PDF": "Встроить как PDF",
|
"Embed as PDF": "Встроить как PDF",
|
||||||
@@ -410,6 +419,9 @@
|
|||||||
"Footnote {{number}}": "Сноска {{number}}",
|
"Footnote {{number}}": "Сноска {{number}}",
|
||||||
"Go to footnote": "Перейти к сноске",
|
"Go to footnote": "Перейти к сноске",
|
||||||
"Back to reference": "Вернуться к ссылке",
|
"Back to reference": "Вернуться к ссылке",
|
||||||
|
"Back to references": "Вернуться к ссылкам",
|
||||||
|
"Back to reference {{label}}": "Вернуться к ссылке {{label}}",
|
||||||
|
"Empty footnote": "Пустая сноска",
|
||||||
"Math inline": "Строчная формула",
|
"Math inline": "Строчная формула",
|
||||||
"Insert inline math equation.": "Вставить математическое выражение в строку.",
|
"Insert inline math equation.": "Вставить математическое выражение в строку.",
|
||||||
"Math block": "Блок формулы",
|
"Math block": "Блок формулы",
|
||||||
@@ -435,9 +447,6 @@
|
|||||||
"{{count}} command available_other": "Доступно {{count}} команд",
|
"{{count}} command available_other": "Доступно {{count}} команд",
|
||||||
"{{count}} result available_one": "Доступен 1 результат",
|
"{{count}} result available_one": "Доступен 1 результат",
|
||||||
"{{count}} result available_other": "Доступно {{count}} результатов",
|
"{{count}} result available_other": "Доступно {{count}} результатов",
|
||||||
"{{count}} result found_one": "Найден {{count}} результат",
|
|
||||||
"{{count}} result found_few": "Найдено {{count}} результата",
|
|
||||||
"{{count}} result found_other": "Найдено {{count}} результатов",
|
|
||||||
"Equal columns": "Равные столбцы",
|
"Equal columns": "Равные столбцы",
|
||||||
"Left sidebar": "Левая боковая панель",
|
"Left sidebar": "Левая боковая панель",
|
||||||
"Right sidebar": "Правая боковая панель",
|
"Right sidebar": "Правая боковая панель",
|
||||||
@@ -447,7 +456,6 @@
|
|||||||
"Names do not match": "Названия не совпадают",
|
"Names do not match": "Названия не совпадают",
|
||||||
"Today, {{time}}": "Сегодня, {{time}}",
|
"Today, {{time}}": "Сегодня, {{time}}",
|
||||||
"Yesterday, {{time}}": "Вчера, {{time}}",
|
"Yesterday, {{time}}": "Вчера, {{time}}",
|
||||||
"now": "сейчас",
|
|
||||||
"Space created successfully": "Пространство успешно создано",
|
"Space created successfully": "Пространство успешно создано",
|
||||||
"Space updated successfully": "Пространство успешно обновлено",
|
"Space updated successfully": "Пространство успешно обновлено",
|
||||||
"Space deleted successfully": "Пространство успешно удалено",
|
"Space deleted successfully": "Пространство успешно удалено",
|
||||||
@@ -551,7 +559,6 @@
|
|||||||
"Add 2FA method": "Добавить метод 2FA",
|
"Add 2FA method": "Добавить метод 2FA",
|
||||||
"Backup codes": "Резервные коды",
|
"Backup codes": "Резервные коды",
|
||||||
"Disable": "Отключить",
|
"Disable": "Отключить",
|
||||||
"disabled": "отключено",
|
|
||||||
"Invalid verification code": "Недействительный код подтверждения",
|
"Invalid verification code": "Недействительный код подтверждения",
|
||||||
"New backup codes have been generated": "Новые резервные коды сгенерированы",
|
"New backup codes have been generated": "Новые резервные коды сгенерированы",
|
||||||
"Failed to regenerate backup codes": "Не удалось заново сгенерировать резервные коды",
|
"Failed to regenerate backup codes": "Не удалось заново сгенерировать резервные коды",
|
||||||
@@ -695,6 +702,62 @@
|
|||||||
"AI search": "Поиск ИИ",
|
"AI search": "Поиск ИИ",
|
||||||
"AI Answer": "Ответ ИИ",
|
"AI Answer": "Ответ ИИ",
|
||||||
"Ask AI": "Спросить ИИ",
|
"Ask AI": "Спросить ИИ",
|
||||||
|
"AI agent": "AI-агент",
|
||||||
|
"Take a look at the current document": "Посмотри текущий документ",
|
||||||
|
"Start automatically": "Запускать автоматически",
|
||||||
|
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "Когда включено, выбор этой роли отправляет стартовое сообщение и начинает чат. Когда выключено, роль выбирается, а первое сообщение вы вводите сами.",
|
||||||
|
"Launch message": "Стартовое сообщение",
|
||||||
|
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Отправляется автоматически при выборе этой роли. Оставьте пустым, чтобы использовать текст по умолчанию. Игнорируется, когда «Запускать автоматически» выключено.",
|
||||||
|
"AI agent is typing…": "AI-агент печатает…",
|
||||||
|
"{{name}} is typing…": "{{name}} печатает…",
|
||||||
|
"Thinking…": "Думаю…",
|
||||||
|
"Thinking… · {{count}} tokens": "Думаю… · {{count}} токенов",
|
||||||
|
"Thinking… · {{count}} tokens_one": "Думаю… · {{count}} токен",
|
||||||
|
"Thinking… · {{count}} tokens_few": "Думаю… · {{count}} токена",
|
||||||
|
"Thinking… · {{count}} tokens_many": "Думаю… · {{count}} токенов",
|
||||||
|
"Thinking · {{count}} tokens": "Размышления · {{count}} токенов",
|
||||||
|
"Thinking · {{count}} tokens_one": "Размышления · {{count}} токен",
|
||||||
|
"Thinking · {{count}} tokens_few": "Размышления · {{count}} токена",
|
||||||
|
"Thinking · {{count}} tokens_many": "Размышления · {{count}} токенов",
|
||||||
|
"Agent role": "Роль агента",
|
||||||
|
"AI chat": "AI-чат",
|
||||||
|
"AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.",
|
||||||
|
"Ask a question about this documentation.": "Задайте вопрос об этой документации.",
|
||||||
|
"Ask a question…": "Задайте вопрос…",
|
||||||
|
"Ask the AI agent anything about your workspace.": "Спросите AI-агента о чём угодно по вашему рабочему пространству.",
|
||||||
|
"Ask the AI agent…": "Спросите AI-агента…",
|
||||||
|
"Copy chat": "Копировать чат",
|
||||||
|
"Dock to sidebar": "Закрепить в боковой панели",
|
||||||
|
"Undock": "Открепить",
|
||||||
|
"Created successfully": "Успешно создано",
|
||||||
|
"Context size / model limit": "Размер контекста / лимит модели",
|
||||||
|
"Context window (tokens)": "Окно контекста (токены)",
|
||||||
|
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
|
||||||
|
"Delete this chat?": "Удалить этот чат?",
|
||||||
|
"Deleted successfully": "Успешно удалено",
|
||||||
|
"AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}",
|
||||||
|
"AI agent {{name}}": "AI-агент {{name}}",
|
||||||
|
"Failed to delete chat": "Не удалось удалить чат",
|
||||||
|
"Failed to rename chat": "Не удалось переименовать чат",
|
||||||
|
"Failed": "Ошибка",
|
||||||
|
"OK · {{n}}": "OK · {{n}}",
|
||||||
|
"Test": "Тест",
|
||||||
|
"No tools available": "Инструменты недоступны",
|
||||||
|
"Available tools": "Доступные инструменты",
|
||||||
|
"Minimize": "Свернуть",
|
||||||
|
"No chats yet.": "Чатов пока нет.",
|
||||||
|
"Send": "Отправить",
|
||||||
|
"Send when the agent finishes": "Отправить, когда агент закончит",
|
||||||
|
"Queue message": "Поставить в очередь",
|
||||||
|
"Remove queued message": "Убрать из очереди",
|
||||||
|
"Send now": "Отправить сейчас",
|
||||||
|
"Interrupt and send now": "Прервать и отправить сейчас",
|
||||||
|
"Something went wrong": "Что-то пошло не так",
|
||||||
|
"Stop": "Стоп",
|
||||||
|
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",
|
||||||
|
"The AI provider is not configured. Ask an administrator to set it up.": "AI-провайдер не настроен. Попросите администратора настроить его.",
|
||||||
|
"Universal assistant": "Универсальный ассистент",
|
||||||
|
"You": "Вы",
|
||||||
"AI is thinking...": "ИИ обрабатывает запрос...",
|
"AI is thinking...": "ИИ обрабатывает запрос...",
|
||||||
"Thinking": "Думаю",
|
"Thinking": "Думаю",
|
||||||
"Ask a question...": "Задайте вопрос...",
|
"Ask a question...": "Задайте вопрос...",
|
||||||
@@ -721,40 +784,8 @@
|
|||||||
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Управляйте API-ключами для всех пользователей в рабочем пространстве. Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Управляйте API-ключами для всех пользователей в рабочем пространстве. Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
||||||
"View the <anchor>API documentation</anchor> for usage details.": "Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
"View the <anchor>API documentation</anchor> for usage details.": "Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
||||||
"View the <anchor>MCP documentation</anchor>.": "Смотрите <anchor>документацию по MCP</anchor>.",
|
"View the <anchor>MCP documentation</anchor>.": "Смотрите <anchor>документацию по MCP</anchor>.",
|
||||||
"AI / Models": "ИИ / Модели",
|
"Instructions": "Инструкции",
|
||||||
"AI / External tools (MCP)": "ИИ / Внешние инструменты (MCP)",
|
|
||||||
"Add server": "Добавить сервер",
|
|
||||||
"Edit server": "Изменить сервер",
|
|
||||||
"Delete server": "Удалить сервер",
|
|
||||||
"Are you sure you want to delete this MCP server?": "Вы уверены, что хотите удалить этот MCP-сервер?",
|
|
||||||
"No external servers configured": "Внешние серверы не настроены",
|
|
||||||
"Server name": "Имя сервера",
|
|
||||||
"Transport": "Транспорт",
|
|
||||||
"URL": "URL",
|
|
||||||
"Authorization header": "Заголовок авторизации",
|
|
||||||
"Tool allowlist": "Список разрешённых инструментов",
|
|
||||||
"Optional. Leave empty to allow all tools the server exposes.": "Необязательно. Оставьте пустым, чтобы разрешить все инструменты, которые предоставляет сервер.",
|
|
||||||
"Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".": "Необязательное указание агенту, как и когда использовать инструменты этого сервера. Добавляется в системный промпт. Инструменты сервера именуются с префиксом «<имя сервера>_*».",
|
"Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".": "Необязательное указание агенту, как и когда использовать инструменты этого сервера. Добавляется в системный промпт. Инструменты сервера именуются с префиксом «<имя сервера>_*».",
|
||||||
"Test": "Тест",
|
|
||||||
"Available tools": "Доступные инструменты",
|
|
||||||
"No tools available": "Инструменты недоступны",
|
|
||||||
"Failed": "Ошибка",
|
|
||||||
"OK · {{n}}": "OK · {{n}}",
|
|
||||||
"Created successfully": "Успешно создано",
|
|
||||||
"Deleted successfully": "Успешно удалено",
|
|
||||||
"Clear": "Очистить",
|
|
||||||
"Provider": "Провайдер",
|
|
||||||
"•••• set": "•••• задан",
|
|
||||||
"Clear key": "Очистить ключ",
|
|
||||||
"Base URL": "Базовый URL",
|
|
||||||
"Chat model": "Модель чата",
|
|
||||||
"Embedding model": "Модель эмбеддингов",
|
|
||||||
"System message": "Системное сообщение",
|
|
||||||
"A built-in safety framework is always appended.": "Встроенный набор правил безопасности всегда добавляется автоматически.",
|
|
||||||
"Test connection": "Проверить соединение",
|
|
||||||
"Connection successful": "Соединение установлено",
|
|
||||||
"Connection failed": "Не удалось установить соединение",
|
|
||||||
"Only workspace admins can manage AI provider settings.": "Управлять настройками провайдера ИИ могут только администраторы рабочего пространства.",
|
|
||||||
"Sources": "Источники",
|
"Sources": "Источники",
|
||||||
"AI Answers not available for attachments": "Ответы ИИ недоступны для вложений",
|
"AI Answers not available for attachments": "Ответы ИИ недоступны для вложений",
|
||||||
"No answer available": "Ответ недоступен",
|
"No answer available": "Ответ недоступен",
|
||||||
@@ -982,7 +1013,6 @@
|
|||||||
"Try again": "Попробовать снова",
|
"Try again": "Попробовать снова",
|
||||||
"Untitled chat": "Чат без названия",
|
"Untitled chat": "Чат без названия",
|
||||||
"No document": "Без документа",
|
"No document": "Без документа",
|
||||||
"You": "Вы",
|
|
||||||
"What can I help you with?": "Чем я могу вам помочь?",
|
"What can I help you with?": "Чем я могу вам помочь?",
|
||||||
"Are you sure you want to revoke this {{credential}}": "Вы уверены, что хотите отозвать этот {{credential}}",
|
"Are you sure you want to revoke this {{credential}}": "Вы уверены, что хотите отозвать этот {{credential}}",
|
||||||
"Automatically provision users and groups from your identity provider via SCIM.": "Автоматически предоставляйте доступ пользователям и группам из вашего провайдера удостоверений через SCIM.",
|
"Automatically provision users and groups from your identity provider via SCIM.": "Автоматически предоставляйте доступ пользователям и группам из вашего провайдера удостоверений через SCIM.",
|
||||||
@@ -1011,9 +1041,6 @@
|
|||||||
"Page menu": "Меню страницы",
|
"Page menu": "Меню страницы",
|
||||||
"Expand": "Развернуть",
|
"Expand": "Развернуть",
|
||||||
"Collapse": "Свернуть",
|
"Collapse": "Свернуть",
|
||||||
"Expand all": "Развернуть все",
|
|
||||||
"Collapse all": "Свернуть все",
|
|
||||||
"Couldn't expand the tree: {{reason}}": "Не удалось развернуть дерево: {{reason}}",
|
|
||||||
"Comment menu": "Меню комментария",
|
"Comment menu": "Меню комментария",
|
||||||
"Group menu": "Меню группы",
|
"Group menu": "Меню группы",
|
||||||
"Show hidden breadcrumbs": "Показать скрытые хлебные крошки",
|
"Show hidden breadcrumbs": "Показать скрытые хлебные крошки",
|
||||||
@@ -1050,7 +1077,7 @@
|
|||||||
"Search pages and spaces...": "Поиск страниц и пространств...",
|
"Search pages and spaces...": "Поиск страниц и пространств...",
|
||||||
"No results found": "Результаты не найдены",
|
"No results found": "Результаты не найдены",
|
||||||
"You don't have permission to create pages here": "У вас нет прав на создание страниц здесь",
|
"You don't have permission to create pages here": "У вас нет прав на создание страниц здесь",
|
||||||
"Chat menu for {{title}}": "Меню чата для {{title}}",
|
"Chat menu": "Меню чата",
|
||||||
"API key menu": "Меню API-ключа",
|
"API key menu": "Меню API-ключа",
|
||||||
"Jump to comment selection": "Перейти к выбору комментария",
|
"Jump to comment selection": "Перейти к выбору комментария",
|
||||||
"Slash commands": "Команды со слешем",
|
"Slash commands": "Команды со слешем",
|
||||||
@@ -1104,9 +1131,6 @@
|
|||||||
"Undo": "Отменить",
|
"Undo": "Отменить",
|
||||||
"Redo": "Повторить",
|
"Redo": "Повторить",
|
||||||
"Backlinks": "Обратные ссылки",
|
"Backlinks": "Обратные ссылки",
|
||||||
"Back to references": "Вернуться к ссылкам",
|
|
||||||
"Back to reference {{label}}": "Вернуться к ссылке {{label}}",
|
|
||||||
"Empty footnote": "Пустая сноска",
|
|
||||||
"Last updated by": "Последний изменивший",
|
"Last updated by": "Последний изменивший",
|
||||||
"Last updated": "Последнее обновление",
|
"Last updated": "Последнее обновление",
|
||||||
"Stats": "Статистика",
|
"Stats": "Статистика",
|
||||||
@@ -1140,7 +1164,6 @@
|
|||||||
"Page title": "Заголовок страницы",
|
"Page title": "Заголовок страницы",
|
||||||
"Page content": "Содержимое страницы",
|
"Page content": "Содержимое страницы",
|
||||||
"Member actions": "Действия с участником",
|
"Member actions": "Действия с участником",
|
||||||
"Member actions for {{name}}": "Действия с участником {{name}}",
|
|
||||||
"Toggle password visibility": "Переключить видимость пароля",
|
"Toggle password visibility": "Переключить видимость пароля",
|
||||||
"Send comment": "Отправить комментарий",
|
"Send comment": "Отправить комментарий",
|
||||||
"Token actions": "Действия с токеном",
|
"Token actions": "Действия с токеном",
|
||||||
@@ -1160,187 +1183,11 @@
|
|||||||
"Removed from favorites": "Удалено из избранного",
|
"Removed from favorites": "Удалено из избранного",
|
||||||
"Added {{name}} to favorites": "{{name}} добавлено в избранное",
|
"Added {{name}} to favorites": "{{name}} добавлено в избранное",
|
||||||
"Removed {{name}} from favorites": "{{name}} удалено из избранного",
|
"Removed {{name}} from favorites": "{{name}} удалено из избранного",
|
||||||
"Label added": "Метка добавлена",
|
|
||||||
"Label removed": "Метка удалена",
|
|
||||||
"Image updated": "Изображение обновлено",
|
|
||||||
"Unsupported image type": "Неподдерживаемый тип изображения",
|
|
||||||
"Member deactivated": "Участник деактивирован",
|
|
||||||
"Member activated": "Участник активирован",
|
|
||||||
"Name is required": "Укажите имя",
|
|
||||||
"Name must be 40 characters or fewer": "Имя должно содержать не более 40 символов",
|
|
||||||
"Group name must be at least 2 characters": "Название группы должно содержать не менее 2 символов",
|
|
||||||
"Group name must be 100 characters or fewer": "Название группы должно содержать не более 100 символов",
|
|
||||||
"Description must be 500 characters or fewer": "Описание должно содержать не более 500 символов",
|
|
||||||
"Invalid invitation link": "Недействительная ссылка-приглашение",
|
|
||||||
"Page menu for {{name}}": "Меню страницы для {{name}}",
|
"Page menu for {{name}}": "Меню страницы для {{name}}",
|
||||||
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
|
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
|
||||||
"AI chat": "AI-чат",
|
|
||||||
"Ask a question about this documentation.": "Задайте вопрос об этой документации.",
|
|
||||||
"Ask a question…": "Задайте вопрос…",
|
|
||||||
"Thinking…": "Думаю…",
|
|
||||||
"Thinking… · {{count}} tokens": "Думаю… · {{count}} токенов",
|
|
||||||
"Thinking… · {{count}} tokens_one": "Думаю… · {{count}} токен",
|
|
||||||
"Thinking… · {{count}} tokens_few": "Думаю… · {{count}} токена",
|
|
||||||
"Thinking… · {{count}} tokens_many": "Думаю… · {{count}} токенов",
|
|
||||||
"Thinking… · {{count}} tokens_other": "Думаю… · {{count}} токенов",
|
|
||||||
"Thinking · {{count}} tokens": "Размышления · {{count}} токенов",
|
|
||||||
"Thinking · {{count}} tokens_one": "Размышления · {{count}} токен",
|
|
||||||
"Thinking · {{count}} tokens_few": "Размышления · {{count}} токена",
|
|
||||||
"Thinking · {{count}} tokens_many": "Размышления · {{count}} токенов",
|
|
||||||
"Thinking · {{count}} tokens_other": "Размышления · {{count}} токенов",
|
|
||||||
"The assistant is unavailable right now. Please try again.": "Ассистент сейчас недоступен. Попробуйте ещё раз.",
|
|
||||||
"Public share assistant": "Ассистент публичного доступа",
|
|
||||||
"Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.": "Позвольте анонимным посетителям публичных ссылок обращаться к ИИ-ассистенту в рамках страниц этой публикации. Токены оплачиваете вы.",
|
|
||||||
"Public assistant model": "Модель публичного ассистента",
|
|
||||||
"Defaults to the chat model": "По умолчанию используется модель чата",
|
|
||||||
"Optional cheaper model id for the public assistant. Empty uses the chat model above.": "Необязательный более дешёвый идентификатор модели для публичного ассистента. Если пусто, используется модель чата выше.",
|
|
||||||
"Assistant identity": "Личность ассистента",
|
|
||||||
"Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.": "Выберите роль агента, чью личность примет публичный ассистент. Правила безопасности всегда остаются в силе.",
|
|
||||||
"Built-in assistant persona": "Встроенная личность ассистента",
|
|
||||||
"Minimize": "Свернуть",
|
|
||||||
"Context size / model limit": "Размер контекста / лимит модели",
|
|
||||||
"Context window (tokens)": "Окно контекста (токены)",
|
|
||||||
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
|
|
||||||
"AI agent": "AI-агент",
|
|
||||||
"Take a look at the current document": "Посмотри текущий документ",
|
|
||||||
"AI agent is typing…": "AI-агент печатает…",
|
|
||||||
"{{name}} is typing…": "{{name}} печатает…",
|
|
||||||
"Send": "Отправить",
|
|
||||||
"Send when the agent finishes": "Отправить, когда агент закончит",
|
|
||||||
"Queue message": "Поставить в очередь",
|
|
||||||
"Remove queued message": "Убрать из очереди",
|
|
||||||
"Send now": "Отправить сейчас",
|
|
||||||
"Interrupt and send now": "Прервать и отправить сейчас",
|
|
||||||
"Stop": "Стоп",
|
|
||||||
"Response stopped.": "Ответ остановлен.",
|
|
||||||
"Connection lost — the answer was interrupted.": "Соединение потеряно — ответ был прерван.",
|
|
||||||
"Response stopped (manually or the connection dropped).": "Ответ остановлен (вручную или из-за разрыва соединения).",
|
|
||||||
"Chat menu": "Меню чата",
|
|
||||||
"No chats yet.": "Чатов пока нет.",
|
|
||||||
"Delete this chat?": "Удалить этот чат?",
|
|
||||||
"Ask the AI agent…": "Спросите AI-агента…",
|
|
||||||
"Ask the AI agent anything about your workspace.": "Спросите AI-агента о чём угодно по вашему рабочему пространству.",
|
|
||||||
"Failed to rename chat": "Не удалось переименовать чат",
|
|
||||||
"Failed to delete chat": "Не удалось удалить чат",
|
|
||||||
"Something went wrong": "Что-то пошло не так",
|
|
||||||
"AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.",
|
|
||||||
"The AI provider is not configured. Ask an administrator to set it up.": "AI-провайдер не настроен. Попросите администратора настроить его.",
|
|
||||||
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",
|
|
||||||
"Searched pages": "Поиск по страницам",
|
|
||||||
"Read page": "Прочитана страница",
|
|
||||||
"Created page": "Создана страница",
|
|
||||||
"Updated page": "Обновлена страница",
|
|
||||||
"Renamed page": "Переименована страница",
|
|
||||||
"Moved page": "Перемещена страница",
|
|
||||||
"Deleted page (to trash)": "Удалена страница (в корзину)",
|
|
||||||
"Commented": "Добавлен комментарий",
|
|
||||||
"Resolved comment": "Комментарий решён",
|
|
||||||
"Ran tool {{name}}": "Выполнен инструмент {{name}}",
|
|
||||||
"AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}",
|
|
||||||
"AI agent {{name}}": "AI-агент {{name}}",
|
|
||||||
"Endpoints": "Эндпоинты",
|
|
||||||
"where we fetch models": "откуда мы получаем модели",
|
|
||||||
"All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.": "Все эндпоинты совместимы с OpenAI. Укажите в базовом URL адрес OpenAI, OpenRouter, локального Ollama или любого self-hosted сервера.",
|
|
||||||
"Chat / LLM": "Чат / LLM",
|
|
||||||
"root": "корневой",
|
|
||||||
"Semantic search": "Семантический поиск",
|
|
||||||
"Voice / STT": "Голос / STT",
|
|
||||||
"Voice dictation": "Голосовой ввод",
|
|
||||||
"Streaming dictation": "Потоковый голосовой ввод",
|
|
||||||
"Transcribe as you speak, cutting on pauses": "Транскрибирование по мере речи, с разбивкой на паузах",
|
|
||||||
"Voice dictation is not available yet.": "Голосовой ввод пока недоступен.",
|
|
||||||
"Test endpoint": "Проверить эндпоинт",
|
|
||||||
"Save and test": "Сохранить и проверить",
|
|
||||||
"Save endpoints": "Сохранить эндпоинты",
|
|
||||||
"Configured and enabled": "Настроено и включено",
|
|
||||||
"Configured but disabled": "Настроено, но отключено",
|
|
||||||
"Enabled but not configured": "Включено, но не настроено",
|
|
||||||
"Not configured": "Не настроено",
|
|
||||||
"External tools": "Внешние инструменты",
|
|
||||||
"Gitmost as MCP client": "Gitmost как MCP-клиент",
|
|
||||||
"Servers the agent calls out to.": "Серверы, к которым обращается агент.",
|
|
||||||
"MCP server": "MCP-сервер",
|
|
||||||
"expose the workspace": "открыть доступ к рабочему пространству",
|
|
||||||
"Enable MCP server": "Включить MCP-сервер",
|
|
||||||
"Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.": "Открывает рабочее пространство как MCP-сервер по адресу /mcp — это предоставляет возможность, а не потребляет модель.",
|
|
||||||
"Resolves to {{url}}": "Разрешается в {{url}}",
|
|
||||||
"Model": "Модель",
|
|
||||||
"Done": "Готово",
|
|
||||||
"shared prompt · safety framework appended automatically": "общий промпт · правила безопасности добавляются автоматически",
|
|
||||||
"/v1/chat/completions · root endpoint — Embeddings and Voice inherit its URL and key": "/v1/chat/completions · корневой эндпоинт — Эмбеддинги и Голос наследуют его URL и ключ",
|
|
||||||
"/v1/embeddings · embeds pages so semantic search can find them": "/v1/embeddings · создаёт эмбеддинги страниц, чтобы их находил семантический поиск",
|
|
||||||
"/v1/audio/transcriptions · works with local whisper (speaches / faster-whisper-server)": "/v1/audio/transcriptions · работает с локальным whisper (speaches / faster-whisper-server)",
|
|
||||||
"Vector search · requires pgvector": "Векторный поиск · требуется pgvector",
|
|
||||||
"Embedding API key": "API-ключ для эмбеддингов",
|
|
||||||
"Embeddings": "Эмбеддинги",
|
|
||||||
"Leave empty to use the chat API key": "Оставьте пустым, чтобы использовать API-ключ чата",
|
|
||||||
"Leave empty to use the chat base URL": "Оставьте пустым, чтобы использовать базовый URL чата",
|
|
||||||
"Reindex now": "Переиндексировать сейчас",
|
|
||||||
"Start dictation": "Начать диктовку",
|
|
||||||
"Stop recording": "Остановить запись",
|
|
||||||
"Transcribing…": "Транскрибация…",
|
|
||||||
"Microphone access denied": "Доступ к микрофону запрещён",
|
|
||||||
"No microphone found": "Микрофон не найден",
|
|
||||||
"Could not start recording": "Не удалось начать запись",
|
|
||||||
"Transcription failed": "Не удалось распознать речь",
|
|
||||||
"Transcribe": "Транскрибировать",
|
|
||||||
"No speech detected": "Речь не распознана",
|
|
||||||
"Voice dictation is not configured": "Голосовой ввод не настроен",
|
|
||||||
"Microphone is unavailable or already in use": "Микрофон недоступен или уже используется",
|
|
||||||
"Audio recording is not available in this browser/context": "Запись аудио недоступна в этом браузере/контексте",
|
|
||||||
"Dictation": "Диктовка",
|
|
||||||
"Dictation becomes available once the page finishes connecting": "Диктовка станет доступна после подключения к документу",
|
|
||||||
"No connection to the collaboration server — dictation unavailable": "Нет связи с сервером совместного редактирования — диктовка недоступна",
|
|
||||||
"This page is read-only": "Страница открыта только для чтения",
|
|
||||||
"Request format": "Формат запроса",
|
|
||||||
"How transcription requests are sent to the endpoint": "Как запросы на транскрибирование отправляются на эндпоинт",
|
|
||||||
"OpenAI-compatible (multipart/form-data)": "Совместимо с OpenAI (multipart/form-data)",
|
|
||||||
"OpenRouter (JSON, base64 audio)": "OpenRouter (JSON, аудио в base64)",
|
|
||||||
"Dictation language": "Язык диктовки",
|
"Dictation language": "Язык диктовки",
|
||||||
"Auto-detect": "Автоопределение",
|
"Auto-detect": "Автоопределение",
|
||||||
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
|
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
|
||||||
"Agent role": "Роль агента",
|
|
||||||
"Universal assistant": "Универсальный ассистент",
|
|
||||||
"Add role": "Добавить роль",
|
|
||||||
"Edit role": "Изменить роль",
|
|
||||||
"Role name": "Название роли",
|
|
||||||
"e.g. Proofreader": "напр. Корректор",
|
|
||||||
"Optional. Shown as the chat badge.": "Необязательно. Отображается как значок чата.",
|
|
||||||
"Optional. A short note about what this role does.": "Необязательно. Краткое описание того, что делает эта роль.",
|
|
||||||
"Instructions": "Инструкции",
|
|
||||||
"The built-in safety framework is always added automatically.": "Встроенный набор правил безопасности всегда добавляется автоматически.",
|
|
||||||
"Model provider override": "Переопределение провайдера модели",
|
|
||||||
"Optional. Defaults to the workspace provider.": "Необязательно. По умолчанию используется провайдер рабочего пространства.",
|
|
||||||
"Model override": "Переопределение модели",
|
|
||||||
"Optional. Defaults to the workspace model.": "Необязательно. По умолчанию используется модель рабочего пространства.",
|
|
||||||
"e.g. gpt-4o-mini": "напр. gpt-4o-mini",
|
|
||||||
"If you choose a different provider, it must already be configured in AI settings.": "Если вы выбираете другого провайдера, он уже должен быть настроен в настройках ИИ.",
|
|
||||||
"Start automatically": "Запускать автоматически",
|
|
||||||
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "Когда включено, выбор этой роли отправляет стартовое сообщение и начинает чат. Когда выключено, роль выбирается, а первое сообщение вы вводите сами.",
|
|
||||||
"Launch message": "Стартовое сообщение",
|
|
||||||
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Отправляется автоматически при выборе этой роли. Оставьте пустым, чтобы использовать текст по умолчанию. Игнорируется, когда «Запускать автоматически» выключено.",
|
|
||||||
"Agent roles": "Роли агента",
|
|
||||||
"Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.": "Многоразовые пресеты, определяющие поведение агента (и, при желании, его модель). Выбираются при запуске нового чата.",
|
|
||||||
"No roles configured": "Роли не настроены",
|
|
||||||
"Delete role": "Удалить роль",
|
|
||||||
"Are you sure you want to delete this role?": "Вы уверены, что хотите удалить эту роль?",
|
|
||||||
"HTML embed": "HTML-вставка",
|
|
||||||
"Edit HTML embed": "Изменить HTML-вставку",
|
|
||||||
"HTML embed is disabled in this workspace": "HTML-вставки отключены в этом рабочем пространстве",
|
|
||||||
"Click to add HTML / CSS / JS": "Нажмите, чтобы добавить HTML / CSS / JS",
|
|
||||||
"This HTML/CSS/JS runs in a sandboxed frame and cannot access the viewer's session, cookies, or API.": "Этот HTML/CSS/JS выполняется в изолированном фрейме и не имеет доступа к сессии, cookie или API просматривающего.",
|
|
||||||
"<script>...</script>": "<script>...</script>",
|
|
||||||
"Height (px, blank = auto)": "Высота (px, пусто = авто)",
|
|
||||||
"advanced": "дополнительно",
|
|
||||||
"Enable HTML embed": "Включить HTML-вставки",
|
|
||||||
"Allow members to insert raw HTML/CSS/JavaScript blocks. The block renders in a sandboxed frame and cannot access the viewer's session, cookies, or API. Off by default.": "Разрешить участникам вставлять блоки с необработанным HTML/CSS/JavaScript. Блок отображается в изолированном фрейме и не имеет доступа к сессии, cookie или API просматривающего. По умолчанию выключено.",
|
|
||||||
"When enabled, any member can insert an HTML embed block. The toggle just enables or disables the block type workspace-wide.": "Когда включено, любой участник может вставить блок HTML-вставки. Переключатель просто включает или отключает этот тип блока во всём рабочем пространстве.",
|
|
||||||
"Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.": "Вставки выполняются в изолированном iframe с отдельным источником, поэтому они не могут читать или изменять страницу, в которую встроены.",
|
|
||||||
"Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.": "Отключение этой опции скрывает существующие вставки (они отображаются как отключённая заглушка) и прекращает их показ на публичных страницах.",
|
|
||||||
"Analytics / tracker": "Аналитика / трекер",
|
|
||||||
"Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Вставляется дословно в <head> только ПУБЛИЧНЫХ страниц (тот же источник). Для сниппетов аналитики (Google Analytics, Яндекс.Метрика и т. п.). Только для администраторов.",
|
|
||||||
"Go to login page": "Перейти на страницу входа",
|
|
||||||
"Move to space": "Переместить в пространство",
|
|
||||||
"Float left (wrap text)": "Обтекание слева",
|
"Float left (wrap text)": "Обтекание слева",
|
||||||
"Float right (wrap text)": "Обтекание справа",
|
"Float right (wrap text)": "Обтекание справа",
|
||||||
"Inline (side by side)": "В ряд",
|
"Inline (side by side)": "В ряд",
|
||||||
@@ -1352,7 +1199,6 @@
|
|||||||
"Showing {{count}} subpages_one": "Показано {{count}} подстраница",
|
"Showing {{count}} subpages_one": "Показано {{count}} подстраница",
|
||||||
"Showing {{count}} subpages_few": "Показано {{count}} подстраницы",
|
"Showing {{count}} subpages_few": "Показано {{count}} подстраницы",
|
||||||
"Showing {{count}} subpages_many": "Показано {{count}} подстраниц",
|
"Showing {{count}} subpages_many": "Показано {{count}} подстраниц",
|
||||||
"Showing {{count}} subpages_other": "Показано {{count}} подстраниц",
|
|
||||||
"Protocol": "Протокол",
|
"Protocol": "Протокол",
|
||||||
"How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning",
|
"How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning",
|
||||||
"OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)",
|
"OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)",
|
||||||
@@ -1422,6 +1268,7 @@
|
|||||||
"Retry": "Повторить",
|
"Retry": "Повторить",
|
||||||
"The catalog is empty": "Каталог пуст",
|
"The catalog is empty": "Каталог пуст",
|
||||||
"No role bundles are published for this language yet. Try switching the content language.": "Для этого языка ещё не опубликовано ни одного набора ролей. Попробуйте сменить язык контента.",
|
"No role bundles are published for this language yet. Try switching the content language.": "Для этого языка ещё не опубликовано ни одного набора ролей. Попробуйте сменить язык контента.",
|
||||||
|
"No roles configured": "Роли не настроены",
|
||||||
"Already up to date": "Уже актуальна",
|
"Already up to date": "Уже актуальна",
|
||||||
"Updated to the latest version": "Обновлено до последней версии",
|
"Updated to the latest version": "Обновлено до последней версии",
|
||||||
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
|
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
|
||||||
@@ -1434,14 +1281,5 @@
|
|||||||
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено.",
|
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено.",
|
||||||
"Dismiss": "Не применять",
|
"Dismiss": "Не применять",
|
||||||
"Suggestion dismissed": "Предложение отклонено",
|
"Suggestion dismissed": "Предложение отклонено",
|
||||||
"Failed to dismiss suggestion": "Не удалось отклонить предложение",
|
"Failed to dismiss suggestion": "Не удалось отклонить предложение"
|
||||||
"Save version": "Сохранить версию",
|
|
||||||
"Ctrl+S": "Ctrl+S",
|
|
||||||
"Version saved": "Версия сохранена",
|
|
||||||
"Already saved as the latest version": "Уже сохранено как последняя версия",
|
|
||||||
"Agent version": "Версия агента",
|
|
||||||
"Boundary": "Граница",
|
|
||||||
"Autosave": "Автосейв",
|
|
||||||
"Only versions": "Только версии",
|
|
||||||
"No saved versions yet.": "Пока нет сохранённых версий."
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
import { Button, Center, Stack, Text } from "@mantine/core";
|
import { Button, Center, Stack, Text } from "@mantine/core";
|
||||||
import {
|
|
||||||
hasAutoReloaded,
|
const RELOAD_FLAG = "chunk-reload-attempted";
|
||||||
markAutoReloaded,
|
|
||||||
recordReloadBreadcrumb,
|
|
||||||
} from "@/lib/reload-guard";
|
|
||||||
|
|
||||||
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
|
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
|
||||||
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
|
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
|
||||||
@@ -24,26 +21,20 @@ export function isChunkLoadError(error: unknown): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exported for tests: the reactive chunk-load reload decision, so the shared
|
function handleError(error: unknown) {
|
||||||
// window budget (invariant: ≤1 auto-reload per window across this path AND the
|
|
||||||
// proactive version-coherence path) can be exercised against the real guard.
|
|
||||||
export function handleError(error: unknown) {
|
|
||||||
if (!isChunkLoadError(error)) return;
|
if (!isChunkLoadError(error)) return;
|
||||||
// A stale-chunk 404 is cured by a full reload that re-fetches index.html and
|
// A stale-chunk 404 is cured by a full reload that re-fetches index.html and
|
||||||
// the new chunk manifest. Auto-reload at most once per window via the SHARED
|
// the new chunk manifest. Auto-reload once, guarding against a reload loop
|
||||||
// window-based reload guard (see @/lib/reload-guard — the same budget the
|
// (e.g. a genuinely missing chunk) with a one-shot sessionStorage flag. If the
|
||||||
// proactive version-coherence path consumes, so a mismatch that arrives on
|
// flag is already set we fall through to the manual recovery UI below.
|
||||||
// both paths reloads at most once per window across BOTH). This recovers
|
try {
|
||||||
// across multiple deploys in a single tab's lifetime, yet a permanently-broken
|
if (sessionStorage.getItem(RELOAD_FLAG)) return;
|
||||||
// lazy chunk (which would loop) is stopped after the first reload and falls
|
sessionStorage.setItem(RELOAD_FLAG, "1");
|
||||||
// through to the manual recovery UI below. If the shared budget is already
|
} catch {
|
||||||
// spent this window, or the stamp write fails (storage unavailable), we return
|
// sessionStorage unavailable (private mode / disabled): skip the automatic
|
||||||
// without reloading rather than risk a loop.
|
// reload rather than risk an unguarded loop; the fallback UI still recovers.
|
||||||
if (hasAutoReloaded()) return;
|
return;
|
||||||
if (!markAutoReloaded()) return;
|
}
|
||||||
// Trace before the reload clears the console (same diagnostic breadcrumb the
|
|
||||||
// proactive version-coherence path writes, tagged with this path).
|
|
||||||
recordReloadBreadcrumb({ path: "chunk-boundary" });
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,11 +58,8 @@ import ConversationList from "@/features/ai-chat/components/conversation-list.ts
|
|||||||
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
||||||
import {
|
import {
|
||||||
exportAiChat,
|
exportAiChat,
|
||||||
getAiChatMessagesDelta,
|
|
||||||
stopRun,
|
stopRun,
|
||||||
} from "@/features/ai-chat/services/ai-chat-service.ts";
|
} from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||||
import { mergeDeltaRowsIntoPages } from "@/features/ai-chat/utils/resume-helpers.ts";
|
|
||||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
|
||||||
import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts";
|
import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts";
|
||||||
import {
|
import {
|
||||||
shouldCollapseOnOutsidePointer,
|
shouldCollapseOnOutsidePointer,
|
||||||
@@ -89,11 +86,19 @@ const MIN_HEIGHT = 400;
|
|||||||
// Margin kept between the window and the viewport edges while dragging.
|
// Margin kept between the window and the viewport edges while dragging.
|
||||||
const EDGE_MARGIN = 8;
|
const EDGE_MARGIN = 8;
|
||||||
|
|
||||||
// #184 phase 1.5 / #430 / #488: the degraded-poll fallback. The window owns only
|
// #184 phase 1.5 / #430: backstop for the degraded-poll fallback. The poll is
|
||||||
// a DUMB 2.5s timer, gated by an armed flag; the THREAD's run-lifecycle FSM owns
|
// armed when a resume attempt could not attach to the live run and disarmed by the
|
||||||
// arm/disarm AND the inactivity cap that turns a stuck run into a `stalled` banner
|
// thread on settle / local stream; this cap is the ONLY backstop against an endless
|
||||||
// (#488 commit 4a — the cap moved into the thread so polling->stalled is a single
|
// tick (a stuck 'streaming' row before the boot-sweep, or a user-tail 204 with no
|
||||||
// FSM transition; the window no longer silently stops polling at the cap).
|
// run).
|
||||||
|
//
|
||||||
|
// #430: measured from RUN ACTIVITY, not from arm-time. A real autonomous run takes
|
||||||
|
// 11-25 min — longer than a fixed 10-min-from-start cap, which used to cut the poll
|
||||||
|
// off mid-run. Instead we cap on INACTIVITY: keep polling as long as the run is
|
||||||
|
// still making progress (its persisted rows keep changing), and only give up after
|
||||||
|
// this long with NO new activity. A genuinely stuck run produces no row changes, so
|
||||||
|
// the idle cap still bounds it; a long-but-progressing run polls to completion.
|
||||||
|
const DEGRADED_POLL_IDLE_MAX_MS = 10 * 60_000;
|
||||||
|
|
||||||
/** Compact token formatter: 1.2M / 3.4k / 950. */
|
/** Compact token formatter: 1.2M / 3.4k / 950. */
|
||||||
function formatTokens(n: number): string {
|
function formatTokens(n: number): string {
|
||||||
@@ -254,13 +259,17 @@ export default function AiChatWindow() {
|
|||||||
[roles],
|
[roles],
|
||||||
);
|
);
|
||||||
|
|
||||||
// #184 phase 1.5 / #488: degraded-poll fallback. ChatThread's FSM arms this via
|
// #184 phase 1.5: degraded-poll fallback (replaces the F4/F5/F7 latches). When
|
||||||
// onResumeFallback(true) when it enters a poll-bearing recovery (attach 204 /
|
// ChatThread could not attach to a still-running run it arms this via
|
||||||
// starved finish / stop) and disarms it on settle / local stream / stalled. The
|
// onResumeFallback(true); the thread disarms it on settle / local stream. The
|
||||||
// window owns ONLY the dumb 2.5s timer; the THREAD owns arm/disarm AND the
|
// window only OWNS the timer (armedAtRef stamps when it was armed for the cap).
|
||||||
// inactivity cap (a stuck run -> the thread's `stalled` banner disarms this).
|
|
||||||
const [degradedPoll, setDegradedPoll] = useState(false);
|
const [degradedPoll, setDegradedPoll] = useState(false);
|
||||||
|
// #430: timestamp of the LAST run activity while the poll is armed — stamped on
|
||||||
|
// arm and re-stamped whenever the polled rows change (see the effect below). The
|
||||||
|
// idle cap is measured from this, so a long-but-progressing run keeps polling.
|
||||||
|
const lastActivityAtRef = useRef(0);
|
||||||
const onResumeFallback = useCallback((active: boolean): void => {
|
const onResumeFallback = useCallback((active: boolean): void => {
|
||||||
|
if (active) lastActivityAtRef.current = Date.now();
|
||||||
setDegradedPoll(active);
|
setDegradedPoll(active);
|
||||||
}, []);
|
}, []);
|
||||||
// Reset the degraded poll whenever the open chat changes: it is scoped to the
|
// Reset the degraded poll whenever the open chat changes: it is scoped to the
|
||||||
@@ -272,63 +281,32 @@ export default function AiChatWindow() {
|
|||||||
const { data: messageRows, isLoading: messagesLoading } =
|
const { data: messageRows, isLoading: messagesLoading } =
|
||||||
useAiChatMessagesQuery(
|
useAiChatMessagesQuery(
|
||||||
activeChatId ?? undefined,
|
activeChatId ?? undefined,
|
||||||
// #491: the full infinite-query no longer POLLS. It seeds the thread ONCE; the
|
// DELIBERATELY DUMB (invariant 8 / task 2.4): poll every 2.5s while armed
|
||||||
// degraded fallback now runs a DELTA poller (below) that augments THIS cache
|
// and while the run is still active (#430: under the INACTIVITY cap, not a
|
||||||
// idempotently, instead of refetching every page (with full parts) every 2.5s.
|
// fixed-from-start cap); otherwise off. NO error checks (TanStack v5 resets
|
||||||
false,
|
// fetchFailureCount each fetch, so consecutive errors are not expressible —
|
||||||
// #344: gate on windowOpen too — no message history is fetched while the window
|
// and the poll must survive a server restart) and NO tail checks (the
|
||||||
// is closed; it loads when the window opens with an active chat.
|
// settled/local-stream semantics live in ChatThread, which disarms via
|
||||||
|
// onResumeFallback(false)). The idle cap is the only backstop.
|
||||||
|
() =>
|
||||||
|
degradedPoll === true &&
|
||||||
|
Date.now() - lastActivityAtRef.current < DEGRADED_POLL_IDLE_MAX_MS
|
||||||
|
? 2500
|
||||||
|
: false,
|
||||||
|
// #344: gate on windowOpen too — no message history is fetched (and no
|
||||||
|
// degraded poll runs) while the window is closed; it loads when the window
|
||||||
|
// opens with an active chat.
|
||||||
windowOpen,
|
windowOpen,
|
||||||
);
|
);
|
||||||
|
|
||||||
// #491 degraded DELTA poll. While armed (degradedPoll) and the window is open on a
|
// #430: re-stamp the activity clock whenever the polled rows change while the
|
||||||
// chat, poll POST /ai-chat/messages/delta every 2.5s: it returns only the rows
|
// poll is armed. TanStack keeps the same `messageRows` reference across refetches
|
||||||
// CHANGED since the previous cursor (+ the run fact) in ONE round-trip. We merge
|
// that return deep-equal data (structural sharing), so a new reference means the
|
||||||
// those rows into the SAME infinite-query cache the thread reads (idempotently by
|
// run genuinely progressed — which extends the inactivity cap above. A stuck run
|
||||||
// id — the delta's overlap window re-delivers rows), so the thread's reconcile
|
// yields no reference change, so the cap eventually fires and stops the poll.
|
||||||
// effect follows the detached run to its terminal row from a fraction of the wire
|
|
||||||
// cost. The run-fact settle stays the thread FSM's job (row-status reconcile), so
|
|
||||||
// we do NOT double-poll /run here. Cursor resets when the chat changes / disarms.
|
|
||||||
const deltaCursorRef = useRef<string | undefined>(undefined);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
deltaCursorRef.current = undefined;
|
if (degradedPoll) lastActivityAtRef.current = Date.now();
|
||||||
}, [activeChatId, degradedPoll]);
|
}, [degradedPoll, messageRows]);
|
||||||
useEffect(() => {
|
|
||||||
if (!degradedPoll || !windowOpen || !activeChatId) return;
|
|
||||||
const chatId = activeChatId;
|
|
||||||
let cancelled = false;
|
|
||||||
const tick = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const res = await getAiChatMessagesDelta(chatId, deltaCursorRef.current);
|
|
||||||
if (cancelled) return;
|
|
||||||
deltaCursorRef.current = res.cursor;
|
|
||||||
if (res.rows.length > 0) {
|
|
||||||
queryClient.setQueryData(
|
|
||||||
AI_CHAT_MESSAGES_RQ_KEY(chatId),
|
|
||||||
(
|
|
||||||
old:
|
|
||||||
| {
|
|
||||||
pages: { items: IAiChatMessageRow[]; meta: unknown }[];
|
|
||||||
pageParams: unknown[];
|
|
||||||
}
|
|
||||||
| undefined,
|
|
||||||
) =>
|
|
||||||
old
|
|
||||||
? { ...old, pages: mergeDeltaRowsIntoPages(old.pages, res.rows) }
|
|
||||||
: old,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Transient failure (e.g. a server restart mid-run): swallow and retry on
|
|
||||||
// the next tick — the poll must survive a bounce, like the old dumb refetch.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const id = setInterval(() => void tick(), 2500);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
clearInterval(id);
|
|
||||||
};
|
|
||||||
}, [degradedPoll, windowOpen, activeChatId, queryClient]);
|
|
||||||
|
|
||||||
// #184 reconnect-and-live-follow. Whether detached agent runs are enabled for
|
// #184 reconnect-and-live-follow. Whether detached agent runs are enabled for
|
||||||
// this workspace. When the feature is off no runs are ever created, so the
|
// this workspace. When the feature is off no runs are ever created, so the
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -47,13 +47,6 @@ interface MessageItemProps {
|
|||||||
* agent's raw query/argument text.
|
* agent's raw query/argument text.
|
||||||
*/
|
*/
|
||||||
showInput?: boolean;
|
showInput?: boolean;
|
||||||
/**
|
|
||||||
* Forwarded to ToolCallCard: whether a failed tool card renders its raw
|
|
||||||
* errorText. Defaults to true (internal chat). The public share passes false so
|
|
||||||
* internal detail in a tool error is never painted (belt to the server-side
|
|
||||||
* byte sanitization).
|
|
||||||
*/
|
|
||||||
showErrors?: boolean;
|
|
||||||
/**
|
/**
|
||||||
* Neutralize internal/relative markdown links in the rendered answer (drop
|
* Neutralize internal/relative markdown links in the rendered answer (drop
|
||||||
* their href so they become inert text). Defaults to false (internal chat,
|
* their href so they become inert text). Defaults to false (internal chat,
|
||||||
@@ -132,7 +125,6 @@ function MessageItem({
|
|||||||
message,
|
message,
|
||||||
showCitations = true,
|
showCitations = true,
|
||||||
showInput = true,
|
showInput = true,
|
||||||
showErrors = true,
|
|
||||||
neutralizeInternalLinks = false,
|
neutralizeInternalLinks = false,
|
||||||
assistantName,
|
assistantName,
|
||||||
turnStreaming = false,
|
turnStreaming = false,
|
||||||
@@ -227,7 +219,6 @@ function MessageItem({
|
|||||||
part={part as unknown as ToolUiPart}
|
part={part as unknown as ToolUiPart}
|
||||||
showCitations={showCitations}
|
showCitations={showCitations}
|
||||||
showInput={showInput}
|
showInput={showInput}
|
||||||
showErrors={showErrors}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -293,7 +284,6 @@ export function arePropsEqual(
|
|||||||
prev.signature === next.signature &&
|
prev.signature === next.signature &&
|
||||||
prev.showCitations === next.showCitations &&
|
prev.showCitations === next.showCitations &&
|
||||||
prev.showInput === next.showInput &&
|
prev.showInput === next.showInput &&
|
||||||
prev.showErrors === next.showErrors &&
|
|
||||||
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
|
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
|
||||||
prev.assistantName === next.assistantName &&
|
prev.assistantName === next.assistantName &&
|
||||||
// The turn-end flip re-renders every row once (cheap, terminal event) —
|
// The turn-end flip re-renders every row once (cheap, terminal event) —
|
||||||
|
|||||||
@@ -32,12 +32,6 @@ interface MessageListProps {
|
|||||||
* doesn't see the agent's raw query/argument text.
|
* doesn't see the agent's raw query/argument text.
|
||||||
*/
|
*/
|
||||||
showInput?: boolean;
|
showInput?: boolean;
|
||||||
/**
|
|
||||||
* Forwarded to MessageItem -> ToolCallCard: whether a failed tool card renders
|
|
||||||
* its raw errorText. Defaults to true (internal chat). The public share passes
|
|
||||||
* false so internal detail in a tool error is never painted.
|
|
||||||
*/
|
|
||||||
showErrors?: boolean;
|
|
||||||
/**
|
/**
|
||||||
* Forwarded to MessageItem: neutralize internal/relative markdown links in
|
* Forwarded to MessageItem: neutralize internal/relative markdown links in
|
||||||
* the rendered answers (drop their href so they render as inert text).
|
* the rendered answers (drop their href so they render as inert text).
|
||||||
@@ -133,7 +127,6 @@ export default function MessageList({
|
|||||||
emptyState,
|
emptyState,
|
||||||
showCitations = true,
|
showCitations = true,
|
||||||
showInput = true,
|
showInput = true,
|
||||||
showErrors = true,
|
|
||||||
neutralizeInternalLinks = false,
|
neutralizeInternalLinks = false,
|
||||||
assistantName,
|
assistantName,
|
||||||
}: MessageListProps) {
|
}: MessageListProps) {
|
||||||
@@ -224,7 +217,6 @@ export default function MessageList({
|
|||||||
signature={messageSignature(message)}
|
signature={messageSignature(message)}
|
||||||
showCitations={showCitations}
|
showCitations={showCitations}
|
||||||
showInput={showInput}
|
showInput={showInput}
|
||||||
showErrors={showErrors}
|
|
||||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||||
assistantName={assistantName}
|
assistantName={assistantName}
|
||||||
// Turn-level liveness, gated to the TAIL row: only the tail message
|
// Turn-level liveness, gated to the TAIL row: only the tail message
|
||||||
|
|||||||
@@ -30,16 +30,6 @@ interface ToolCallCardProps {
|
|||||||
* the extra summary line, leaving the card (the action log) intact.
|
* the extra summary line, leaving the card (the action log) intact.
|
||||||
*/
|
*/
|
||||||
showInput?: boolean;
|
showInput?: boolean;
|
||||||
/**
|
|
||||||
* Whether to render the tool's raw errorText on a failed call. Defaults to true
|
|
||||||
* (the internal chat, where the operator may debug). The public share passes
|
|
||||||
* false: a tool error string can carry internal detail (an internal page title,
|
|
||||||
* a stack fragment, a provider message). This is the RENDER gate only — the
|
|
||||||
* authoritative fix also sanitizes the bytes server-side (see
|
|
||||||
* PublicShareChatToolsService.forShare), so a share reader never receives raw
|
|
||||||
* error text over the wire, not just never sees it painted (#394).
|
|
||||||
*/
|
|
||||||
showErrors?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,7 +41,6 @@ export default function ToolCallCard({
|
|||||||
part,
|
part,
|
||||||
showCitations = true,
|
showCitations = true,
|
||||||
showInput = true,
|
showInput = true,
|
||||||
showErrors = true,
|
|
||||||
}: ToolCallCardProps) {
|
}: ToolCallCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toolName = getToolName(part);
|
const toolName = getToolName(part);
|
||||||
@@ -85,7 +74,7 @@ export default function ToolCallCard({
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{state === "error" && showErrors && part.errorText && (
|
{state === "error" && part.errorText && (
|
||||||
<Text size="xs" c="red" mt={2}>
|
<Text size="xs" c="red" mt={2}>
|
||||||
{part.errorText}
|
{part.errorText}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -57,50 +57,6 @@ export async function stopRun(
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delta poll (#491): the chat's message rows changed since `cursor` (a DB-clock
|
|
||||||
* timestamp echoed from the previous poll) plus the current run fact, in ONE
|
|
||||||
* round-trip — the degraded-poll fallback's payload, replacing the old "refetch
|
|
||||||
* ALL infinite-query pages every 2.5s with full parts" poll. Omit `cursor` on the
|
|
||||||
* first poll (returns just a fresh cursor, no rows, to start the chain). The
|
|
||||||
* overlap window guarantees occasional REPEATS, so the caller MUST merge rows
|
|
||||||
* idempotently by id (mergeById). Owner-gated server-side.
|
|
||||||
*/
|
|
||||||
export async function getAiChatMessagesDelta(
|
|
||||||
chatId: string,
|
|
||||||
cursor?: string,
|
|
||||||
): Promise<{
|
|
||||||
rows: IAiChatMessageRow[];
|
|
||||||
cursor: string;
|
|
||||||
run: { id: string; status: string } | null;
|
|
||||||
}> {
|
|
||||||
const req = await api.post<{
|
|
||||||
rows: IAiChatMessageRow[];
|
|
||||||
cursor: string;
|
|
||||||
run: { id: string; status: string } | null;
|
|
||||||
}>("/ai-chat/messages/delta", { chatId, cursor });
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #488: the run-fact — "is a run active on this chat?" — first-class from the
|
|
||||||
* server (POST /ai-chat/run). Called on mount to seed the client FSM's run-fact
|
|
||||||
* and to VERIFY after a supersede mismatch (an observer following a superseded
|
|
||||||
* run asks for the latest run and follows it). Returns the latest run row (with
|
|
||||||
* its `id` and `status`) and its projected assistant message, or `run: null` when
|
|
||||||
* the chat has never had a run. Owner-gated server-side.
|
|
||||||
*/
|
|
||||||
export async function getRun(chatId: string): Promise<{
|
|
||||||
run: { id: string; status: string } | null;
|
|
||||||
message: IAiChatMessageRow | null;
|
|
||||||
}> {
|
|
||||||
const req = await api.post<{
|
|
||||||
run: { id: string; status: string } | null;
|
|
||||||
message: IAiChatMessageRow | null;
|
|
||||||
}>("/ai-chat/run", { chatId });
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the chat bound to a document (the current user's most-recent chat
|
* Resolve the chat bound to a document (the current user's most-recent chat
|
||||||
* created on that page), or null when there is none. Drives auto-open-on-page.
|
* created on that page), or null when there is none. Drives auto-open-on-page.
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
# AI-chat run-lifecycle FSM — design spec (#488)
|
|
||||||
|
|
||||||
This is the written design that `run-fsm.ts` implements. It ships in the PR (issue
|
|
||||||
#488 commit 1: "the spec is written FIRST and enters the PR"). It has four parts:
|
|
||||||
(1) the event × state transition table, (2) the map of every `chat-thread.tsx` ref
|
|
||||||
to {FSM state | FSM context | stays data}, (3) the run-fact protocol, (4) the
|
|
||||||
invariants.
|
|
||||||
|
|
||||||
The reducer is a **pure function** `reduce(machine, event) → machine`. The returned
|
|
||||||
machine carries the **command effects** for that transition; a thin runtime in
|
|
||||||
`chat-thread.tsx` dispatches events and executes effects. Because it is pure, the
|
|
||||||
whole machine is enumerable and unit-tested directly (event × state → next state is
|
|
||||||
the observable property) — see `run-fsm.test.ts`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Event × state transition table
|
|
||||||
|
|
||||||
Phases: `idle | sending | streaming | attaching | reconnecting(attempt,failed) |
|
|
||||||
polling(reason) | stalled | stopping | superseding | error(kind)`.
|
|
||||||
Context (orthogonal): `epoch`, `ownership: local|observer`, `runFact: {runId}|null`,
|
|
||||||
`liveFollow` (are we following a live run we locally streamed — the reconnect
|
|
||||||
ladder — vs a one-shot mount-attach resume? both are `observer`, but a live-follow
|
|
||||||
drop RE-ENTERS the ladder (#488 commit 3) while a mount-resume drop polls).
|
|
||||||
|
|
||||||
Legend: **†** = command-transition (bumps `epoch`, I1). Effects in `[…]`.
|
|
||||||
|
|
||||||
| Event (source) | From phase(s) | → To phase | Effects / ctx |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `SEND_LOCAL` (user send) | idle, error, polling, stalled, reconnecting | sending **†** | `[cancelReconnect, disarmPoll]`, ownership=local |
|
|
||||||
| `STREAM_START{runId}` (SDK `start` metadata) | sending, attaching, reconnecting, superseding | streaming | `[cancelReconnect, disarmPoll]`, runFact←runId |
|
|
||||||
| `FINISH_CLEAN` (onFinish clean) | streaming, … | idle | `[disarmPoll, cancelReconnect]`, runFact←null |
|
|
||||||
| `FINISH_ABORT` (onFinish isAbort) | streaming, stopping | idle | `[disarmPoll, cancelReconnect]`, runFact←null (I4 exits stopping by this DATA) |
|
|
||||||
| `FINISH_DISCONNECT` (observer, NOT liveFollow) | streaming(observer) | polling(disconnect-visible) | `[armPoll]` (a mount-resume drop polls) |
|
|
||||||
| `FINISH_DISCONNECT{hasVisibleContent}` (local drop OR liveFollow) | streaming | reconnecting(1) **†** *iff runFact\|liveFollow* | `[scheduleReconnect(1)]` (+`armPoll` if visible), ownership=observer, liveFollow=true (commit 3: repeatable) |
|
|
||||||
| `FINISH_DISCONNECT` (no runFact, not liveFollow) | streaming | idle | runFact←null (plain terminal "connection lost") |
|
|
||||||
| `STREAM_INCOMPLETE{reason}` (observer starved/torn clean finish) | streaming(observer) | polling(reason) | `[armPoll(reason)]` |
|
|
||||||
| `FINISH_ERROR{kind}` (onFinish isError) | any | error(kind) | `[disarmPoll, cancelReconnect]`, runFact←null |
|
|
||||||
| `STREAM_START{runId}` (first assistant frame of a local turn) | sending | streaming | runFact←runId, `[cancelReconnect, disarmPoll]` |
|
|
||||||
| `ATTACH_START{runId}` (mount resume) | **idle only** (F2) | attaching **†** | `[resumeStream]`, ownership=observer, runFact←runId; ignored from any non-idle phase |
|
|
||||||
| `ATTACH_LIVE` (attach GET 2xx) | attaching | streaming | — |
|
|
||||||
| `ATTACH_NONE` (attach GET 204/err/throw) | attaching | polling(attach-none) | `[armPoll(attach-none)]` |
|
|
||||||
| `RECONNECT_ATTEMPT{n}` (backoff timer) | reconnecting | reconnecting(n) **†** | `[resumeStream]` |
|
|
||||||
| `RECONNECT_ATTACHED` (reconnect GET 2xx) | reconnecting | streaming | `[cancelReconnect, disarmPoll]` — **counter reset** (commit 3) |
|
|
||||||
| `RECONNECT_NONE` (reconnect GET 204/err), attempt<MAX | reconnecting | reconnecting(n+1) **†** | `[armPoll(attach-none), scheduleReconnect(n+1)]` |
|
|
||||||
| `RECONNECT_NONE`, attempt=MAX | reconnecting | reconnecting(MAX, failed) | `[armPoll(reconnect-exhausted)]` |
|
|
||||||
| `RETRY` (manual, failed banner) | reconnecting(failed) | reconnecting(1) **†** | `[resumeStream]` |
|
|
||||||
| `RETRY` (manual, stalled banner) | stalled | polling(attach-none) **†** | `[armPoll]` |
|
|
||||||
| `POLL_TERMINAL` (settled tail merged) | polling, reconnecting, stopping | idle | `[disarmPoll, cancelReconnect]`, runFact←null (I4) |
|
|
||||||
| `POLL_IDLE_CAP` (inactivity cap) | polling, reconnecting | stalled | `[disarmPoll, cancelReconnect]` (commit 4a — no more silent) |
|
|
||||||
| `POLL_IDLE_CAP` (inactivity cap) | stopping | idle | `[disarmPoll, cancelReconnect]`, runFact←null (Review #4: a Stop-armed poll with no SDK/terminal backstop gets a bounded exit — NOT `stalled`, Stop was already pressed so nothing to retry) |
|
|
||||||
| `RUN_FACT{null}` (POST /run → null/terminal, 204) | reconnecting/attaching/polling/stopping | idle | `[cancelReconnect, disarmPoll]`, runFact←null (I3 fresh-negative gate) |
|
|
||||||
| `RUN_FACT{runId}` | any | (same) | runFact←runId (pessimism toward an attempt) |
|
|
||||||
| `STOP_REQUESTED` (user Stop) | streaming, reconnecting, polling | stopping **†** | `[stopRun, abortAttach, cancelReconnect, armPoll]` (poll drives the terminal — I4 exit by data) |
|
|
||||||
| `SUPERSEDE_REQUESTED{targetRunId}` (interrupt+send) | streaming, reconnecting, polling, error | superseding **†** | `[supersede(target), cancelReconnect, disarmPoll]` |
|
|
||||||
| `SUPERSEDE_READY{runId}` (CAS ok) | superseding | streaming | ownership=local, runFact←runId |
|
|
||||||
| `SUPERSEDE_MISMATCH{currentRunId}` (409 SUPERSEDE_TARGET_MISMATCH) | superseding | error(supersede-mismatch) | `[postRun(verify)]`, runFact←currentRunId |
|
|
||||||
| `SUPERSEDE_TIMEOUT` (409 SUPERSEDE_TIMEOUT) | superseding | error(supersede-timeout) | — (composer keeps text; no auto-retry) |
|
|
||||||
| `SUPERSEDE_INVALID` (409 SUPERSEDE_INVALID) | superseding | error(supersede-invalid) | — |
|
|
||||||
| `RUN_ALREADY_ACTIVE{activeRunId}` (409 A_RUN_ALREADY_ACTIVE, plain POST) | sending | error(run-already-active) | runFact←activeRunId (composer offers supersede; NO auto-retry) |
|
|
||||||
| `DISPOSE` (unmount) | any | idle **†** | `[abortAttach, cancelReconnect, disarmPoll]` (I1/I5 — epoch++ kills late callbacks) |
|
|
||||||
|
|
||||||
**`stopping` honors any finish (re-review MEDIUM):** BEFORE the epoch filter, a
|
|
||||||
stream finish (`FINISH_*`/`STREAM_INCOMPLETE`) arriving in phase `stopping` exits
|
|
||||||
`stopping -> idle` regardless of generation. A plain Stop has no successor stream,
|
|
||||||
so the aborted stream's finish IS the expected end (I4 exit by data) — and it
|
|
||||||
carries the PRE-stop generation (STOP_REQUESTED bumped the epoch), so the filter
|
|
||||||
would otherwise strand the machine in `stopping` (no idle-cap covers it). The filter
|
|
||||||
stays in force for `superseding` (that is the F1 supersede drop).
|
|
||||||
|
|
||||||
**Epoch filter (I1):** the reducer then drops any event carrying an `epoch` that
|
|
||||||
does not equal the current `ctx.epoch`. Outcome events (`STREAM_START`, `ATTACH_*`,
|
|
||||||
`RECONNECT_*`, `SUPERSEDE_*`, **`FINISH_*`/`STREAM_INCOMPLETE`**, `RUN_FACT`) are
|
|
||||||
stamped with the generation the corresponding STREAM started under (the runtime
|
|
||||||
holds a per-owned-stream `turnEpoch`); trigger events (user actions, fresh
|
|
||||||
disconnects) carry no epoch. **F1:** this is what makes a SUPERSEDED stream's late
|
|
||||||
`onFinish` (a dead stream A closing after the CAS started stream B) get dropped, so
|
|
||||||
A cannot drive the live new run into a false reconnect or reset its run-fact. The
|
|
||||||
supersede path additionally ABORTS A and starts B only from A's onFinish (a
|
|
||||||
microtask), because ai@6 `AbstractChat.makeRequest` corrupts overlapping streams
|
|
||||||
(A's `finally` reads then nulls the shared `activeResponse`).
|
|
||||||
|
|
||||||
**Removed events (scope-cut, internal review):** `RUN_SUPERSEDED` (a ghost feature —
|
|
||||||
never dispatched; the observer-superseded case is handled by the degraded poll,
|
|
||||||
which follows the latest rows regardless of runId), `RECONNECT_BEGIN` (reconnect is
|
|
||||||
entered by `FINISH_DISCONNECT`), and `POLL_ACTIVITY` (the window's activity clock was
|
|
||||||
removed when the idle-cap moved into the thread). The reducer and this table now
|
|
||||||
share exactly the dispatched event set.
|
|
||||||
|
|
||||||
### 409-code → event map (the real #487 contract consumed here)
|
|
||||||
|
|
||||||
| Server response | Event dispatched | error kind → banner |
|
|
||||||
|---|---|---|
|
|
||||||
| 409 `A_RUN_ALREADY_ACTIVE` (+ body.activeRunId) | `RUN_ALREADY_ACTIVE{activeRunId}` | run-already-active → "already answering / interrupt & send" |
|
|
||||||
| 409 `SUPERSEDE_TARGET_MISMATCH` (+ body.activeRunId) | `SUPERSEDE_MISMATCH{currentRunId}` | supersede-mismatch → verify via /run |
|
|
||||||
| 409 `SUPERSEDE_TIMEOUT` | `SUPERSEDE_TIMEOUT` | supersede-timeout → "couldn't interrupt in time, resend" |
|
|
||||||
| 409 `SUPERSEDE_INVALID` | `SUPERSEDE_INVALID` | supersede-invalid → "couldn't interrupt this run" |
|
|
||||||
| 503 `A_RUN_BEGIN_FAILED` | `FINISH_ERROR{begin-failed}` | begin-failed → "could not start, temporary" |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Ref-map — every `chat-thread.tsx` ref → its new home (MIGRATION RESOLVED)
|
|
||||||
|
|
||||||
The migration is COMPLETE: the 13 run-lifecycle FLAGS below are GONE from
|
|
||||||
`chat-thread.tsx` (collapsed into FSM phase/ctx/effects, or deleted). What remains
|
|
||||||
are identity/data mirrors, effect-owned controllers/timers, and ONE React-liveness
|
|
||||||
bit — none of which is a run-lifecycle flag, so the post-merge "no new flags" rule
|
|
||||||
holds. **Pending column: empty.**
|
|
||||||
|
|
||||||
| # | Old ref | Resolved to | Where now |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | `reconcileTailRef` | **FSM phase** | reconcile-merge gated on `phase ∈ {polling, reconnecting, stopping}` |
|
|
||||||
| 2 | `noStreamHandledRef` | **FSM epoch (I1)** | the attach outcome's epoch guard drops the stale/second outcome |
|
|
||||||
| 3 | `onNoActiveStreamRef` | **FSM event** | transport → `handleAttachOutcome` dispatches `ATTACH_NONE`/`RECONNECT_NONE` |
|
|
||||||
| 4 | `onReconnectAttachedRef` | **FSM event** | transport dispatches `ATTACH_LIVE` / `RECONNECT_ATTACHED` |
|
|
||||||
| 5 | `resumedTurnRef` + `resumedTurn` state | **FSM ctx `ownership`** | `ownership==='observer'` ⇒ never flush; hides "Send now" |
|
|
||||||
| 6 | `reconnectStateRef` + `reconnectState` state | **FSM phase** | `reconnecting(attempt,failed)` renders the banner |
|
|
||||||
| 7 | `reconnectTimerRef` | **effect-owned timer** | owned by `scheduleReconnect`/`cancelReconnect` effects (not a flag) |
|
|
||||||
| 8 | `flushOnAbortRef` | **DELETED** | the stop→flush dance is replaced by the CAS supersede (commit 5) |
|
|
||||||
| 9 | `interruptNextSendRef` | **DELETED** | the server injects the interrupt note from the supersede itself |
|
|
||||||
| 10 | `supersedeRetryRef` | **DELETED** (commit 5) | the client 409 retry ladder is gone; CAS supersede replaces it |
|
|
||||||
| 11 | `stopPendingRef` | **FSM phase `stopping`** | the deferred stop fires from the chat-id adoption effect while `stopping` |
|
|
||||||
| 12 | `mountedRef` | **retained (React liveness)** | orthogonal to run-lifecycle; gates imperative onFinish side-effects post-unmount. Epoch (I1) handles stale COMMAND-outcomes; DISPOSE bumps it |
|
|
||||||
| 13 | `attemptResumeRef` | **FSM `ATTACH_START` + run-fact** | mount arms attach ONLY on a confirmed active run (commit 4b: streaming-tail status, or POST /run for a user tail) |
|
|
||||||
| 14–15 | `anchorRef {id, stepsPersisted}` | **data** (attachStrategy) | #491 tail-only: replaced `stripRef`/`strippedRowRef`. The PERSISTED assistant row that pins the run (server invariant 6) + its step frontier N; feeds `?anchor=<id>&n=<stepsPersisted>`. No strip — the seed keeps every row; entering reconnecting re-seeds from persist |
|
|
||||||
| 16 | `attachAbortRef` | **effect-owned controller** | aborted by the `abortAttach` effect in cleanup (I5) |
|
|
||||||
| 17–25 | `chatIdRef`, `openPageRef`, `getEditorSelectionRef`, `roleIdRef`, `stableIdRef`, `queuedRef`, `sendMessageRef`, `statusRef`, `lastForwardedChatIdRef` | **data** (identity/send mirrors) | unchanged — not lifecycle flags |
|
|
||||||
| NEW | `pendingSupersedeRef` | **data** (send-plumbing) | the runId injected into the next `POST /stream {supersede}`; the single replacement for the 3 DELETED one-shots (#8/#9/#10) — net −2 refs |
|
|
||||||
| NEW | `idleCapTimerRef` | **effect-owned timer** | the stalled inactivity cap → `POLL_IDLE_CAP` (commit 4a); not a flag |
|
|
||||||
|
|
||||||
Net: the 13 lifecycle flags (#1–#13) are eliminated: **8** → FSM phase/ctx/epoch/event
|
|
||||||
(#1–#6, #11, #13), **3** deleted (#8/#9/#10), **`reconnectTimerRef` (#7)** becomes an
|
|
||||||
effect-owned controller, and **`mountedRef` (#12)** is retained as React liveness
|
|
||||||
(8 + 3 + 1 + 1 = 13). (`attachAbortRef` (#16) is outside the #1–#13 set — it was
|
|
||||||
already an effect-owned controller.) Two effect-owned timers + one send-plumbing data
|
|
||||||
ref are added — none is a boolean lifecycle latch.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Run-fact protocol (`runFact: {runId} | null`) — I3
|
|
||||||
|
|
||||||
"A run is active" is first-class from the SERVER, not inferred from an assistant
|
|
||||||
message. Sources, in the order they update `ctx.runFact`:
|
|
||||||
|
|
||||||
1. **Init (mount):** `POST /ai-chat/run { chatId }` → `{ run, message }`. A `run`
|
|
||||||
with a non-terminal `status` seeds `runFact = { runId: run.id }`; a null/terminal
|
|
||||||
run seeds `null`. This is what arms the resume attempt (`ATTACH_START`) — the
|
|
||||||
attempt is armed ONLY on a positive fact (commit 4b: a user-tail with no active
|
|
||||||
run no longer arms a pointless poll on every open).
|
|
||||||
2. **Live update:** the `start` stream metadata carries `runId` → `STREAM_START{runId}`.
|
|
||||||
3. **Attach outcomes:** `ATTACH_LIVE` (2xx) confirms active; a 204 on a non-stripped
|
|
||||||
path is an authoritative NEGATIVE fact → the runtime dispatches `RUN_FACT{null}`,
|
|
||||||
which cancels recovery (I3 fresh-negative gate).
|
|
||||||
4. **Poll (#491, implemented):** the degraded poll now hits the delta endpoint
|
|
||||||
(`POST /ai-chat/messages/delta`), which ALREADY carries the run fact
|
|
||||||
(`run: {id, status} | null`) alongside the changed rows. The client does NOT yet
|
|
||||||
consume that run field — it still drives to a terminal ROW (merged by id),
|
|
||||||
dispatched as `POLL_TERMINAL` — so the run field rides the wire for a future
|
|
||||||
client that settles straight off it.
|
|
||||||
|
|
||||||
Pessimism rule: a stale-but-positive fact PERMITS entering recovery (attach); the
|
|
||||||
204 then cuts it. A fresh negative fact gates recovery OUT immediately.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Invariants
|
|
||||||
|
|
||||||
- **I1 — Epoch (generation counter).** Every command-emitting transition bumps
|
|
||||||
`ctx.epoch`; every async outcome event carries its issuing epoch; the reducer
|
|
||||||
drops stale-epoch outcomes. Replaces the one-shot-ref zoo (`noStreamHandledRef`,
|
|
||||||
the flush/interrupt/supersede one-shots, the `mountedRef` late-callback gate).
|
|
||||||
- **I2 — Ownership is context, not state.** `local | observer` is orthogonal to the
|
|
||||||
transport phase. The queue flushes ONLY under local ownership; an observer
|
|
||||||
following a detached run never flushes (was `resumedTurnRef`).
|
|
||||||
- **I3 — Run-fact is first-class from the server.** Reconnect is entered by the
|
|
||||||
run-fact, not by an assistant message (commit 2). A fresh negative fact cancels
|
|
||||||
recovery.
|
|
||||||
- **I4 — Exit `stopping` by DATA.** A terminal row / negative run-fact / terminal
|
|
||||||
finish exits `stopping`, never the stopRun HTTP response (which returns after the
|
|
||||||
abort but before finalization — keying off it would unlock the composer on a 409).
|
|
||||||
- **I5 — Dispose protocol.** Command controllers (attach GET, POST /stream, POST
|
|
||||||
/run) are effect-owned and aborted in cleanup (`abortAttach` on `DISPOSE`), not
|
|
||||||
render-phase refs. A client abort of an already-sent POST does not cancel the
|
|
||||||
server action, so disarming on unmount is safe.
|
|
||||||
- **attachStrategy** is behind the `resumeStream` effect; #491 swapped it to
|
|
||||||
tail-only (`?anchor=&n=`, `anchorRef` data) WITHOUT touching the FSM. Entering
|
|
||||||
reconnecting always re-seeds from persist; on a getRun failure the live partial
|
|
||||||
is dropped + replay-from-start so it is never the tail-apply base (no #137/#161
|
|
||||||
duplication).
|
|
||||||
- **Queue** stays a data structure; flush/interrupt decisions are transitions.
|
|
||||||
@@ -1,482 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import {
|
|
||||||
reduce,
|
|
||||||
initialMachine,
|
|
||||||
reconnectDelayMs,
|
|
||||||
RECONNECT_MAX_ATTEMPTS,
|
|
||||||
type Machine,
|
|
||||||
type Effect,
|
|
||||||
type Event,
|
|
||||||
} from "./run-fsm";
|
|
||||||
|
|
||||||
// Drive a sequence of events through the reducer, returning the final machine.
|
|
||||||
function run(m: Machine, ...events: Event[]): Machine {
|
|
||||||
return events.reduce(reduce, m);
|
|
||||||
}
|
|
||||||
function withRunFact(runId = "run-1"): Machine {
|
|
||||||
return {
|
|
||||||
...initialMachine(),
|
|
||||||
ctx: { epoch: 0, ownership: "local", runFact: { runId }, liveFollow: false },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function effectTypes(m: Machine): string[] {
|
|
||||||
return m.effects.map((e) => e.type);
|
|
||||||
}
|
|
||||||
function hasEffect(m: Machine, type: Effect["type"]): boolean {
|
|
||||||
return m.effects.some((e) => e.type === type);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("run-fsm — epoch invariant (I1)", () => {
|
|
||||||
it("drops an outcome carrying a stale epoch", () => {
|
|
||||||
// A command bumps the epoch; an outcome stamped with the OLD epoch is dropped.
|
|
||||||
const m0 = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" }); // epoch 0->1, attaching
|
|
||||||
expect(m0.ctx.epoch).toBe(1);
|
|
||||||
expect(m0.phase.name).toBe("attaching");
|
|
||||||
// A late ATTACH_LIVE from a SUPERSEDED attempt (epoch 0) must NOT drive us.
|
|
||||||
const stale = reduce(m0, { type: "ATTACH_LIVE", epoch: 0 });
|
|
||||||
expect(stale.phase.name).toBe("attaching");
|
|
||||||
expect(stale.effects).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies an outcome carrying the current epoch", () => {
|
|
||||||
const m0 = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
|
||||||
const live = reduce(m0, { type: "ATTACH_LIVE", epoch: m0.ctx.epoch });
|
|
||||||
expect(live.phase.name).toBe("streaming");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("an outcome with no epoch is never dropped (trigger events)", () => {
|
|
||||||
const m0 = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
|
||||||
const disposed = reduce(m0, { type: "DISPOSE" });
|
|
||||||
expect(disposed.phase.name).toBe("idle");
|
|
||||||
expect(hasEffect(disposed, "abortAttach")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("every command-transition increments the epoch exactly once", () => {
|
|
||||||
let m = initialMachine();
|
|
||||||
const before = m.ctx.epoch;
|
|
||||||
m = reduce(m, { type: "SEND_LOCAL" });
|
|
||||||
expect(m.ctx.epoch).toBe(before + 1);
|
|
||||||
m = reduce(m, { type: "STOP_REQUESTED" });
|
|
||||||
expect(m.ctx.epoch).toBe(before + 2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("run-fsm — local turn", () => {
|
|
||||||
it("SEND_LOCAL → sending, local ownership, cancels recovery", () => {
|
|
||||||
const m = reduce(withRunFact(), { type: "SEND_LOCAL" });
|
|
||||||
expect(m.phase.name).toBe("sending");
|
|
||||||
expect(m.ctx.ownership).toBe("local");
|
|
||||||
expect(effectTypes(m)).toEqual(
|
|
||||||
expect.arrayContaining(["cancelReconnect", "disarmPoll"]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("STREAM_START adopts the runId into the run-fact and goes streaming", () => {
|
|
||||||
const m = run(initialMachine(), { type: "SEND_LOCAL" });
|
|
||||||
const s = reduce(m, { type: "STREAM_START", runId: "run-9", epoch: m.ctx.epoch });
|
|
||||||
expect(s.phase.name).toBe("streaming");
|
|
||||||
expect(s.ctx.runFact).toEqual({ runId: "run-9" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("FINISH_CLEAN → idle, run-fact cleared, poll/reconnect disarmed", () => {
|
|
||||||
const streaming = run(initialMachine(), { type: "SEND_LOCAL" }, { type: "STREAM_START", runId: "r" });
|
|
||||||
const done = reduce(streaming, { type: "FINISH_CLEAN" });
|
|
||||||
expect(done.phase.name).toBe("idle");
|
|
||||||
expect(done.ctx.runFact).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// #488 commit 2 — SSE break BEFORE the first assistant frame must still recover.
|
|
||||||
describe("run-fsm — commit 2: reconnect by run-fact, not by assistant message", () => {
|
|
||||||
it("FINISH_DISCONNECT with an active run-fact → reconnecting (even with no visible content)", () => {
|
|
||||||
// Setup-phase break: no assistant frame yet, but a run-fact exists.
|
|
||||||
const streaming = withRunFact("run-2");
|
|
||||||
const m = reduce(streaming, {
|
|
||||||
type: "FINISH_DISCONNECT",
|
|
||||||
hasVisibleContent: false,
|
|
||||||
epoch: streaming.ctx.epoch,
|
|
||||||
});
|
|
||||||
expect(m.phase.name).toBe("reconnecting");
|
|
||||||
if (m.phase.name === "reconnecting") expect(m.phase.attempt).toBe(1);
|
|
||||||
expect(m.ctx.ownership).toBe("observer");
|
|
||||||
expect(hasEffect(m, "scheduleReconnect")).toBe(true);
|
|
||||||
// No visible content -> no poll arm yet (the reconnect ladder rebuilds it).
|
|
||||||
expect(hasEffect(m, "armPoll")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("FINISH_DISCONNECT WITH visible content also arms the poll", () => {
|
|
||||||
const m = reduce(withRunFact("run-2"), {
|
|
||||||
type: "FINISH_DISCONNECT",
|
|
||||||
hasVisibleContent: true,
|
|
||||||
epoch: 0,
|
|
||||||
});
|
|
||||||
expect(m.phase.name).toBe("reconnecting");
|
|
||||||
expect(hasEffect(m, "armPoll")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("FINISH_DISCONNECT with NO run-fact → idle (plain connection-lost)", () => {
|
|
||||||
const m = reduce(initialMachine(), {
|
|
||||||
type: "FINISH_DISCONNECT",
|
|
||||||
hasVisibleContent: true,
|
|
||||||
epoch: 0,
|
|
||||||
});
|
|
||||||
expect(m.phase.name).toBe("idle");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// #488 commit 3 — a SECOND break after a successful re-attach starts a NEW ladder.
|
|
||||||
describe("run-fsm — commit 3: repeated reconnect cycles", () => {
|
|
||||||
it("two breaks in a row produce two reconnect cycles (counter resets on attach)", () => {
|
|
||||||
let m = withRunFact("run-3");
|
|
||||||
// First break -> reconnecting(1).
|
|
||||||
m = reduce(m, { type: "FINISH_DISCONNECT", hasVisibleContent: false, epoch: m.ctx.epoch });
|
|
||||||
expect(m.phase.name).toBe("reconnecting");
|
|
||||||
// Attempt fires, re-attaches live.
|
|
||||||
m = reduce(m, { type: "RECONNECT_ATTEMPT", attempt: 1, epoch: m.ctx.epoch });
|
|
||||||
m = reduce(m, { type: "RECONNECT_ATTACHED", epoch: m.ctx.epoch });
|
|
||||||
expect(m.phase.name).toBe("streaming");
|
|
||||||
// SECOND break: the counter was reset, so a fresh ladder starts at attempt 1
|
|
||||||
// (the old one-shot !wasResumed gate would have sent this to silent poll).
|
|
||||||
m = reduce(m, { type: "FINISH_DISCONNECT", hasVisibleContent: false, epoch: m.ctx.epoch });
|
|
||||||
expect(m.phase.name).toBe("reconnecting");
|
|
||||||
if (m.phase.name === "reconnecting") expect(m.phase.attempt).toBe(1);
|
|
||||||
expect(hasEffect(m, "scheduleReconnect")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a MOUNT-attach observer drop falls to POLL, not the reconnect ladder", () => {
|
|
||||||
// Distinguishes commit 3 from a one-shot resume: an observer that never
|
|
||||||
// live-followed (liveFollow false) polls on a drop.
|
|
||||||
let m = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
|
||||||
m = reduce(m, { type: "ATTACH_LIVE", epoch: m.ctx.epoch });
|
|
||||||
expect(m.ctx.ownership).toBe("observer");
|
|
||||||
expect(m.ctx.liveFollow).toBe(false);
|
|
||||||
m = reduce(m, { type: "FINISH_DISCONNECT", hasVisibleContent: true, epoch: m.ctx.epoch });
|
|
||||||
expect(m.phase.name).toBe("polling");
|
|
||||||
expect(hasEffect(m, "armPoll")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("STREAM_INCOMPLETE (observer starved/torn finish) → polling", () => {
|
|
||||||
let m = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
|
||||||
m = reduce(m, { type: "ATTACH_LIVE", epoch: m.ctx.epoch });
|
|
||||||
m = reduce(m, { type: "STREAM_INCOMPLETE", reason: "starved", epoch: m.ctx.epoch });
|
|
||||||
expect(m.phase).toEqual({ name: "polling", reason: "starved" });
|
|
||||||
expect(hasEffect(m, "armPoll")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("liveFollow is set on the first local drop and kept across a re-attach", () => {
|
|
||||||
let m = withRunFact("run-3");
|
|
||||||
m = reduce(m, { type: "FINISH_DISCONNECT", hasVisibleContent: false, epoch: m.ctx.epoch });
|
|
||||||
expect(m.ctx.liveFollow).toBe(true);
|
|
||||||
m = reduce(m, { type: "RECONNECT_ATTEMPT", attempt: 1, epoch: m.ctx.epoch });
|
|
||||||
m = reduce(m, { type: "RECONNECT_ATTACHED", epoch: m.ctx.epoch });
|
|
||||||
expect(m.ctx.liveFollow).toBe(true); // kept — so a second drop reconnects
|
|
||||||
// A clean finish clears it.
|
|
||||||
m = reduce(m, { type: "FINISH_CLEAN", epoch: m.ctx.epoch });
|
|
||||||
expect(m.ctx.liveFollow).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("RECONNECT_NONE backs off through the ladder, then fails at the cap", () => {
|
|
||||||
let m = withRunFact("run-3");
|
|
||||||
m = reduce(m, { type: "FINISH_DISCONNECT", hasVisibleContent: false, epoch: m.ctx.epoch });
|
|
||||||
for (let n = 1; n < RECONNECT_MAX_ATTEMPTS; n++) {
|
|
||||||
m = reduce(m, { type: "RECONNECT_ATTEMPT", attempt: n, epoch: m.ctx.epoch });
|
|
||||||
m = reduce(m, { type: "RECONNECT_NONE", epoch: m.ctx.epoch });
|
|
||||||
expect(m.phase.name).toBe("reconnecting");
|
|
||||||
if (m.phase.name === "reconnecting") {
|
|
||||||
expect(m.phase.attempt).toBe(n + 1);
|
|
||||||
expect(m.phase.failed).toBe(false);
|
|
||||||
}
|
|
||||||
// The belt-and-suspenders poll is armed each failed attempt.
|
|
||||||
expect(hasEffect(m, "armPoll")).toBe(true);
|
|
||||||
}
|
|
||||||
// Final attempt fails -> failed banner (Retry), poll armed.
|
|
||||||
m = reduce(m, { type: "RECONNECT_ATTEMPT", attempt: RECONNECT_MAX_ATTEMPTS, epoch: m.ctx.epoch });
|
|
||||||
m = reduce(m, { type: "RECONNECT_NONE", epoch: m.ctx.epoch });
|
|
||||||
expect(m.phase.name).toBe("reconnecting");
|
|
||||||
if (m.phase.name === "reconnecting") expect(m.phase.failed).toBe(true);
|
|
||||||
// RETRY restarts at attempt 1.
|
|
||||||
m = reduce(m, { type: "RETRY" });
|
|
||||||
expect(m.phase.name).toBe("reconnecting");
|
|
||||||
if (m.phase.name === "reconnecting") {
|
|
||||||
expect(m.phase.attempt).toBe(1);
|
|
||||||
expect(m.phase.failed).toBe(false);
|
|
||||||
}
|
|
||||||
expect(hasEffect(m, "resumeStream")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reconnectDelayMs is the exponential backoff 1s,2s,4s,8s,16s", () => {
|
|
||||||
expect([1, 2, 3, 4, 5].map(reconnectDelayMs)).toEqual([1000, 2000, 4000, 8000, 16000]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// #488 commit 4 — polling stalled-state + user-tail gating.
|
|
||||||
describe("run-fsm — commit 4: stalled + run-fact gating", () => {
|
|
||||||
it("POLL_IDLE_CAP: polling → stalled with a banner (poll disarmed), not silent", () => {
|
|
||||||
let m = reduce(withRunFact(), { type: "ATTACH_START", runId: "r" });
|
|
||||||
m = reduce(m, { type: "ATTACH_NONE", epoch: m.ctx.epoch });
|
|
||||||
expect(m.phase.name).toBe("polling");
|
|
||||||
m = reduce(m, { type: "POLL_IDLE_CAP" });
|
|
||||||
expect(m.phase.name).toBe("stalled");
|
|
||||||
expect(hasEffect(m, "disarmPoll")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("RETRY from stalled re-arms the poll", () => {
|
|
||||||
let m = reduce(withRunFact(), { type: "ATTACH_START", runId: "r" });
|
|
||||||
m = reduce(m, { type: "ATTACH_NONE", epoch: m.ctx.epoch });
|
|
||||||
m = reduce(m, { type: "POLL_IDLE_CAP" });
|
|
||||||
m = reduce(m, { type: "RETRY" });
|
|
||||||
expect(m.phase.name).toBe("polling");
|
|
||||||
expect(hasEffect(m, "armPoll")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a fresh NEGATIVE run-fact while attaching cancels recovery (user-tail, no active run)", () => {
|
|
||||||
// The mount POST /run returns no active run: attaching → idle, no poll armed.
|
|
||||||
let m = reduce(withRunFact(), { type: "ATTACH_START", runId: "r" });
|
|
||||||
m = reduce(m, { type: "RUN_FACT", runFact: null, epoch: m.ctx.epoch });
|
|
||||||
expect(m.phase.name).toBe("idle");
|
|
||||||
expect(m.ctx.runFact).toBeNull();
|
|
||||||
expect(hasEffect(m, "disarmPoll")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a negative run-fact while polling stops the poll", () => {
|
|
||||||
let m = reduce(withRunFact(), { type: "ATTACH_START", runId: "r" });
|
|
||||||
m = reduce(m, { type: "ATTACH_NONE", epoch: m.ctx.epoch });
|
|
||||||
m = reduce(m, { type: "RUN_FACT", runFact: null, epoch: m.ctx.epoch });
|
|
||||||
expect(m.phase.name).toBe("idle");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("POLL_TERMINAL settles polling → idle (I4 data-driven exit)", () => {
|
|
||||||
let m = reduce(withRunFact(), { type: "ATTACH_START", runId: "r" });
|
|
||||||
m = reduce(m, { type: "ATTACH_NONE", epoch: m.ctx.epoch });
|
|
||||||
m = reduce(m, { type: "POLL_TERMINAL" });
|
|
||||||
expect(m.phase.name).toBe("idle");
|
|
||||||
expect(m.ctx.runFact).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// #488 commit 5 — error classification + supersede CAS transitions.
|
|
||||||
describe("run-fsm — commit 5: supersede CAS + error classification", () => {
|
|
||||||
it("SUPERSEDE_REQUESTED → superseding, fires the CAS effect, bumps epoch", () => {
|
|
||||||
const streaming = withRunFact("run-old");
|
|
||||||
const m = reduce(streaming, { type: "SUPERSEDE_REQUESTED", targetRunId: "run-old" });
|
|
||||||
expect(m.phase.name).toBe("superseding");
|
|
||||||
expect(m.ctx.epoch).toBe(streaming.ctx.epoch + 1);
|
|
||||||
const sup = m.effects.find((e) => e.type === "supersede");
|
|
||||||
expect(sup).toEqual({ type: "supersede", targetRunId: "run-old" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("SUPERSEDE_READY → streaming as the new local owner", () => {
|
|
||||||
let m = reduce(withRunFact("run-old"), { type: "SUPERSEDE_REQUESTED", targetRunId: "run-old" });
|
|
||||||
m = reduce(m, { type: "SUPERSEDE_READY", runId: "run-new", epoch: m.ctx.epoch });
|
|
||||||
expect(m.phase.name).toBe("streaming");
|
|
||||||
expect(m.ctx.ownership).toBe("local");
|
|
||||||
expect(m.ctx.runFact).toEqual({ runId: "run-new" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("SUPERSEDE_MISMATCH → error(supersede-mismatch) + verify via /run (no blind banner)", () => {
|
|
||||||
let m = reduce(withRunFact("run-old"), { type: "SUPERSEDE_REQUESTED", targetRunId: "run-old" });
|
|
||||||
m = reduce(m, { type: "SUPERSEDE_MISMATCH", currentRunId: "run-x", epoch: m.ctx.epoch });
|
|
||||||
expect(m.phase).toEqual({ name: "error", kind: "supersede-mismatch" });
|
|
||||||
expect(hasEffect(m, "postRun")).toBe(true);
|
|
||||||
expect(m.ctx.runFact).toEqual({ runId: "run-x" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("SUPERSEDE_TIMEOUT → error(supersede-timeout), no auto-retry effect", () => {
|
|
||||||
let m = reduce(withRunFact("run-old"), { type: "SUPERSEDE_REQUESTED", targetRunId: "run-old" });
|
|
||||||
m = reduce(m, { type: "SUPERSEDE_TIMEOUT", epoch: m.ctx.epoch });
|
|
||||||
expect(m.phase).toEqual({ name: "error", kind: "supersede-timeout" });
|
|
||||||
expect(m.effects).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("SUPERSEDE_INVALID → error(supersede-invalid)", () => {
|
|
||||||
let m = reduce(withRunFact("run-old"), { type: "SUPERSEDE_REQUESTED", targetRunId: "run-old" });
|
|
||||||
m = reduce(m, { type: "SUPERSEDE_INVALID", epoch: m.ctx.epoch });
|
|
||||||
expect(m.phase).toEqual({ name: "error", kind: "supersede-invalid" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a stale SUPERSEDE outcome from a superseded epoch is dropped", () => {
|
|
||||||
let m = reduce(withRunFact("run-old"), { type: "SUPERSEDE_REQUESTED", targetRunId: "run-old" });
|
|
||||||
const supersedingEpoch = m.ctx.epoch;
|
|
||||||
// The user retriggers, bumping the epoch again.
|
|
||||||
m = reduce(m, { type: "SUPERSEDE_REQUESTED", targetRunId: "run-old" });
|
|
||||||
// The first CAS's late TIMEOUT (old epoch) must NOT knock us out of superseding.
|
|
||||||
const late = reduce(m, { type: "SUPERSEDE_TIMEOUT", epoch: supersedingEpoch });
|
|
||||||
expect(late.phase.name).toBe("superseding");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("RUN_ALREADY_ACTIVE (plain POST gate) → error(run-already-active), no retry effect", () => {
|
|
||||||
const m = reduce(run(initialMachine(), { type: "SEND_LOCAL" }), { type: "RUN_ALREADY_ACTIVE" });
|
|
||||||
expect(m.phase).toEqual({ name: "error", kind: "run-already-active" });
|
|
||||||
expect(m.effects).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("#497/S4: RUN_ALREADY_ACTIVE{activeRunId} ADOPTS the server's active run as the run-fact", () => {
|
|
||||||
// The server sends `activeRunId` so a later supersede can TARGET that run
|
|
||||||
// instead of a blind promote+abort. Absorb it into runFact.
|
|
||||||
const m = reduce(run(initialMachine(), { type: "SEND_LOCAL" }), {
|
|
||||||
type: "RUN_ALREADY_ACTIVE",
|
|
||||||
activeRunId: "run-foreign",
|
|
||||||
});
|
|
||||||
expect(m.phase).toEqual({ name: "error", kind: "run-already-active" });
|
|
||||||
expect(m.ctx.runFact).toEqual({ runId: "run-foreign" });
|
|
||||||
expect(m.effects).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("#497/S4: RUN_ALREADY_ACTIVE without an activeRunId keeps the prior run-fact", () => {
|
|
||||||
const seeded = reduce(run(initialMachine(), { type: "SEND_LOCAL" }), {
|
|
||||||
type: "RUN_FACT",
|
|
||||||
runFact: { runId: "run-prior" },
|
|
||||||
});
|
|
||||||
const m = reduce(seeded, { type: "RUN_ALREADY_ACTIVE" });
|
|
||||||
expect(m.ctx.runFact).toEqual({ runId: "run-prior" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// #488 F2 — a late mount `getRun → ATTACH_START` must not hijack a local turn.
|
|
||||||
describe("run-fsm — F2: ATTACH_START only from idle", () => {
|
|
||||||
it("ATTACH_START from a local `sending` turn is ignored (no observer hijack)", () => {
|
|
||||||
const sending = reduce(initialMachine(), { type: "SEND_LOCAL" }); // idle -> sending, local
|
|
||||||
const m = reduce(sending, { type: "ATTACH_START", runId: "r" });
|
|
||||||
expect(m.phase.name).toBe("sending");
|
|
||||||
expect(m.ctx.ownership).toBe("local"); // NOT flipped to observer
|
|
||||||
expect(m.effects).toEqual([]); // no resumeStream
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ATTACH_START from idle attaches as normal", () => {
|
|
||||||
const m = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
|
||||||
expect(m.phase.name).toBe("attaching");
|
|
||||||
expect(m.ctx.ownership).toBe("observer");
|
|
||||||
expect(hasEffect(m, "resumeStream")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("run-fsm — stop (I4: exit by data)", () => {
|
|
||||||
it("STOP_REQUESTED → stopping, fires stopRun + abortAttach, no data-independent exit", () => {
|
|
||||||
const m = reduce(withRunFact(), { type: "STOP_REQUESTED" });
|
|
||||||
expect(m.phase.name).toBe("stopping");
|
|
||||||
expect(effectTypes(m)).toEqual(expect.arrayContaining(["stopRun", "abortAttach"]));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stopping exits on the aborted stream's finish carrying the PRE-STOP epoch", () => {
|
|
||||||
// MEDIUM (#488 re-review): STOP_REQUESTED is a command that BUMPS the epoch, but
|
|
||||||
// the runtime stamps the aborted stream's onFinish with the stream's START (pre-
|
|
||||||
// stop) generation — exactly what the component sends. `stopping` must HONOR
|
|
||||||
// that finish regardless of generation (no idle-cap covers `stopping`).
|
|
||||||
// MUTATION-VERIFY: remove the honor-in-`stopping` branch and this hangs in
|
|
||||||
// `stopping` (the epoch filter drops the pre-stop finish) -> red.
|
|
||||||
const preStopEpoch = withRunFact().ctx.epoch; // E1 (the stream's start epoch)
|
|
||||||
let m = reduce(withRunFact(), { type: "STOP_REQUESTED" }); // E1 -> E2, stopping
|
|
||||||
expect(m.ctx.epoch).toBe(preStopEpoch + 1);
|
|
||||||
m = reduce(m, { type: "FINISH_ABORT", epoch: preStopEpoch }); // NOT the current epoch
|
|
||||||
expect(m.phase.name).toBe("idle");
|
|
||||||
expect(m.ctx.runFact).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stopping exits on a clean finish carrying the pre-stop epoch too", () => {
|
|
||||||
const preStopEpoch = withRunFact().ctx.epoch;
|
|
||||||
let m = reduce(withRunFact(), { type: "STOP_REQUESTED" });
|
|
||||||
m = reduce(m, { type: "FINISH_CLEAN", epoch: preStopEpoch });
|
|
||||||
expect(m.phase.name).toBe("idle");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stopping exits on a negative run-fact (data)", () => {
|
|
||||||
let m = reduce(withRunFact(), { type: "STOP_REQUESTED" });
|
|
||||||
m = reduce(m, { type: "RUN_FACT", runFact: null, epoch: m.ctx.epoch });
|
|
||||||
expect(m.phase.name).toBe("idle");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Review #4: `stopping` arms the poll but had no inactivity backstop.
|
|
||||||
it("review-4: POLL_IDLE_CAP in `stopping` exits to idle (bounded), NOT stalled", () => {
|
|
||||||
let m = reduce(withRunFact(), { type: "STOP_REQUESTED" });
|
|
||||||
expect(m.phase.name).toBe("stopping");
|
|
||||||
expect(hasEffect(m, "armPoll")).toBe(true);
|
|
||||||
// MUTATION-VERIFY: drop the `stopping` branch in POLL_IDLE_CAP and this hangs
|
|
||||||
// in `stopping` (poll forever) -> red.
|
|
||||||
m = reduce(m, { type: "POLL_IDLE_CAP" });
|
|
||||||
expect(m.phase.name).toBe("idle");
|
|
||||||
expect(hasEffect(m, "disarmPoll")).toBe(true);
|
|
||||||
expect(m.ctx.ownership).toBe("local");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Review #1: positive attach outcomes must be guarded by the SOURCE phase — the
|
|
||||||
// epoch filter alone is insufficient because POLL_TERMINAL uses to() (no epoch
|
|
||||||
// bump) and does not abort the in-flight GET.
|
|
||||||
describe("run-fsm — review-1: attach outcomes guarded by source phase", () => {
|
|
||||||
it("a late RECONNECT_ATTACHED after POLL_TERMINAL stays idle (no phantom streaming)", () => {
|
|
||||||
let m = withRunFact("run-1");
|
|
||||||
m = reduce(m, { type: "FINISH_DISCONNECT", hasVisibleContent: true, epoch: m.ctx.epoch });
|
|
||||||
m = reduce(m, { type: "RECONNECT_ATTEMPT", attempt: 1, epoch: m.ctx.epoch }); // attach GET
|
|
||||||
const epoch = m.ctx.epoch;
|
|
||||||
// The armed degraded poll reaches the terminal row FIRST (epoch unchanged).
|
|
||||||
m = reduce(m, { type: "POLL_TERMINAL" });
|
|
||||||
expect(m.phase.name).toBe("idle");
|
|
||||||
expect(m.ctx.epoch).toBe(epoch); // POLL_TERMINAL did NOT bump the epoch
|
|
||||||
// The slow GET returns live 2xx under the SAME epoch — must NOT resurrect.
|
|
||||||
m = reduce(m, { type: "RECONNECT_ATTACHED", epoch });
|
|
||||||
expect(m.phase.name).toBe("idle");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a late ATTACH_LIVE / ATTACH_NONE after leaving `attaching` is ignored", () => {
|
|
||||||
let m = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
|
||||||
const epoch = m.ctx.epoch;
|
|
||||||
m = reduce(m, { type: "ATTACH_NONE", epoch }); // attaching -> polling
|
|
||||||
m = reduce(m, { type: "POLL_TERMINAL" }); // -> idle (epoch unchanged)
|
|
||||||
expect(m.phase.name).toBe("idle");
|
|
||||||
m = reduce(m, { type: "ATTACH_LIVE", epoch }); // late 2xx, same epoch
|
|
||||||
expect(m.phase.name).toBe("idle");
|
|
||||||
// And a late ATTACH_NONE (not `attaching`) is a no-op too.
|
|
||||||
m = reduce(m, { type: "ATTACH_NONE", epoch });
|
|
||||||
expect(m.phase.name).toBe("idle");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Review #2: every terminal transition resets ownership to local.
|
|
||||||
describe("run-fsm — review-2: terminal transitions reset ownership to local", () => {
|
|
||||||
const observer = (): Machine => {
|
|
||||||
let m = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
|
||||||
m = reduce(m, { type: "ATTACH_LIVE", epoch: m.ctx.epoch });
|
|
||||||
expect(m.ctx.ownership).toBe("observer");
|
|
||||||
return m;
|
|
||||||
};
|
|
||||||
it("FINISH_CLEAN resets ownership", () => {
|
|
||||||
const m = reduce(observer(), { type: "FINISH_CLEAN", epoch: observer().ctx.epoch });
|
|
||||||
expect(m.ctx.ownership).toBe("local");
|
|
||||||
});
|
|
||||||
it("FINISH_ERROR / POLL_TERMINAL / RUN_FACT(null) reset ownership", () => {
|
|
||||||
let o = observer();
|
|
||||||
expect(reduce(o, { type: "FINISH_ERROR", kind: "stream", epoch: o.ctx.epoch }).ctx.ownership).toBe("local");
|
|
||||||
// POLL_TERMINAL from an observer polling phase
|
|
||||||
let p = reduce(observer(), { type: "STREAM_INCOMPLETE", reason: "starved", epoch: observer().ctx.epoch });
|
|
||||||
expect(reduce(p, { type: "POLL_TERMINAL" }).ctx.ownership).toBe("local");
|
|
||||||
// RUN_FACT(null) from an observer attaching phase
|
|
||||||
let a = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
|
||||||
expect(reduce(a, { type: "RUN_FACT", runFact: null, epoch: a.ctx.epoch }).ctx.ownership).toBe("local");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("run-fsm — ownership (I2) is context, orthogonal to phase", () => {
|
|
||||||
it("attach/reconnect set observer; send/supersede-ready set local", () => {
|
|
||||||
let m = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
|
||||||
expect(m.ctx.ownership).toBe("observer");
|
|
||||||
m = reduce(m, { type: "ATTACH_LIVE", epoch: m.ctx.epoch });
|
|
||||||
expect(m.phase.name).toBe("streaming");
|
|
||||||
expect(m.ctx.ownership).toBe("observer"); // still observing a detached run
|
|
||||||
// A local send flips ownership back to local.
|
|
||||||
m = reduce(m, { type: "SEND_LOCAL" });
|
|
||||||
expect(m.ctx.ownership).toBe("local");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("run-fsm — dispose (I5)", () => {
|
|
||||||
it("DISPOSE from any phase aborts controllers and bumps epoch", () => {
|
|
||||||
let m = reduce(withRunFact(), { type: "ATTACH_START", runId: "r" });
|
|
||||||
const before = m.ctx.epoch;
|
|
||||||
m = reduce(m, { type: "DISPOSE" });
|
|
||||||
expect(m.phase.name).toBe("idle");
|
|
||||||
expect(m.ctx.epoch).toBe(before + 1);
|
|
||||||
expect(effectTypes(m)).toEqual(
|
|
||||||
expect.arrayContaining(["abortAttach", "cancelReconnect", "disarmPoll"]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,600 +0,0 @@
|
|||||||
/**
|
|
||||||
* Run-lifecycle finite state machine for a single AI-chat thread (#488).
|
|
||||||
*
|
|
||||||
* ============================================================================
|
|
||||||
* WHY THIS EXISTS
|
|
||||||
* ----------------------------------------------------------------------------
|
|
||||||
* The resume/reconnect/poll/stop/supersede lifecycle used to be spread across
|
|
||||||
* ~26 `useRef` one-shot flags in `chat-thread.tsx`, each disarmed "on every
|
|
||||||
* path". Ownerless flag combinations produced silent UI freezes, and every fix
|
|
||||||
* added another ref (the #381 -> #432 -> #456 spiral). This module replaces that
|
|
||||||
* ref-zoo with ONE pure reducer whose transitions are enumerable and unit-
|
|
||||||
* testable in isolation (event x state -> next state is the observable property).
|
|
||||||
*
|
|
||||||
* The reducer is PURE: it owns no timers, no fetches, no React state. It maps
|
|
||||||
* `(machine, event) -> machine`, where the returned machine carries the list of
|
|
||||||
* COMMAND EFFECTS to run for that transition. A thin runtime in `chat-thread.tsx`
|
|
||||||
* dispatches events (from SDK callbacks / HTTP outcomes) and executes the
|
|
||||||
* effects (attach GET, POST /stream, POST /run, POST /stop, backoff timers,
|
|
||||||
* poll arm/disarm). The runtime lives in a THREAD, not the window, so a late SDK
|
|
||||||
* callback dies with the owner (kills the "event from a dead view" class, #161).
|
|
||||||
*
|
|
||||||
* ============================================================================
|
|
||||||
* INVARIANTS (see run-fsm.spec.md for the full spec + tables)
|
|
||||||
* ----------------------------------------------------------------------------
|
|
||||||
* I1 EPOCH (generation counter). Commands (`resumeStream`, `postRun`, `stop`,
|
|
||||||
* `supersede`, `scheduleReconnect`) are async; their outcomes arrive on the
|
|
||||||
* SAME SDK/HTTP callbacks. Every command-emitting transition increments
|
|
||||||
* `ctx.epoch`; every OUTCOME event carries the epoch it was issued under;
|
|
||||||
* the reducer DROPS an outcome whose epoch != the current epoch. This is
|
|
||||||
* what the one-shot-ref zoo used to approximate by hand.
|
|
||||||
* I2 OWNERSHIP is a CONTEXT FIELD (`'local' | 'observer'`), not a state —
|
|
||||||
* orthogonal to the transport phase. The queue is flushed ONLY by a local
|
|
||||||
* owner (an observer following a detached run never flushes).
|
|
||||||
* I3 RUN-FACT ("a run is active") is first-class from the server: `runFact`
|
|
||||||
* holds the server-confirmed active run id (POST /run on mount, the `start`
|
|
||||||
* metadata runId, attach outcomes). Reconnect is entered by the RUN-FACT,
|
|
||||||
* not by the presence of an assistant message (#488 commit 2). A fresh
|
|
||||||
* negative fact (null) cancels reconnect immediately.
|
|
||||||
* I4 Exit `stopping` by DATA (a terminal row / negative run-fact), NEVER by the
|
|
||||||
* stopRun HTTP response (which returns after abort, before finalization).
|
|
||||||
* I5 Command controllers are effect-owned (abort in cleanup), NOT render-phase
|
|
||||||
* refs — expressed here as the `abortAttach` effect on disposing transitions.
|
|
||||||
* ============================================================================
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Phases (the transport lifecycle). Ownership / runFact are CONTEXT, not here.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Why the degraded poll is the active recovery. */
|
|
||||||
export type PollReason =
|
|
||||||
| "attach-none" // mount attach returned 204 / error — nothing live to attach
|
|
||||||
| "starved" // a resumed finish carried no visible content
|
|
||||||
| "disconnect-visible" // a live disconnect WITH on-screen content — poll to terminal
|
|
||||||
| "reconnect-exhausted"; // the live re-attach ladder gave up
|
|
||||||
|
|
||||||
/** The classified error kind (drives the banner text + composer behavior). */
|
|
||||||
export type ErrorKind =
|
|
||||||
| "stream" // a generic provider/network stream error (useChat error)
|
|
||||||
| "run-already-active" // 409 A_RUN_ALREADY_ACTIVE (a plain POST hit the gate)
|
|
||||||
| "supersede-mismatch" // 409 SUPERSEDE_TARGET_MISMATCH (CAS target moved)
|
|
||||||
| "supersede-timeout" // 409 SUPERSEDE_TIMEOUT (old run did not settle in W)
|
|
||||||
| "supersede-invalid" // 409 SUPERSEDE_INVALID (bad supersede target)
|
|
||||||
| "begin-failed"; // 503 A_RUN_BEGIN_FAILED (could not start the run)
|
|
||||||
|
|
||||||
export type Phase =
|
|
||||||
| { name: "idle" }
|
|
||||||
| { name: "sending" } // local POST in flight, before the first frame
|
|
||||||
| { name: "streaming" } // receiving frames
|
|
||||||
| { name: "attaching" } // mount-time attach GET in flight
|
|
||||||
| { name: "reconnecting"; attempt: number; failed: boolean }
|
|
||||||
| { name: "polling"; reason: PollReason }
|
|
||||||
| { name: "stalled" } // poll hit the inactivity cap — banner + Retry
|
|
||||||
| { name: "stopping" }
|
|
||||||
| { name: "superseding" }
|
|
||||||
| { name: "error"; kind: ErrorKind };
|
|
||||||
|
|
||||||
export type Ownership = "local" | "observer";
|
|
||||||
|
|
||||||
/** The server-confirmed active run, or null when no run is active. */
|
|
||||||
export type RunFact = { runId: string } | null;
|
|
||||||
|
|
||||||
export interface Ctx {
|
|
||||||
/** I1: generation counter — every command-transition increments it. */
|
|
||||||
epoch: number;
|
|
||||||
/** I2: does THIS client own the turn's writes (local streamer) or observe? */
|
|
||||||
ownership: Ownership;
|
|
||||||
/** I3: the server-confirmed active run. */
|
|
||||||
runFact: RunFact;
|
|
||||||
/**
|
|
||||||
* Are we FOLLOWING a live run we were locally streaming (the reconnect ladder),
|
|
||||||
* as opposed to a one-shot mount-attach resume? Both are `ownership: 'observer'`,
|
|
||||||
* but they recover DIFFERENTLY on a drop: a live-follow drop RE-ENTERS the
|
|
||||||
* reconnect ladder (#488 commit 3 — the second break after a successful re-attach
|
|
||||||
* must reconnect again, not fall to silent poll), while a mount-resume drop falls
|
|
||||||
* to the degraded poll. This is the ctx bit that separates the two WITHOUT a new
|
|
||||||
* component ref (it is why commit 3 needs the FSM, not a surgical patch).
|
|
||||||
*/
|
|
||||||
liveFollow: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Machine {
|
|
||||||
phase: Phase;
|
|
||||||
ctx: Ctx;
|
|
||||||
/** Command effects to run for the transition that produced THIS machine.
|
|
||||||
* The runtime executes them and does not read them again. */
|
|
||||||
effects: Effect[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Command effects (the reducer's only side-channel — executed by the runtime).
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type Effect =
|
|
||||||
/** POST /run to (re)establish or verify the run-fact. `reason` is diagnostic. */
|
|
||||||
| { type: "postRun"; reason: "mount" | "verify" }
|
|
||||||
/** Trigger the SDK `resumeStream()` (attach GET via prepareReconnectToStream). */
|
|
||||||
| { type: "resumeStream" }
|
|
||||||
/** Schedule a reconnect attempt after a backoff, then dispatch RECONNECT_ATTEMPT. */
|
|
||||||
| { type: "scheduleReconnect"; attempt: number; delayMs: number }
|
|
||||||
/** Cancel any pending reconnect backoff timer. */
|
|
||||||
| { type: "cancelReconnect" }
|
|
||||||
/** Arm the degraded poll (the window's dumb timer follows the run in the DB). */
|
|
||||||
| { type: "armPoll"; reason: PollReason }
|
|
||||||
/** Disarm the degraded poll. */
|
|
||||||
| { type: "disarmPoll" }
|
|
||||||
/** POST /stop the chat's active run (authoritative detached-run stop). */
|
|
||||||
| { type: "stopRun" }
|
|
||||||
/** POST /stream { supersede: { runId } } — the CAS "interrupt and send now". */
|
|
||||||
| { type: "supersede"; targetRunId: string }
|
|
||||||
/** Abort the in-flight attach/reconnect GET controller (dispose / observer stop). */
|
|
||||||
| { type: "abortAttach" };
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Events. An OUTCOME event MAY carry `epoch`; if it does and it does not equal
|
|
||||||
// the current epoch, the reducer drops it (I1). Trigger events (user actions,
|
|
||||||
// fresh disconnects) carry no epoch and are never dropped.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type Event =
|
|
||||||
// -- local turn --
|
|
||||||
| { type: "SEND_LOCAL" }
|
|
||||||
| { type: "STREAM_START"; runId?: string; epoch?: number }
|
|
||||||
/** An OBSERVER's attached stream ended WITHOUT reaching terminal (a starved
|
|
||||||
* clean replay, or a torn resume) — fall to the degraded poll to drive the row
|
|
||||||
* to its real terminal state. (A live-follow drop uses FINISH_DISCONNECT.) */
|
|
||||||
| { type: "STREAM_INCOMPLETE"; reason: PollReason; epoch?: number }
|
|
||||||
| { type: "FINISH_CLEAN"; epoch?: number }
|
|
||||||
| { type: "FINISH_ABORT"; epoch?: number }
|
|
||||||
| { type: "FINISH_DISCONNECT"; hasVisibleContent: boolean; epoch?: number }
|
|
||||||
| { type: "FINISH_ERROR"; kind: ErrorKind; epoch?: number }
|
|
||||||
// -- mount attach (resume) --
|
|
||||||
| { type: "ATTACH_START"; runId?: string }
|
|
||||||
| { type: "ATTACH_LIVE"; epoch?: number }
|
|
||||||
| { type: "ATTACH_NONE"; epoch?: number }
|
|
||||||
// -- reconnect after a live disconnect (entered by FINISH_DISCONNECT, #488 c2) --
|
|
||||||
| { type: "RECONNECT_ATTEMPT"; attempt: number; epoch?: number }
|
|
||||||
| { type: "RECONNECT_ATTACHED"; epoch?: number }
|
|
||||||
| { type: "RECONNECT_NONE"; epoch?: number }
|
|
||||||
| { type: "RETRY" }
|
|
||||||
// -- degraded poll --
|
|
||||||
| { type: "POLL_TERMINAL" }
|
|
||||||
| { type: "POLL_IDLE_CAP" }
|
|
||||||
// -- run-fact (server-confirmed active run) --
|
|
||||||
| { type: "RUN_FACT"; runFact: RunFact; epoch?: number }
|
|
||||||
// -- stop --
|
|
||||||
| { type: "STOP_REQUESTED" }
|
|
||||||
// -- supersede (CAS) --
|
|
||||||
| { type: "SUPERSEDE_REQUESTED"; targetRunId: string }
|
|
||||||
| { type: "SUPERSEDE_READY"; runId?: string; epoch?: number }
|
|
||||||
| { type: "SUPERSEDE_MISMATCH"; currentRunId?: string; epoch?: number }
|
|
||||||
| { type: "SUPERSEDE_TIMEOUT"; epoch?: number }
|
|
||||||
| { type: "SUPERSEDE_INVALID"; epoch?: number }
|
|
||||||
| { type: "RUN_ALREADY_ACTIVE"; activeRunId?: string }
|
|
||||||
// -- lifecycle --
|
|
||||||
| { type: "DISPOSE" };
|
|
||||||
|
|
||||||
export const RECONNECT_MAX_ATTEMPTS = 5;
|
|
||||||
export const RECONNECT_BASE_DELAY_MS = 1000;
|
|
||||||
/** Backoff before attempt N (1-based): 1s, 2s, 4s, 8s, 16s. */
|
|
||||||
export function reconnectDelayMs(attempt: number): number {
|
|
||||||
return RECONNECT_BASE_DELAY_MS * 2 ** (attempt - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Constructors / helpers.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function initialMachine(overrides?: Partial<Ctx>): Machine {
|
|
||||||
return {
|
|
||||||
phase: { name: "idle" },
|
|
||||||
ctx: { epoch: 0, ownership: "local", runFact: null, liveFollow: false, ...overrides },
|
|
||||||
effects: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a machine result: a phase, optional ctx patch, and effects. Empty
|
|
||||||
* effects by default. Never mutates the input. */
|
|
||||||
function to(
|
|
||||||
m: Machine,
|
|
||||||
phase: Phase,
|
|
||||||
opts?: { ctx?: Partial<Ctx>; effects?: Effect[] },
|
|
||||||
): Machine {
|
|
||||||
return {
|
|
||||||
phase,
|
|
||||||
ctx: { ...m.ctx, ...(opts?.ctx ?? {}) },
|
|
||||||
effects: opts?.effects ?? [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** No transition: keep the phase, clear effects (so a re-run does not re-fire). */
|
|
||||||
function stay(m: Machine): Machine {
|
|
||||||
return { phase: m.phase, ctx: m.ctx, effects: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A command-transition: same as `to` but bumps the epoch (I1). Any outcome
|
|
||||||
* event issued under the old epoch is dropped once this lands. */
|
|
||||||
function command(
|
|
||||||
m: Machine,
|
|
||||||
phase: Phase,
|
|
||||||
effects: Effect[],
|
|
||||||
ctx?: Partial<Ctx>,
|
|
||||||
): Machine {
|
|
||||||
return {
|
|
||||||
phase,
|
|
||||||
ctx: { ...m.ctx, ...(ctx ?? {}), epoch: m.ctx.epoch + 1 },
|
|
||||||
effects,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// The pure reducer.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** The terminal stream-finish events (one turn's stream ended). */
|
|
||||||
function isFinishEvent(event: Event): boolean {
|
|
||||||
return (
|
|
||||||
event.type === "FINISH_ABORT" ||
|
|
||||||
event.type === "FINISH_CLEAN" ||
|
|
||||||
event.type === "FINISH_DISCONNECT" ||
|
|
||||||
event.type === "FINISH_ERROR" ||
|
|
||||||
event.type === "STREAM_INCOMPLETE"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reduce(m: Machine, event: Event): Machine {
|
|
||||||
// MEDIUM (#488 re-review): honor ANY stream finish in `stopping` regardless of
|
|
||||||
// generation. A plain user Stop has NO successor stream — the aborted stream's
|
|
||||||
// finish IS the expected end of the stop, so exit `stopping -> idle` by that DATA
|
|
||||||
// (I4). The epoch filter below must NOT drop it: STOP_REQUESTED bumped the epoch,
|
|
||||||
// but the finish carries the PRE-stop generation (the runtime stamps it with the
|
|
||||||
// stream's start epoch), so I1 would otherwise strand the machine in `stopping`
|
|
||||||
// forever (no idle-cap covers `stopping`). The epoch filter stays in force for
|
|
||||||
// `superseding` (a successor B owns) — that is the F1 supersede drop.
|
|
||||||
if (m.phase.name === "stopping" && isFinishEvent(event)) {
|
|
||||||
return to(m, { name: "idle" }, {
|
|
||||||
// Reset ownership to local on this terminal transition (review #2): otherwise
|
|
||||||
// an observer-stop leaves ownership 'observer' and hides "Send now" forever.
|
|
||||||
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
|
||||||
effects: [{ type: "disarmPoll" }, { type: "cancelReconnect" }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// I1: drop a stale outcome (an event issued under a superseded epoch).
|
|
||||||
if ("epoch" in event && event.epoch !== undefined && event.epoch !== m.ctx.epoch) {
|
|
||||||
return stay(m);
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (event.type) {
|
|
||||||
// ---- local turn ----------------------------------------------------
|
|
||||||
case "SEND_LOCAL":
|
|
||||||
// A local send owns the view: leave any recovery, become the local
|
|
||||||
// streamer, disarm poll/reconnect. epoch++ so a late recovery outcome
|
|
||||||
// from the previous phase is dropped.
|
|
||||||
return command(
|
|
||||||
m,
|
|
||||||
{ name: "sending" },
|
|
||||||
[{ type: "cancelReconnect" }, { type: "disarmPoll" }],
|
|
||||||
{ ownership: "local", liveFollow: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
case "STREAM_INCOMPLETE":
|
|
||||||
// An OBSERVER's attached stream ended incomplete (starved / torn) — follow
|
|
||||||
// the run to terminal via the degraded poll.
|
|
||||||
return to(m, { name: "polling", reason: event.reason }, {
|
|
||||||
effects: [{ type: "armPoll", reason: event.reason }],
|
|
||||||
});
|
|
||||||
|
|
||||||
case "STREAM_START": {
|
|
||||||
// First frame arrived. Adopt the run-fact runId if present. sending ->
|
|
||||||
// streaming; a reconnect/attach that just went live also lands here.
|
|
||||||
const runFact = event.runId ? { runId: event.runId } : m.ctx.runFact;
|
|
||||||
return to(m, { name: "streaming" }, {
|
|
||||||
ctx: { runFact },
|
|
||||||
effects: [{ type: "cancelReconnect" }, { type: "disarmPoll" }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
case "FINISH_CLEAN":
|
|
||||||
// A clean terminal outcome. The run is done — clear the run-fact and go
|
|
||||||
// idle. (The queue flush is a component concern gated by ownership; the
|
|
||||||
// FSM only models the phase.) Review #2: reset ownership to local so a
|
|
||||||
// just-finished observer-attach turn re-exposes "Send now" for the queue.
|
|
||||||
return to(m, { name: "idle" }, {
|
|
||||||
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
|
||||||
effects: [{ type: "disarmPoll" }, { type: "cancelReconnect" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
case "FINISH_ABORT":
|
|
||||||
// A user Stop / intentional abort finished. If we were stopping, the
|
|
||||||
// terminal data has now arrived (I4) — go idle. The run-fact is cleared.
|
|
||||||
return to(m, { name: "idle" }, {
|
|
||||||
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
|
||||||
effects: [{ type: "disarmPoll" }, { type: "cancelReconnect" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
case "FINISH_DISCONNECT":
|
|
||||||
// A LIVE SSE drop. Recovery depends on WHO we are (I2 + liveFollow):
|
|
||||||
// - a mount-attach OBSERVER (a one-shot resume, NOT live-follow) that drops
|
|
||||||
// -> the degraded poll drives the row to terminal from the DB.
|
|
||||||
if (m.ctx.ownership === "observer" && !m.ctx.liveFollow) {
|
|
||||||
return to(m, { name: "polling", reason: "disconnect-visible" }, {
|
|
||||||
effects: [{ type: "armPoll", reason: "disconnect-visible" }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// - a LOCAL live turn (first drop) OR a live-follow re-attach (a SUBSEQUENT
|
|
||||||
// drop) -> (re-)enter the reconnect ladder. #488 commit 3: allowed
|
|
||||||
// REPEATEDLY — `liveFollow` is kept across a successful re-attach, so the
|
|
||||||
// second break reconnects again instead of falling to silent poll.
|
|
||||||
// #488 commit 2: gated on the RUN-FACT (or an existing live-follow), NOT on
|
|
||||||
// the presence of an assistant message — a setup-phase break still recovers.
|
|
||||||
// - visible content already on screen -> keep it, ALSO poll to terminal
|
|
||||||
// (a full replay could clobber the fuller live tail);
|
|
||||||
// - no visible content -> the reconnect ladder rebuilds it.
|
|
||||||
if (m.ctx.runFact || m.ctx.liveFollow) {
|
|
||||||
const effects: Effect[] = [
|
|
||||||
{ type: "scheduleReconnect", attempt: 1, delayMs: reconnectDelayMs(1) },
|
|
||||||
];
|
|
||||||
if (event.hasVisibleContent) effects.push({ type: "armPoll", reason: "disconnect-visible" });
|
|
||||||
return command(m, { name: "reconnecting", attempt: 1, failed: false }, effects, {
|
|
||||||
ownership: "observer",
|
|
||||||
liveFollow: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// No run to recover: a plain disconnect. Surface the terminal notice.
|
|
||||||
return to(m, { name: "idle" }, {
|
|
||||||
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
|
||||||
});
|
|
||||||
|
|
||||||
case "FINISH_ERROR":
|
|
||||||
return to(m, { name: "error", kind: event.kind }, {
|
|
||||||
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
|
||||||
effects: [{ type: "disarmPoll" }, { type: "cancelReconnect" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- mount attach (resume) ----------------------------------------
|
|
||||||
case "ATTACH_START":
|
|
||||||
// A reopened tab attaches to a still-running run: observer ownership.
|
|
||||||
// #488 F2: ONLY from idle. The mount `getRun` round-trip resolves async, and
|
|
||||||
// a local send may have started meanwhile (phase `sending`, ownership local);
|
|
||||||
// a late ATTACH_START must NOT hijack that local turn into an observer-attach
|
|
||||||
// (queue would stop flushing, "Send now" would hide). Guarding in the reducer
|
|
||||||
// covers every dispatch source.
|
|
||||||
if (m.phase.name !== "idle") return stay(m);
|
|
||||||
return command(m, { name: "attaching" }, [{ type: "resumeStream" }], {
|
|
||||||
ownership: "observer",
|
|
||||||
runFact: event.runId ? { runId: event.runId } : m.ctx.runFact,
|
|
||||||
});
|
|
||||||
|
|
||||||
case "ATTACH_LIVE":
|
|
||||||
// The attach GET returned a live 2xx stream — follow it as an observer.
|
|
||||||
// Review #1: guard by SOURCE phase. The epoch filter alone is not enough — a
|
|
||||||
// POLL_TERMINAL uses to() (no epoch bump) and does not abort the in-flight
|
|
||||||
// GET, so a slow 2xx landing after the machine already left `attaching` (e.g.
|
|
||||||
// the armed poll saw the terminal row -> idle) would resurrect a settled run
|
|
||||||
// into a phantom `streaming`. Only enter streaming FROM `attaching`.
|
|
||||||
if (m.phase.name !== "attaching") return stay(m);
|
|
||||||
return to(m, { name: "streaming" });
|
|
||||||
|
|
||||||
case "ATTACH_NONE":
|
|
||||||
// 204 / non-2xx / throw: nothing live to attach. Arm the degraded poll to
|
|
||||||
// follow the run to terminal from the DB. This is a soft-negative run-fact
|
|
||||||
// (204 on a non-stripped path is authoritative-negative; the runtime may
|
|
||||||
// pass a RUN_FACT null separately). Keep the run-fact as-is here.
|
|
||||||
// Review #1: guard by source phase for consistency (a late outcome after the
|
|
||||||
// machine already left `attaching` must not re-arm a poll).
|
|
||||||
if (m.phase.name !== "attaching") return stay(m);
|
|
||||||
return to(m, { name: "polling", reason: "attach-none" }, {
|
|
||||||
effects: [{ type: "armPoll", reason: "attach-none" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- reconnect after a live disconnect ----------------------------
|
|
||||||
case "RECONNECT_ATTEMPT":
|
|
||||||
// A scheduled backoff fired — fire the attach GET. epoch++ so the previous
|
|
||||||
// attempt's late outcome cannot drive this one.
|
|
||||||
if (m.phase.name !== "reconnecting") return stay(m);
|
|
||||||
return command(
|
|
||||||
m,
|
|
||||||
{ name: "reconnecting", attempt: event.attempt, failed: false },
|
|
||||||
[{ type: "resumeStream" }],
|
|
||||||
);
|
|
||||||
|
|
||||||
case "RECONNECT_ATTACHED":
|
|
||||||
// #488 commit 3: a live re-attach succeeded. Reset to streaming — the
|
|
||||||
// attempt counter is dropped, so a LATER disconnect can start a fresh
|
|
||||||
// ladder from attempt 1 (the old one-shot `!wasResumed` gate forbade a
|
|
||||||
// second cycle, sending the second break to silent poll).
|
|
||||||
// Review #1: guard by SOURCE phase. The armed degraded poll can reach the
|
|
||||||
// terminal row (POLL_TERMINAL -> idle, via to(), NO epoch bump, GET not
|
|
||||||
// aborted) BEFORE a slow reconnect GET returns 2xx; without this guard that
|
|
||||||
// late RECONNECT_ATTACHED (same epoch) would resurrect a settled run into a
|
|
||||||
// phantom `streaming`. Only re-enter streaming FROM `reconnecting`.
|
|
||||||
if (m.phase.name !== "reconnecting") return stay(m);
|
|
||||||
return to(m, { name: "streaming" }, {
|
|
||||||
effects: [{ type: "cancelReconnect" }, { type: "disarmPoll" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
case "RECONNECT_NONE": {
|
|
||||||
// 204 / error during a reconnect attempt. Arm the degraded poll as the
|
|
||||||
// belt-and-suspenders fallback, then either back off to the next attempt
|
|
||||||
// or, at the cap, surface the manual Retry ("failed").
|
|
||||||
if (m.phase.name !== "reconnecting") return stay(m);
|
|
||||||
const attempt = m.phase.attempt;
|
|
||||||
if (attempt < RECONNECT_MAX_ATTEMPTS) {
|
|
||||||
return command(
|
|
||||||
m,
|
|
||||||
{ name: "reconnecting", attempt: attempt + 1, failed: false },
|
|
||||||
[
|
|
||||||
{ type: "armPoll", reason: "attach-none" },
|
|
||||||
{ type: "scheduleReconnect", attempt: attempt + 1, delayMs: reconnectDelayMs(attempt + 1) },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return to(m, { name: "reconnecting", attempt, failed: true }, {
|
|
||||||
effects: [{ type: "armPoll", reason: "reconnect-exhausted" }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
case "RETRY":
|
|
||||||
// Manual Retry from the "failed" reconnect banner OR the stalled banner.
|
|
||||||
if (m.phase.name === "reconnecting" && m.phase.failed) {
|
|
||||||
return command(
|
|
||||||
m,
|
|
||||||
{ name: "reconnecting", attempt: 1, failed: false },
|
|
||||||
[{ type: "resumeStream" }],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (m.phase.name === "stalled") {
|
|
||||||
// Re-arm the poll to try to catch the run up again.
|
|
||||||
return command(m, { name: "polling", reason: "attach-none" }, [
|
|
||||||
{ type: "armPoll", reason: "attach-none" },
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
return stay(m);
|
|
||||||
|
|
||||||
// ---- degraded poll -------------------------------------------------
|
|
||||||
case "POLL_TERMINAL":
|
|
||||||
// The run reached a terminal row via the poll (or the reconcile merge). Go
|
|
||||||
// idle and disarm everything (I4: this is a DATA-driven exit, incl. exit
|
|
||||||
// from `stopping`). Review #2: reset ownership to local.
|
|
||||||
return to(m, { name: "idle" }, {
|
|
||||||
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
|
||||||
effects: [{ type: "disarmPoll" }, { type: "cancelReconnect" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
case "POLL_IDLE_CAP":
|
|
||||||
// Review #4: `stopping` also arms the poll (STOP_REQUESTED) but has NO other
|
|
||||||
// backstop — an observer-stop with no SDK stream to fire onFinish, whose
|
|
||||||
// server stop never drives the run terminal, would poll the DB forever. Give
|
|
||||||
// it a bounded exit: cap -> idle + disarm (NOT `stalled`; Stop was already
|
|
||||||
// pressed, so there is nothing for the user to retry).
|
|
||||||
if (m.phase.name === "stopping") {
|
|
||||||
return to(m, { name: "idle" }, {
|
|
||||||
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
|
||||||
effects: [{ type: "disarmPoll" }, { type: "cancelReconnect" }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// #488 commit 4a: the poll hit the inactivity cap. Instead of going SILENT
|
|
||||||
// (the old "forever half-done answer"), surface a stalled banner + Retry.
|
|
||||||
if (m.phase.name !== "polling" && m.phase.name !== "reconnecting") return stay(m);
|
|
||||||
return to(m, { name: "stalled" }, {
|
|
||||||
effects: [{ type: "disarmPoll" }, { type: "cancelReconnect" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- run-fact ------------------------------------------------------
|
|
||||||
case "RUN_FACT": {
|
|
||||||
const runFact = event.runFact;
|
|
||||||
// A fresh NEGATIVE fact (no active run) cancels recovery immediately (I3):
|
|
||||||
// there is nothing to reconnect to / poll for.
|
|
||||||
if (!runFact) {
|
|
||||||
if (
|
|
||||||
m.phase.name === "reconnecting" ||
|
|
||||||
m.phase.name === "attaching" ||
|
|
||||||
m.phase.name === "polling" ||
|
|
||||||
m.phase.name === "stopping"
|
|
||||||
) {
|
|
||||||
return to(m, { name: "idle" }, {
|
|
||||||
// Review #2: reset ownership to local on this terminal transition.
|
|
||||||
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
|
||||||
effects: [{ type: "cancelReconnect" }, { type: "disarmPoll" }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return to(m, m.phase, { ctx: { runFact: null } });
|
|
||||||
}
|
|
||||||
// A positive fact just updates the context (pessimism toward an attempt: a
|
|
||||||
// stale-but-positive fact permits entering recovery; a 204 will cut it).
|
|
||||||
return to(m, m.phase, { ctx: { runFact } });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- stop ----------------------------------------------------------
|
|
||||||
case "STOP_REQUESTED":
|
|
||||||
// Authoritative stop of a detached run. Enter `stopping` and fire stopRun +
|
|
||||||
// abort the local/attach reader. ALSO arm the poll so the terminal row is
|
|
||||||
// observed — the exit is by DATA (I4: a terminal row / negative run-fact),
|
|
||||||
// never by the stopRun HTTP response (which returns after abort, before
|
|
||||||
// finalization). For a local turn the aborted stream's onFinish (ANY finish)
|
|
||||||
// is HONORED in `stopping` at the top of reduce() — regardless of generation
|
|
||||||
// — and exits to idle; the armed poll is the fallback for an observer stop
|
|
||||||
// with no local onFinish.
|
|
||||||
return command(
|
|
||||||
m,
|
|
||||||
{ name: "stopping" },
|
|
||||||
[
|
|
||||||
{ type: "stopRun" },
|
|
||||||
{ type: "abortAttach" },
|
|
||||||
{ type: "cancelReconnect" },
|
|
||||||
{ type: "armPoll", reason: "attach-none" },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---- supersede (CAS) ----------------------------------------------
|
|
||||||
case "SUPERSEDE_REQUESTED":
|
|
||||||
// "Interrupt and send now": CAS POST /stream { supersede }. epoch++ so a
|
|
||||||
// late outcome of the interrupted run is dropped.
|
|
||||||
return command(
|
|
||||||
m,
|
|
||||||
{ name: "superseding" },
|
|
||||||
[{ type: "supersede", targetRunId: event.targetRunId }, { type: "cancelReconnect" }, { type: "disarmPoll" }],
|
|
||||||
);
|
|
||||||
|
|
||||||
case "SUPERSEDE_READY": {
|
|
||||||
// CAS succeeded (old run stopped/settled, slot taken, new run begun). We
|
|
||||||
// are now the local streamer of the NEW run. Adopt its runId if provided.
|
|
||||||
const runFact = event.runId ? { runId: event.runId } : m.ctx.runFact;
|
|
||||||
return to(m, { name: "streaming" }, {
|
|
||||||
ctx: { ownership: "local", runFact, liveFollow: false },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
case "SUPERSEDE_MISMATCH":
|
|
||||||
// The active run moved between the click and the CAS. Per the spec: verify
|
|
||||||
// via /run rather than blindly banner — the mismatch may be our own already-
|
|
||||||
// superseded run. Surface a classified error AND fire a run-fact verify.
|
|
||||||
return to(m, { name: "error", kind: "supersede-mismatch" }, {
|
|
||||||
ctx: { runFact: event.currentRunId ? { runId: event.currentRunId } : m.ctx.runFact },
|
|
||||||
effects: [{ type: "postRun", reason: "verify" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
case "SUPERSEDE_TIMEOUT":
|
|
||||||
// The old run did not settle within W. Nothing persisted; the composer keeps
|
|
||||||
// its text. Classified error, NO auto-retry (the old client retry ladder is
|
|
||||||
// removed in #488 commit 5).
|
|
||||||
return to(m, { name: "error", kind: "supersede-timeout" });
|
|
||||||
|
|
||||||
case "SUPERSEDE_INVALID":
|
|
||||||
return to(m, { name: "error", kind: "supersede-invalid" });
|
|
||||||
|
|
||||||
case "RUN_ALREADY_ACTIVE":
|
|
||||||
// A plain POST hit the one-active-run gate. NO auto-retry — the composer
|
|
||||||
// offers "interrupt and send" (supersede) instead. #497/S4: adopt the
|
|
||||||
// server's activeRunId as the run-fact so that supersede can TARGET the
|
|
||||||
// (possibly foreign-tab) active run via the CAS, rather than a blind
|
|
||||||
// promote+abort that just 409s again. A stale/absent id keeps the prior fact.
|
|
||||||
return to(m, { name: "error", kind: "run-already-active" }, {
|
|
||||||
ctx: { runFact: event.activeRunId ? { runId: event.activeRunId } : m.ctx.runFact },
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- lifecycle -----------------------------------------------------
|
|
||||||
case "DISPOSE":
|
|
||||||
// Unmount: abort in-flight controllers, drop timers, and bump the epoch so
|
|
||||||
// NO late callback can drive this (now dead) machine (I5).
|
|
||||||
return command(
|
|
||||||
m,
|
|
||||||
{ name: "idle" },
|
|
||||||
[
|
|
||||||
{ type: "abortAttach" },
|
|
||||||
{ type: "cancelReconnect" },
|
|
||||||
{ type: "disarmPoll" },
|
|
||||||
],
|
|
||||||
{ liveFollow: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
default: {
|
|
||||||
// Exhaustiveness guard.
|
|
||||||
const _never: never = event;
|
|
||||||
void _never;
|
|
||||||
return stay(m);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -181,12 +181,6 @@ export interface IAiChatMessageRow {
|
|||||||
toolCalls?: unknown;
|
toolCalls?: unknown;
|
||||||
metadata?: {
|
metadata?: {
|
||||||
parts?: UIMessage["parts"];
|
parts?: UIMessage["parts"];
|
||||||
// #491 step-alignment anchor: the count of FINISHED steps whose parts are in
|
|
||||||
// THIS row, written atomically with `parts` server-side (flushAssistant). The
|
|
||||||
// resume client reads it as its persisted step frontier N — the tail-only
|
|
||||||
// attach asks the run-stream registry for the frames of step N onward (the
|
|
||||||
// seed already carries steps 0..N-1). Absent on pre-#491 rows -> read as 0.
|
|
||||||
stepsPersisted?: number;
|
|
||||||
// AI SDK v6 `totalUsage` persisted on assistant rows. Legacy cumulative
|
// AI SDK v6 `totalUsage` persisted on assistant rows. Legacy cumulative
|
||||||
// figure (sum of every step's usage for the turn); kept for back-compat and
|
// figure (sum of every step's usage for the turn); kept for back-compat and
|
||||||
// as the fallback for older rows that have no `contextTokens`.
|
// as the fallback for older rows that have no `contextTokens`.
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
resolveAdoptedChatId,
|
resolveAdoptedChatId,
|
||||||
newlyAddedChatIds,
|
newlyAddedChatIds,
|
||||||
extractServerChatId,
|
extractServerChatId,
|
||||||
extractRunId,
|
|
||||||
} from "./adopt-chat-id";
|
} from "./adopt-chat-id";
|
||||||
|
|
||||||
describe("resolveAdoptedChatId", () => {
|
describe("resolveAdoptedChatId", () => {
|
||||||
@@ -71,17 +70,3 @@ describe("extractServerChatId", () => {
|
|||||||
expect(extractServerChatId(undefined)).toBeUndefined();
|
expect(extractServerChatId(undefined)).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("extractRunId", () => {
|
|
||||||
it("reads a string runId from the start metadata", () => {
|
|
||||||
expect(extractRunId({ metadata: { runId: "run-1" } })).toBe("run-1");
|
|
||||||
});
|
|
||||||
it("returns undefined when runId is absent", () => {
|
|
||||||
expect(extractRunId({ metadata: { chatId: "c" } })).toBeUndefined();
|
|
||||||
expect(extractRunId({})).toBeUndefined();
|
|
||||||
expect(extractRunId(undefined)).toBeUndefined();
|
|
||||||
});
|
|
||||||
it("returns undefined for a non-string runId", () => {
|
|
||||||
expect(extractRunId({ metadata: { runId: 7 } })).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -56,20 +56,6 @@ export function extractServerChatId(
|
|||||||
return typeof m?.chatId === "string" ? m.chatId : undefined;
|
return typeof m?.chatId === "string" ? m.chatId : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* #488: read the authoritative RUN id off a streaming assistant message. The
|
|
||||||
* server attaches it as `message.metadata.runId` on the `start` part when a run
|
|
||||||
* wraps the turn (see server `chatStreamMetadata`, #184/#487). This is the live
|
|
||||||
* run-fact update the client FSM adopts (mirrors `extractServerChatId`). Returns
|
|
||||||
* it only when it is a string; undefined otherwise.
|
|
||||||
*/
|
|
||||||
export function extractRunId(
|
|
||||||
message: { metadata?: unknown } | undefined,
|
|
||||||
): string | undefined {
|
|
||||||
const m = message?.metadata as { runId?: string } | undefined;
|
|
||||||
return typeof m?.runId === "string" ? m.runId : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The deduped set of ids present in `afterIds` but not in `beforeIds`. A
|
* The deduped set of ids present in `afterIds` but not in `beforeIds`. A
|
||||||
* paginated/flatMapped list can repeat the same id, so dedupe: one genuinely-new
|
* paginated/flatMapped list can repeat the same id, so dedupe: one genuinely-new
|
||||||
|
|||||||
@@ -6,13 +6,10 @@ describe("estimateTokens", () => {
|
|||||||
expect(estimateTokens("")).toBe(0);
|
expect(estimateTokens("")).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// #490: migrated onto the shared @docmost/token-estimate module (chars/2.5, up
|
it("ceils chars/4 so any non-empty text is at least 1 token", () => {
|
||||||
// from the old client-only chars/4) so the client counter and the server replay
|
|
||||||
// budgeter can never diverge.
|
|
||||||
it("ceils chars/2.5 so any non-empty text is at least 1 token", () => {
|
|
||||||
expect(estimateTokens("a")).toBe(1);
|
expect(estimateTokens("a")).toBe(1);
|
||||||
expect(estimateTokens("ab")).toBe(1);
|
expect(estimateTokens("abcd")).toBe(1);
|
||||||
expect(estimateTokens("abcde")).toBe(2); // 5 / 2.5 = 2
|
expect(estimateTokens("abcde")).toBe(2);
|
||||||
expect(estimateTokens("x".repeat(10))).toBe(4); // 10 / 2.5 = 4
|
expect(estimateTokens("12345678")).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,10 +2,18 @@
|
|||||||
* Rough client-side token estimation for AI-chat UI affordances.
|
* Rough client-side token estimation for AI-chat UI affordances.
|
||||||
*
|
*
|
||||||
* No provider streams exact per-token usage mid-stream, so any in-flight figure
|
* No provider streams exact per-token usage mid-stream, so any in-flight figure
|
||||||
* is a CLIENT ESTIMATE. This re-exports the SHARED estimator from
|
* is a CLIENT ESTIMATE (chars/≈4 heuristic). Pure + unit-testable: it never runs
|
||||||
* `@docmost/token-estimate` (chars/2.5) so the in-body counter and the server's
|
* a real BPE tokenizer (that would be O(n²) on the hot path, bloat the bundle,
|
||||||
* replay budgeter use the SAME heuristic — two divergent estimators would mean
|
* and be wrong for Gemini/Ollama anyway). Used by the in-body reasoning counter
|
||||||
* "the badge shows 60%" while "the budgeter already trimmed" (#490). Used by the
|
* ("Thinking · N tokens").
|
||||||
* in-body reasoning counter ("Thinking · N tokens").
|
|
||||||
*/
|
*/
|
||||||
export { estimateTokens } from "@docmost/token-estimate";
|
|
||||||
|
/**
|
||||||
|
* Rough token estimate for a piece of text using the standard chars/≈4 heuristic.
|
||||||
|
* Returns 0 for empty/whitespace-free-of-content input, and ceils so any
|
||||||
|
* non-empty text counts as at least one token.
|
||||||
|
*/
|
||||||
|
export function estimateTokens(text: string): number {
|
||||||
|
if (!text) return 0;
|
||||||
|
return Math.ceil(text.length / 4);
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,89 +23,6 @@ describe("describeChatError", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("classifies an A_RUN_BEGIN_FAILED 503 as a temporary run-start failure, NOT provider-not-configured (#486)", () => {
|
|
||||||
// The FULL real body the server writes for a beginRun failure: a
|
|
||||||
// ServiceUnavailableException(object) whose response is serialized verbatim
|
|
||||||
// onto the raw socket, self-describing statusCode 503 + the run-start code.
|
|
||||||
const body =
|
|
||||||
'{"message":"Could not start the agent run. This is usually temporary — please try again.","code":"A_RUN_BEGIN_FAILED","statusCode":503}';
|
|
||||||
expect(describeChatError(body, t)).toEqual({
|
|
||||||
title: "Could not start the run",
|
|
||||||
detail:
|
|
||||||
"The agent run could not be started. This is usually temporary — please try again.",
|
|
||||||
});
|
|
||||||
// ORDER GUARD: even though the body ALSO carries statusCode 503 (which the
|
|
||||||
// generic branch matches), the A_RUN_BEGIN_FAILED branch runs first, so it is
|
|
||||||
// never mislabeled "AI provider not configured".
|
|
||||||
expect(describeChatError(body, t).title).not.toBe(
|
|
||||||
"AI provider not configured",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// #488 commit 5: the #487 concurrency-gate / supersede 409s. FULL real bodies:
|
|
||||||
// a ConflictException(object) whose response is serialized verbatim, carrying a
|
|
||||||
// `code` and statusCode 409. Each must classify to a human text, not raw JSON.
|
|
||||||
it("classifies A_RUN_ALREADY_ACTIVE (409) as already-answering, not raw JSON", () => {
|
|
||||||
const body =
|
|
||||||
'{"message":"A run is already active for this chat","code":"A_RUN_ALREADY_ACTIVE","statusCode":409}';
|
|
||||||
expect(describeChatError(body, t).title).toBe(
|
|
||||||
"The agent is already answering",
|
|
||||||
);
|
|
||||||
// Never leaks the raw code as the detail.
|
|
||||||
expect(describeChatError(body, t).detail).not.toContain("A_RUN_ALREADY_ACTIVE");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies SUPERSEDE_TARGET_MISMATCH (409) as run-changed", () => {
|
|
||||||
// Real server body shape: the current run id is `activeRunId` (NOT `runId`) —
|
|
||||||
// see ai-chat.controller.ts. describeChatError classifies off `code` only.
|
|
||||||
const body =
|
|
||||||
'{"message":"active run does not match the supersede target","code":"SUPERSEDE_TARGET_MISMATCH","activeRunId":"run-x","statusCode":409}';
|
|
||||||
expect(describeChatError(body, t).title).toBe(
|
|
||||||
"Couldn't interrupt — the run changed",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies SUPERSEDE_TIMEOUT (409) as couldn't-interrupt-in-time", () => {
|
|
||||||
const body =
|
|
||||||
'{"message":"the run did not settle within the supersede window","code":"SUPERSEDE_TIMEOUT","statusCode":409}';
|
|
||||||
expect(describeChatError(body, t).title).toBe("Couldn't interrupt in time");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies SUPERSEDE_INVALID (409) as couldn't-interrupt-that-run", () => {
|
|
||||||
const body =
|
|
||||||
'{"message":"supervise requires chatId","code":"SUPERSEDE_INVALID","statusCode":409}';
|
|
||||||
expect(describeChatError(body, t).title).toBe(
|
|
||||||
"Couldn't interrupt that run",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ORDER GUARD: A_RUN_ALREADY_ACTIVE wins over any generic status branch", () => {
|
|
||||||
// Even though the body could superficially look 4xx-ish, the code branch runs
|
|
||||||
// first, so it is never mislabeled by a generic status heading.
|
|
||||||
const body =
|
|
||||||
'{"message":"conflict","code":"A_RUN_ALREADY_ACTIVE","statusCode":409}';
|
|
||||||
const view = describeChatError(body, t);
|
|
||||||
expect(view.title).not.toBe("Something went wrong");
|
|
||||||
expect(view.title).not.toBe("AI provider not configured");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies a token-degeneration abort under the SAME 'Response stopped.' marker the live view shows (#495)", () => {
|
|
||||||
// The exact reason the server persists in metadata.error on a degeneration
|
|
||||||
// abort (ai-chat.service OUTPUT_DEGENERATION_ERROR). Live, this event shows
|
|
||||||
// the neutral "Response stopped." notice; the persisted banner MUST match it
|
|
||||||
// so live and refetch never disagree.
|
|
||||||
const view = describeChatError(
|
|
||||||
"Output degeneration detected (repeated token loop)",
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
expect(view.title).toBe("Response stopped.");
|
|
||||||
expect(view.detail).toBe(
|
|
||||||
"The answer was stopped automatically because the model fell into a repeated output loop.",
|
|
||||||
);
|
|
||||||
// Regression guard: it must NOT fall through to the generic heading.
|
|
||||||
expect(view.title).not.toBe("Something went wrong");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies a dropped connection (ECONNRESET) as a lost-connection error", () => {
|
it("classifies a dropped connection (ECONNRESET) as a lost-connection error", () => {
|
||||||
expect(
|
expect(
|
||||||
describeChatError("Cannot connect to API: read ECONNRESET", t).title,
|
describeChatError("Cannot connect to API: read ECONNRESET", t).title,
|
||||||
|
|||||||
@@ -24,75 +24,6 @@ export function describeChatError(
|
|||||||
): ChatErrorView {
|
): ChatErrorView {
|
||||||
const msg = message ?? "";
|
const msg = message ?? "";
|
||||||
|
|
||||||
// Our own "could not start the run" gate (A_RUN_BEGIN_FAILED, #486): a 503
|
|
||||||
// whose body carries this code is a TEMPORARY server-side failure while
|
|
||||||
// starting the run (e.g. a DB-pool blip), NOT an unconfigured provider. It MUST
|
|
||||||
// be matched STRICTLY BEFORE the generic 503 branch below, which would
|
|
||||||
// otherwise mislabel it "The AI provider is not configured" and tell the user
|
|
||||||
// to call an admin instead of just retrying.
|
|
||||||
if (/"code"\s*:\s*"A_RUN_BEGIN_FAILED"/.test(msg)) {
|
|
||||||
return {
|
|
||||||
title: t("Could not start the run"),
|
|
||||||
detail: t(
|
|
||||||
"The agent run could not be started. This is usually temporary — please try again.",
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// #488 commit 5: the #487 concurrency-gate / supersede 409s. These arrive as a
|
|
||||||
// ConflictException(object) body carrying a `code` (and statusCode 409). They
|
|
||||||
// MUST be classified by `code` STRICTLY BEFORE any generic status branch, or the
|
|
||||||
// user sees the raw JSON `{"code":"A_RUN_ALREADY_ACTIVE",…}`. The code strings
|
|
||||||
// are the real #487 server contract (ai-chat.controller.ts) — do not invent.
|
|
||||||
if (/"code"\s*:\s*"A_RUN_ALREADY_ACTIVE"/.test(msg)) {
|
|
||||||
return {
|
|
||||||
title: t("The agent is already answering"),
|
|
||||||
detail: t(
|
|
||||||
"This chat already has a run in progress. Wait for it to finish, or interrupt it and send now.",
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (/"code"\s*:\s*"SUPERSEDE_TARGET_MISMATCH"/.test(msg)) {
|
|
||||||
return {
|
|
||||||
title: t("Couldn't interrupt — the run changed"),
|
|
||||||
detail: t(
|
|
||||||
"The run you tried to interrupt is no longer the active one. Check the latest answer and try again.",
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (/"code"\s*:\s*"SUPERSEDE_TIMEOUT"/.test(msg)) {
|
|
||||||
return {
|
|
||||||
title: t("Couldn't interrupt in time"),
|
|
||||||
detail: t(
|
|
||||||
"The previous run didn't stop in time. Nothing was sent — try sending again.",
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (/"code"\s*:\s*"SUPERSEDE_INVALID"/.test(msg)) {
|
|
||||||
return {
|
|
||||||
title: t("Couldn't interrupt that run"),
|
|
||||||
detail: t(
|
|
||||||
"The run to interrupt doesn't belong to this chat. Reload and try again.",
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Our own token-degeneration abort (#444): the server aborts a runaway
|
|
||||||
// repetition loop and persists this exact reason in metadata.error. LIVE, the
|
|
||||||
// same abort surfaces as the neutral "Response stopped." notice (the client
|
|
||||||
// cannot tell it from a manual Stop mid-stream), so the persisted banner must
|
|
||||||
// read the SAME "Response stopped." marker — otherwise the live view and a
|
|
||||||
// later refetch show two different texts for one event. The detail explains the
|
|
||||||
// loop-guard cause without contradicting the shared heading.
|
|
||||||
if (/output degeneration detected|repeated token loop/i.test(msg)) {
|
|
||||||
return {
|
|
||||||
title: t("Response stopped."),
|
|
||||||
detail: t(
|
|
||||||
"The answer was stopped automatically because the model fell into a repeated output loop.",
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/"statusCode"\s*:\s*403\b/.test(msg)) {
|
if (/"statusCode"\s*:\s*403\b/.test(msg)) {
|
||||||
return {
|
return {
|
||||||
title: t("AI chat is disabled"),
|
title: t("AI chat is disabled"),
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.t
|
|||||||
import {
|
import {
|
||||||
isStreamingTail,
|
isStreamingTail,
|
||||||
isSettledAssistantTail,
|
isSettledAssistantTail,
|
||||||
stepsPersistedOf,
|
seedRows,
|
||||||
mergeDeltaRowsIntoPages,
|
|
||||||
mergeById,
|
mergeById,
|
||||||
} from "./resume-helpers.ts";
|
} from "./resume-helpers.ts";
|
||||||
|
|
||||||
@@ -13,18 +12,8 @@ function row(
|
|||||||
id: string,
|
id: string,
|
||||||
role: string,
|
role: string,
|
||||||
status?: string,
|
status?: string,
|
||||||
stepsPersisted?: number,
|
|
||||||
): IAiChatMessageRow {
|
): IAiChatMessageRow {
|
||||||
return {
|
return { id, role, content: "", status, createdAt: "2026-01-01T00:00:00Z" };
|
||||||
id,
|
|
||||||
role,
|
|
||||||
content: "",
|
|
||||||
status,
|
|
||||||
createdAt: "2026-01-01T00:00:00Z",
|
|
||||||
...(stepsPersisted !== undefined
|
|
||||||
? { metadata: { stepsPersisted } }
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeMsg(id: string, text: string): UIMessage {
|
function makeMsg(id: string, text: string): UIMessage {
|
||||||
@@ -76,92 +65,23 @@ describe("isSettledAssistantTail", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("stepsPersistedOf", () => {
|
describe("seedRows", () => {
|
||||||
it("reads metadata.stepsPersisted", () => {
|
const rows = [row("u1", "user"), row("a1", "assistant", "streaming")];
|
||||||
expect(stepsPersistedOf(row("a1", "assistant", "streaming", 3))).toBe(3);
|
|
||||||
expect(stepsPersistedOf(row("a1", "assistant", "streaming", 0))).toBe(0);
|
it("returns the rows unchanged when not stripping", () => {
|
||||||
|
expect(seedRows(rows, false)).toBe(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults to 0 for a pre-#491 row (absent), null/undefined, or a bad value", () => {
|
it("drops the last row when stripping", () => {
|
||||||
expect(stepsPersistedOf(row("a1", "assistant", "streaming"))).toBe(0);
|
const seeded = seedRows(rows, true);
|
||||||
expect(stepsPersistedOf(null)).toBe(0);
|
expect(seeded).toHaveLength(1);
|
||||||
expect(stepsPersistedOf(undefined)).toBe(0);
|
expect(seeded[0].id).toBe("u1");
|
||||||
expect(
|
|
||||||
stepsPersistedOf({
|
|
||||||
id: "a1",
|
|
||||||
role: "assistant",
|
|
||||||
content: "",
|
|
||||||
createdAt: "x",
|
|
||||||
metadata: { stepsPersisted: -2 },
|
|
||||||
}),
|
|
||||||
).toBe(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("floors a non-integer count", () => {
|
it("returns an empty list when stripping a single-row list", () => {
|
||||||
expect(
|
expect(seedRows([row("a1", "assistant", "streaming")], true)).toHaveLength(
|
||||||
stepsPersistedOf({
|
0,
|
||||||
id: "a1",
|
);
|
||||||
role: "assistant",
|
|
||||||
content: "",
|
|
||||||
createdAt: "x",
|
|
||||||
metadata: { stepsPersisted: 2.9 },
|
|
||||||
}),
|
|
||||||
).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("mergeDeltaRowsIntoPages", () => {
|
|
||||||
const pages = () => [
|
|
||||||
{ items: [row("u1", "user"), row("a1", "assistant", "streaming", 1)], meta: {} },
|
|
||||||
];
|
|
||||||
|
|
||||||
it("returns the pages unchanged for an empty delta", () => {
|
|
||||||
const p = pages();
|
|
||||||
expect(mergeDeltaRowsIntoPages(p, [])).toBe(p);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("appends a genuinely new row to the last page in chronological order", () => {
|
|
||||||
const merged = mergeDeltaRowsIntoPages(pages(), [row("a2", "assistant", "streaming", 0)]);
|
|
||||||
expect(merged[0].items.map((i) => i.id)).toEqual(["u1", "a1", "a2"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("replaces a grown row in place (per-step growth), never appends a duplicate", () => {
|
|
||||||
const merged = mergeDeltaRowsIntoPages(pages(), [
|
|
||||||
row("a1", "assistant", "streaming", 2),
|
|
||||||
]);
|
|
||||||
expect(merged[0].items.map((i) => i.id)).toEqual(["u1", "a1"]);
|
|
||||||
// the in-place replacement carries the grown step frontier.
|
|
||||||
expect(stepsPersistedOf(merged[0].items[1])).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not mutate the input pages", () => {
|
|
||||||
const input = pages();
|
|
||||||
const before = input[0].items.slice();
|
|
||||||
mergeDeltaRowsIntoPages(input, [row("a2", "assistant", "streaming", 0)]);
|
|
||||||
expect(input[0].items).toEqual(before); // untouched
|
|
||||||
});
|
|
||||||
|
|
||||||
// #491 CONTRACT: the delta overlap window re-delivers the same rows, so merging
|
|
||||||
// MUST be idempotent — applying a delta twice equals applying it once (no growth,
|
|
||||||
// no reorder). A regression re-introduces duplicate assistant bubbles per poll.
|
|
||||||
it("is idempotent: applying the same delta twice equals once", () => {
|
|
||||||
const delta = [
|
|
||||||
row("a1", "assistant", "streaming", 2), // grown existing row
|
|
||||||
row("a2", "assistant", "streaming", 0), // new row
|
|
||||||
];
|
|
||||||
const once = mergeDeltaRowsIntoPages(pages(), delta);
|
|
||||||
const twice = mergeDeltaRowsIntoPages(once, delta);
|
|
||||||
const thrice = mergeDeltaRowsIntoPages(twice, delta);
|
|
||||||
expect(once[0].items.map((i) => i.id)).toEqual(["u1", "a1", "a2"]);
|
|
||||||
expect(twice[0].items.map((i) => i.id)).toEqual(["u1", "a1", "a2"]);
|
|
||||||
expect(twice).toEqual(once);
|
|
||||||
expect(thrice).toEqual(once);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("seeds a first page when the cache is empty", () => {
|
|
||||||
const merged = mergeDeltaRowsIntoPages([], [row("u1", "user")]);
|
|
||||||
expect(merged).toHaveLength(1);
|
|
||||||
expect(merged[0].items.map((i) => i.id)).toEqual(["u1"]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -189,37 +109,4 @@ describe("mergeById", () => {
|
|||||||
expect(mergeById(prev, null)).toBe(prev);
|
expect(mergeById(prev, null)).toBe(prev);
|
||||||
expect(mergeById(prev, undefined)).toBe(prev);
|
expect(mergeById(prev, undefined)).toBe(prev);
|
||||||
});
|
});
|
||||||
|
|
||||||
// #491 CONTRACT: the delta poll's overlap window GUARANTEES the same row is
|
|
||||||
// re-delivered across close polls, so merging must be IDEMPOTENT by id — merging
|
|
||||||
// the same row (or an equal-length list of rows) twice must not duplicate or
|
|
||||||
// reorder. This is the property the whole delta-poll design leans on; a
|
|
||||||
// regression here would re-introduce duplicate assistant bubbles on every poll.
|
|
||||||
it("is idempotent by id: re-merging the same row does not duplicate or reorder", () => {
|
|
||||||
const seed = [makeMsg("u1", "hi"), makeMsg("a1", "step 1")];
|
|
||||||
const repeat = makeMsg("a1", "step 1"); // the SAME row the overlap re-delivers
|
|
||||||
const once = mergeById(seed, repeat);
|
|
||||||
const twice = mergeById(once, repeat);
|
|
||||||
const thrice = mergeById(twice, repeat);
|
|
||||||
// Length is stable (no growth), order is stable (user then assistant).
|
|
||||||
expect(once.map((m) => m.id)).toEqual(["u1", "a1"]);
|
|
||||||
expect(twice.map((m) => m.id)).toEqual(["u1", "a1"]);
|
|
||||||
expect(thrice.map((m) => m.id)).toEqual(["u1", "a1"]);
|
|
||||||
// The repeated merge converges: the row is replaced in place, never appended.
|
|
||||||
expect(twice[1]).toBe(repeat);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is idempotent across a batch of repeated + grown rows (delta re-delivery)", () => {
|
|
||||||
// A delta poll re-delivers a1 (unchanged) and a2 (grown one step). Applying the
|
|
||||||
// batch twice must equal applying it once — the poll can re-send either.
|
|
||||||
const start = [makeMsg("u1", "hi"), makeMsg("a1", "done")];
|
|
||||||
const batch = [makeMsg("a1", "done"), makeMsg("a2", "grown step 2")];
|
|
||||||
const apply = (list: typeof start) =>
|
|
||||||
batch.reduce((acc, row) => mergeById(acc, row), list);
|
|
||||||
const once = apply(start);
|
|
||||||
const twice = apply(once);
|
|
||||||
expect(once.map((m) => m.id)).toEqual(["u1", "a1", "a2"]);
|
|
||||||
expect(twice.map((m) => m.id)).toEqual(["u1", "a1", "a2"]);
|
|
||||||
expect(twice).toEqual(once);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,10 +11,9 @@ import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.t
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A STREAMING tail: the last persisted row is an assistant row still marked
|
* A STREAMING tail: the last persisted row is an assistant row still marked
|
||||||
* `status === 'streaming'`. #491 (tail-only): such a tail is seeded UNCHANGED —
|
* `status === 'streaming'`. Such a tail is stripped from the seed and rebuilt by
|
||||||
* it carries the persisted steps 0..N-1 — and the run-stream registry's tail
|
* the replay (`expect=live`), since the SDK's `text-start` always pushes a new
|
||||||
* (frames for steps >= N) is APPENDED to it by the SDK's `readUIMessageStream`
|
* part and replaying over a seeded in-progress row would duplicate its text.
|
||||||
* continuation. Only the presence of this tail decides WHETHER to attach.
|
|
||||||
*/
|
*/
|
||||||
export function isStreamingTail(rows: IAiChatMessageRow[]): boolean {
|
export function isStreamingTail(rows: IAiChatMessageRow[]): boolean {
|
||||||
const tail = rows[rows.length - 1];
|
const tail = rows[rows.length - 1];
|
||||||
@@ -33,61 +32,15 @@ export function isSettledAssistantTail(rows: IAiChatMessageRow[]): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* #491 tail-only anchor: the count of FINISHED steps whose parts are persisted in
|
* Seed rows for `useChat`: return the rows unchanged, or without the last row when
|
||||||
* THIS assistant row (`metadata.stepsPersisted`), written atomically with `parts`
|
* `strip` is set (the streaming tail is stripped so the live replay rebuilds it
|
||||||
* server-side. The resume client reads it as its persisted step frontier N — the
|
* without duplicating parts).
|
||||||
* tail-only attach asks the run-stream registry for the frames of step N onward
|
|
||||||
* (the seed already carries steps 0..N-1). Absent on pre-#491 rows => 0.
|
|
||||||
*/
|
*/
|
||||||
export function stepsPersistedOf(
|
export function seedRows(
|
||||||
row: IAiChatMessageRow | null | undefined,
|
|
||||||
): number {
|
|
||||||
const n = row?.metadata?.stepsPersisted;
|
|
||||||
return typeof n === "number" && n >= 0 ? Math.floor(n) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One page of the messages infinite-query cache (`{ items, meta }`). */
|
|
||||||
export interface IMessagePage {
|
|
||||||
items: IAiChatMessageRow[];
|
|
||||||
meta: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #491 delta-poll merge: upsert the delta poll's `rows` into the messages
|
|
||||||
* infinite-query page structure IDEMPOTENTLY by id. The delta endpoint's overlap
|
|
||||||
* window GUARANTEES occasional REPEATS, so this MUST converge: a row already
|
|
||||||
* present is REPLACED IN PLACE (per-step growth of an in-progress row), a new row
|
|
||||||
* is APPENDED to the last page in chronological order (the server returns delta
|
|
||||||
* rows oldest-first). Applying the same delta twice equals applying it once. Never
|
|
||||||
* mutates the input pages (returns fresh page objects with cloned item arrays).
|
|
||||||
*/
|
|
||||||
export function mergeDeltaRowsIntoPages(
|
|
||||||
pages: IMessagePage[],
|
|
||||||
rows: IAiChatMessageRow[],
|
rows: IAiChatMessageRow[],
|
||||||
): IMessagePage[] {
|
strip: boolean,
|
||||||
if (rows.length === 0) return pages;
|
): IAiChatMessageRow[] {
|
||||||
const next: IMessagePage[] = pages.map((p) => ({
|
return strip ? rows.slice(0, -1) : rows;
|
||||||
...p,
|
|
||||||
items: p.items.slice(),
|
|
||||||
}));
|
|
||||||
const locate = (id: string): [number, number] | null => {
|
|
||||||
for (let pi = 0; pi < next.length; pi++) {
|
|
||||||
const ii = next[pi].items.findIndex((it) => it.id === id);
|
|
||||||
if (ii !== -1) return [pi, ii];
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
for (const row of rows) {
|
|
||||||
const at = locate(row.id);
|
|
||||||
if (at) {
|
|
||||||
next[at[0]].items[at[1]] = row; // replace in place — idempotent by id
|
|
||||||
} else if (next.length > 0) {
|
|
||||||
next[next.length - 1].items.push(row); // append chronologically
|
|
||||||
} else {
|
|
||||||
next.push({ items: [row], meta: undefined });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { readUIMessageStream, type UIMessage } from "ai";
|
|
||||||
import pkg from "../../../../package.json";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PIN-SPEC TRIP-WIRE (#491). The tail-only attach continuation relies on THREE
|
|
||||||
* behaviors of `ai@6.0.207`, verified line-by-line in the issue. Without this
|
|
||||||
* test, an `ai` bump could silently break attach (the client would append the
|
|
||||||
* live tail to the wrong message, or duplicate a step):
|
|
||||||
*
|
|
||||||
* 1. `readUIMessageStream({ message })` CONTINUES the passed message — it does
|
|
||||||
* not start a fresh one — so the tail streamed after a re-seed is appended to
|
|
||||||
* the seeded assistant row (the same DB id).
|
|
||||||
* 2. A `start` frame does NOT reset the existing message's parts (so the seeded
|
|
||||||
* steps 0..N-1 survive; the synthetic `start` the registry prepends only
|
|
||||||
* carries the run-fact metadata).
|
|
||||||
* 3. Text parts do NOT cross a `finish-step` boundary — a new `text-start` after
|
|
||||||
* `finish-step` is a NEW part — so the reconstructed steps stay separated and
|
|
||||||
* the step frontier stays meaningful.
|
|
||||||
*
|
|
||||||
* If an `ai` upgrade changes any of these, this test fails LOUD instead of the
|
|
||||||
* resume path silently corrupting.
|
|
||||||
*/
|
|
||||||
describe("ai SDK continuation trip-wire (#491, tail-only attach)", () => {
|
|
||||||
it("is pinned to the exact ai version the continuation was verified against", () => {
|
|
||||||
// A caret/range bump is exactly what would silently break attach — require an
|
|
||||||
// exact pin. Bumping ai MUST re-verify the behavior asserted below, then this.
|
|
||||||
expect((pkg as { dependencies: Record<string, string> }).dependencies.ai).toBe(
|
|
||||||
"6.0.207",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("continues the seeded message: start does not reset parts, the tail appends as new parts", async () => {
|
|
||||||
// A seeded assistant row with ONE finished step already reconstructed.
|
|
||||||
const seeded: UIMessage = {
|
|
||||||
id: "assistant-1",
|
|
||||||
role: "assistant",
|
|
||||||
parts: [
|
|
||||||
{ type: "step-start" },
|
|
||||||
{ type: "text", text: "STEP0", state: "done" },
|
|
||||||
],
|
|
||||||
} as UIMessage;
|
|
||||||
|
|
||||||
// The tail the registry delivers on re-attach: a synthetic start (run-fact),
|
|
||||||
// then step 1's frames, then finish. As UI-message chunks (what the SSE frames
|
|
||||||
// decode to).
|
|
||||||
const chunks = [
|
|
||||||
{ type: "start", messageMetadata: { runId: "r1", chatId: "c1" } },
|
|
||||||
{ type: "start-step" },
|
|
||||||
{ type: "text-start", id: "t1" },
|
|
||||||
{ type: "text-delta", id: "t1", delta: "STEP1" },
|
|
||||||
{ type: "text-end", id: "t1" },
|
|
||||||
{ type: "finish-step" },
|
|
||||||
{ type: "finish" },
|
|
||||||
];
|
|
||||||
const stream = new ReadableStream({
|
|
||||||
start(c) {
|
|
||||||
for (const ch of chunks) c.enqueue(ch);
|
|
||||||
c.close();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let last: UIMessage | undefined;
|
|
||||||
for await (const msg of readUIMessageStream({ message: seeded, stream })) {
|
|
||||||
last = msg;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(last).toBeDefined();
|
|
||||||
// Same message id (continuation, not a fresh message).
|
|
||||||
expect(last!.id).toBe("assistant-1");
|
|
||||||
// The seeded step-0 parts SURVIVED the `start` frame, and step 1 was appended
|
|
||||||
// as SEPARATE parts (text did not cross the finish-step boundary).
|
|
||||||
const shape = last!.parts.map((p) => `${p.type}:${(p as { text?: string }).text ?? ""}`);
|
|
||||||
expect(shape).toEqual([
|
|
||||||
"step-start:",
|
|
||||||
"text:STEP0",
|
|
||||||
"step-start:",
|
|
||||||
"text:STEP1",
|
|
||||||
]);
|
|
||||||
// The run-fact metadata from the synthetic start frame is applied.
|
|
||||||
expect(last!.metadata).toMatchObject({ runId: "r1", chatId: "c1" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
|
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
|
||||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||||
import { StarterKit } from "@tiptap/starter-kit";
|
import { StarterKit } from "@tiptap/starter-kit";
|
||||||
import { Mention, LinkExtension } from "@docmost/editor-ext";
|
import { Mention, LinkExtension, Code } from "@docmost/editor-ext";
|
||||||
import classes from "./comment.module.css";
|
import classes from "./comment.module.css";
|
||||||
import { useFocusWithin } from "@mantine/hooks";
|
import { useFocusWithin } from "@mantine/hooks";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@@ -44,7 +44,12 @@ const CommentEditor = forwardRef(
|
|||||||
gapcursor: false,
|
gapcursor: false,
|
||||||
dropcursor: false,
|
dropcursor: false,
|
||||||
link: false,
|
link: false,
|
||||||
|
// #515: use the shared editor-ext `Code` (excludes: "") instead of
|
||||||
|
// StarterKit's excluding one, so inline code in a comment can carry
|
||||||
|
// other marks and does not drop them when the comment is edited.
|
||||||
|
code: false,
|
||||||
}),
|
}),
|
||||||
|
Code,
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: placeholder || t("Reply..."),
|
placeholder: placeholder || t("Reply..."),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -3,20 +3,11 @@ import { atom } from "jotai";
|
|||||||
// import would drag the whole @tiptap/core engine into the eager graph of every
|
// import would drag the whole @tiptap/core engine into the eager graph of every
|
||||||
// shell component that reads one of these atoms.
|
// shell component that reads one of these atoms.
|
||||||
import type { Editor } from "@tiptap/core";
|
import type { Editor } from "@tiptap/core";
|
||||||
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
||||||
|
|
||||||
export const pageEditorAtom = atom<Editor | null>(null);
|
export const pageEditorAtom = atom<Editor | null>(null);
|
||||||
|
|
||||||
// #370 — the active page's collab provider, published by the page editor so the
|
|
||||||
// header menu can emit the "save-version" stateless signal (Cmd+S / button).
|
|
||||||
// Null when the page is read-only / collab isn't connected. A typed initial
|
|
||||||
// value (rather than an explicit generic) keeps jotai's overload resolution on
|
|
||||||
// the writable PrimitiveAtom branch.
|
|
||||||
const initialCollabProvider: HocuspocusProvider | null = null;
|
|
||||||
export const collabProviderAtom = atom(initialCollabProvider);
|
|
||||||
|
|
||||||
export const titleEditorAtom = atom<Editor | null>(null);
|
export const titleEditorAtom = atom<Editor | null>(null);
|
||||||
|
|
||||||
export const readOnlyEditorAtom = atom<Editor | null>(null);
|
export const readOnlyEditorAtom = atom<Editor | null>(null);
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import * as Y from "yjs";
|
|
||||||
import { yHistoryAvailability } from "./use-toolbar-state.ts";
|
|
||||||
|
|
||||||
// Undo/redo availability is derived from the Yjs UndoManager's PRIVATE
|
|
||||||
// `undoStack` / `redoStack` fields (see use-toolbar-state.ts for why we read the
|
|
||||||
// stack lengths directly instead of the expensive `editor.can().undo()` dry-run).
|
|
||||||
// These tests lock in the behavior AND pin the library shape so a yjs / y-undo
|
|
||||||
// upgrade that renames/restructures those internals fails loudly here rather than
|
|
||||||
// silently enabling/disabling the toolbar buttons in production.
|
|
||||||
describe("yHistoryAvailability", () => {
|
|
||||||
it("reports availability from the stack lengths", () => {
|
|
||||||
expect(yHistoryAvailability({ undoStack: [], redoStack: [] })).toEqual({
|
|
||||||
canUndo: false,
|
|
||||||
canRedo: false,
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
yHistoryAvailability({ undoStack: [{}], redoStack: [] }),
|
|
||||||
).toEqual({ canUndo: true, canRedo: false });
|
|
||||||
expect(
|
|
||||||
yHistoryAvailability({ undoStack: [{}], redoStack: [{}, {}] }),
|
|
||||||
).toEqual({ canUndo: true, canRedo: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when the private stack shape is unrecognized (upgrade guard)", () => {
|
|
||||||
// Simulates a yjs / y-undo upgrade that renames or restructures the private
|
|
||||||
// fields: the caller then falls back to the safe prosemirror-history default
|
|
||||||
// instead of throwing on `.length` of undefined or reading garbage.
|
|
||||||
expect(yHistoryAvailability(undefined)).toBeNull();
|
|
||||||
expect(yHistoryAvailability(null)).toBeNull();
|
|
||||||
expect(yHistoryAvailability({})).toBeNull();
|
|
||||||
expect(yHistoryAvailability({ undoStack: 5, redoStack: 5 })).toBeNull();
|
|
||||||
// Only one stack present (partial rename) is still not trusted.
|
|
||||||
expect(yHistoryAvailability({ undoStack: [] })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("pin-test: a real yjs UndoManager still exposes undoStack/redoStack arrays", () => {
|
|
||||||
const doc = new Y.Doc();
|
|
||||||
const text = doc.getText("prosemirror");
|
|
||||||
const undoManager = new Y.UndoManager(text);
|
|
||||||
|
|
||||||
// Fresh manager: both stacks empty -> nothing to undo/redo.
|
|
||||||
expect(yHistoryAvailability(undoManager)).toEqual({
|
|
||||||
canUndo: false,
|
|
||||||
canRedo: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// A tracked edit must push onto the private undoStack. If a future yjs
|
|
||||||
// renames these fields, yHistoryAvailability(undoManager) returns null and
|
|
||||||
// the expectation below fails loudly.
|
|
||||||
text.insert(0, "hello");
|
|
||||||
undoManager.stopCapturing();
|
|
||||||
expect(yHistoryAvailability(undoManager)).toEqual({
|
|
||||||
canUndo: true,
|
|
||||||
canRedo: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Undoing moves the item to the redoStack -> redo becomes available.
|
|
||||||
undoManager.undo();
|
|
||||||
expect(yHistoryAvailability(undoManager)).toEqual({
|
|
||||||
canUndo: false,
|
|
||||||
canRedo: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -35,30 +35,6 @@ export interface ToolbarState {
|
|||||||
// When neither history backend is installed (the pre-sync static editor —
|
// When neither history backend is installed (the pre-sync static editor —
|
||||||
// mainExtensions only, undoRedo disabled), both fall through to 0 -> false,
|
// mainExtensions only, undoRedo disabled), both fall through to 0 -> false,
|
||||||
// matching the previous `safeCan` behavior.
|
// matching the previous `safeCan` behavior.
|
||||||
// Reads the Yjs UndoManager's undo/redo availability from its stack lengths.
|
|
||||||
//
|
|
||||||
// `undoStack` / `redoStack` are PRIVATE y-undo / yjs internals, so we touch them
|
|
||||||
// defensively: a yjs or y-undo upgrade that renames or restructures these fields
|
|
||||||
// must not silently mis-drive the toolbar buttons (nor throw on `.length` of
|
|
||||||
// `undefined`). We only trust them when they are actually arrays; otherwise this
|
|
||||||
// returns null and the caller falls back to a safe default. The pin-test in
|
|
||||||
// use-toolbar-state.test.ts asserts the current library shape, so an upgrade that
|
|
||||||
// breaks this contract fails loudly there instead of failing silently in the UI.
|
|
||||||
export function yHistoryAvailability(
|
|
||||||
undoManager: unknown,
|
|
||||||
): { canUndo: boolean; canRedo: boolean } | null {
|
|
||||||
if (!undoManager || typeof undoManager !== "object") return null;
|
|
||||||
const { undoStack, redoStack } = undoManager as {
|
|
||||||
undoStack?: unknown;
|
|
||||||
redoStack?: unknown;
|
|
||||||
};
|
|
||||||
if (!Array.isArray(undoStack) || !Array.isArray(redoStack)) return null;
|
|
||||||
return {
|
|
||||||
canUndo: undoStack.length > 0,
|
|
||||||
canRedo: redoStack.length > 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function historyAvailability(editor: Editor): {
|
function historyAvailability(editor: Editor): {
|
||||||
canUndo: boolean;
|
canUndo: boolean;
|
||||||
canRedo: boolean;
|
canRedo: boolean;
|
||||||
@@ -67,14 +43,16 @@ function historyAvailability(editor: Editor): {
|
|||||||
|
|
||||||
// Collaboration history (Yjs) takes precedence when present.
|
// Collaboration history (Yjs) takes precedence when present.
|
||||||
const yState = yUndoPluginKey.getState(state) as
|
const yState = yUndoPluginKey.getState(state) as
|
||||||
| { undoManager?: unknown }
|
| { undoManager?: { undoStack: unknown[]; redoStack: unknown[] } }
|
||||||
| undefined;
|
| undefined;
|
||||||
const yAvail = yHistoryAvailability(yState?.undoManager);
|
if (yState?.undoManager) {
|
||||||
if (yAvail) return yAvail;
|
return {
|
||||||
|
canUndo: yState.undoManager.undoStack.length > 0,
|
||||||
|
canRedo: yState.undoManager.redoStack.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Plain prosemirror-history (returns 0 when the history plugin is absent).
|
// Plain prosemirror-history (returns 0 when the history plugin is absent).
|
||||||
// This is also the safe default when a Yjs UndoManager is present but its
|
|
||||||
// private stack shape is no longer recognized (yHistoryAvailability -> null).
|
|
||||||
return {
|
return {
|
||||||
canUndo: undoDepth(state) > 0,
|
canUndo: undoDepth(state) > 0,
|
||||||
canRedo: redoDepth(state) > 0,
|
canRedo: redoDepth(state) > 0,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { markInputRule } from "@tiptap/core";
|
import { markInputRule } from "@tiptap/core";
|
||||||
import { StarterKit } from "@tiptap/starter-kit";
|
import { StarterKit } from "@tiptap/starter-kit";
|
||||||
import { Code } from "@tiptap/extension-code";
|
|
||||||
import { TextAlign } from "@tiptap/extension-text-align";
|
import { TextAlign } from "@tiptap/extension-text-align";
|
||||||
import { TaskList, TaskItem } from "@tiptap/extension-list";
|
import { TaskList, TaskItem } from "@tiptap/extension-list";
|
||||||
import { Placeholder, CharacterCount, UndoRedo } from "@tiptap/extensions";
|
import { Placeholder, CharacterCount, UndoRedo } from "@tiptap/extensions";
|
||||||
@@ -67,6 +66,7 @@ import {
|
|||||||
FootnoteReference,
|
FootnoteReference,
|
||||||
FootnotesList,
|
FootnotesList,
|
||||||
FootnoteDefinition,
|
FootnoteDefinition,
|
||||||
|
Code,
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
randomElement,
|
randomElement,
|
||||||
@@ -153,6 +153,10 @@ export const mainExtensions = [
|
|||||||
codeBlock: false,
|
codeBlock: false,
|
||||||
code: false,
|
code: false,
|
||||||
}),
|
}),
|
||||||
|
// Base `Code` comes from @docmost/editor-ext, which overrides `excludes: ""`
|
||||||
|
// (#515) so inline code can co-occur with bold/italic/… — the SINGLE shared
|
||||||
|
// source also used by the collab server and comment editor. Here we keep the
|
||||||
|
// existing client-only behavior on top of it:
|
||||||
// Override TipTap's Code extension to fix the inline code input rule.
|
// Override TipTap's Code extension to fix the inline code input rule.
|
||||||
// The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character
|
// The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character
|
||||||
// before the opening backtick as part of the match, causing markInputRule
|
// before the opening backtick as part of the match, causing markInputRule
|
||||||
|
|||||||
@@ -31,18 +31,11 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
|||||||
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
|
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
import {
|
import {
|
||||||
collabProviderAtom,
|
|
||||||
currentPageEditModeAtom,
|
currentPageEditModeAtom,
|
||||||
dictationAvailabilityAtom,
|
dictationAvailabilityAtom,
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms";
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import {
|
|
||||||
VERSION_SAVED_MESSAGE_TYPE,
|
|
||||||
type VersionSavedMessage,
|
|
||||||
saveVersionPending,
|
|
||||||
} from "@/features/page-history/version-messages";
|
|
||||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||||
import {
|
import {
|
||||||
activeCommentIdAtom,
|
activeCommentIdAtom,
|
||||||
@@ -131,7 +124,6 @@ export default function PageEditor({
|
|||||||
|
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const [, setEditor] = useAtom(pageEditorAtom);
|
const [, setEditor] = useAtom(pageEditorAtom);
|
||||||
const setCollabProvider = useSetAtom(collabProviderAtom);
|
|
||||||
const [, setAsideState] = useAtom(asideStateAtom);
|
const [, setAsideState] = useAtom(asideStateAtom);
|
||||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||||
@@ -189,24 +181,6 @@ export default function PageEditor({
|
|||||||
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
|
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(payload);
|
const message = JSON.parse(payload);
|
||||||
// #370 — a version was saved somewhere; live-refresh the history panel
|
|
||||||
// on every client. Only the client that pressed Save (tracked by the
|
|
||||||
// module-level flag) shows the confirmation toast.
|
|
||||||
if (message?.type === VERSION_SAVED_MESSAGE_TYPE) {
|
|
||||||
const versionMsg = message as VersionSavedMessage;
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["page-history-list"],
|
|
||||||
});
|
|
||||||
if (saveVersionPending.current) {
|
|
||||||
saveVersionPending.current = false;
|
|
||||||
notifications.show({
|
|
||||||
message: versionMsg.alreadySaved
|
|
||||||
? t("Already saved as the latest version")
|
|
||||||
: t("Version saved"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (message?.type !== "page.updated" || !message.updatedAt) return;
|
if (message?.type !== "page.updated" || !message.updatedAt) return;
|
||||||
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
||||||
if (pageData) {
|
if (pageData) {
|
||||||
@@ -264,16 +238,12 @@ export default function PageEditor({
|
|||||||
|
|
||||||
local.on("synced", onLocalSyncedHandler);
|
local.on("synced", onLocalSyncedHandler);
|
||||||
providersRef.current = { socket, local, remote };
|
providersRef.current = { socket, local, remote };
|
||||||
// #370 — publish the provider so the header menu can emit save-version.
|
|
||||||
setCollabProvider(remote);
|
|
||||||
setProvidersReady(true);
|
setProvidersReady(true);
|
||||||
} else {
|
} else {
|
||||||
setCollabProvider(providersRef.current.remote);
|
|
||||||
setProvidersReady(true);
|
setProvidersReady(true);
|
||||||
}
|
}
|
||||||
// Only destroy on final unmount
|
// Only destroy on final unmount
|
||||||
return () => {
|
return () => {
|
||||||
setCollabProvider(null);
|
|
||||||
providersRef.current?.socket.destroy();
|
providersRef.current?.socket.destroy();
|
||||||
providersRef.current?.remote.destroy();
|
providersRef.current?.remote.destroy();
|
||||||
providersRef.current?.local.destroy();
|
providersRef.current?.local.destroy();
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import {
|
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
|
||||||
Text,
|
|
||||||
Group,
|
|
||||||
UnstyledButton,
|
|
||||||
Avatar,
|
|
||||||
Tooltip,
|
|
||||||
Badge,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
||||||
import { formattedDate } from "@/lib/time";
|
import { formattedDate } from "@/lib/time";
|
||||||
@@ -14,59 +7,36 @@ import clsx from "clsx";
|
|||||||
import { IPageHistory } from "@/features/page-history/types/page.types";
|
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||||
import { memo, useCallback } from "react";
|
import { memo, useCallback } from "react";
|
||||||
import { useSetAtom } from "jotai";
|
import { useSetAtom } from "jotai";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||||
|
|
||||||
const MAX_VISIBLE_AVATARS = 5;
|
const MAX_VISIBLE_AVATARS = 5;
|
||||||
|
|
||||||
/**
|
|
||||||
* #370 — map a snapshot's intentionality tier to its badge. `version: true`
|
|
||||||
* marks the intentional points (manual / agent); autosaves (boundary / idle /
|
|
||||||
* legacy null) are non-versions and get dimmed in the list.
|
|
||||||
*/
|
|
||||||
type HistoryKindMeta = { labelKey: string; color: string; version: boolean };
|
|
||||||
export function historyKindMeta(kind?: string | null): HistoryKindMeta {
|
|
||||||
switch (kind) {
|
|
||||||
case "manual":
|
|
||||||
return { labelKey: "Saved", color: "blue", version: true };
|
|
||||||
case "agent":
|
|
||||||
return { labelKey: "Agent version", color: "violet", version: true };
|
|
||||||
case "boundary":
|
|
||||||
return { labelKey: "Boundary", color: "gray", version: false };
|
|
||||||
default: // "idle" | null | undefined (legacy autosave)
|
|
||||||
return { labelKey: "Autosave", color: "gray", version: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HistoryItemProps {
|
interface HistoryItemProps {
|
||||||
historyItem: IPageHistory;
|
historyItem: IPageHistory;
|
||||||
// The previous snapshot for diff/restore is resolved by id from the FULL list
|
index: number;
|
||||||
// in the parent (resolvePrevSnapshotId), so the item only needs to report its
|
onSelect: (id: string, index: number) => void;
|
||||||
// own id — never a list index (which would be the filtered-view index).
|
onHover?: (id: string, index: number) => void;
|
||||||
onSelect: (id: string) => void;
|
|
||||||
onHover?: (id: string) => void;
|
|
||||||
onHoverEnd?: () => void;
|
onHoverEnd?: () => void;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HistoryItem = memo(function HistoryItem({
|
const HistoryItem = memo(function HistoryItem({
|
||||||
historyItem,
|
historyItem,
|
||||||
|
index,
|
||||||
onSelect,
|
onSelect,
|
||||||
onHover,
|
onHover,
|
||||||
onHoverEnd,
|
onHoverEnd,
|
||||||
isActive,
|
isActive,
|
||||||
}: HistoryItemProps) {
|
}: HistoryItemProps) {
|
||||||
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
||||||
const { t } = useTranslation();
|
|
||||||
const kindMeta = historyKindMeta(historyItem.kind);
|
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
onSelect(historyItem.id);
|
onSelect(historyItem.id, index);
|
||||||
}, [onSelect, historyItem.id]);
|
}, [onSelect, historyItem.id, index]);
|
||||||
|
|
||||||
const handleMouseEnter = useCallback(() => {
|
const handleMouseEnter = useCallback(() => {
|
||||||
onHover?.(historyItem.id);
|
onHover?.(historyItem.id, index);
|
||||||
}, [onHover, historyItem.id]);
|
}, [onHover, historyItem.id, index]);
|
||||||
|
|
||||||
const contributors = historyItem.contributors;
|
const contributors = historyItem.contributors;
|
||||||
const hasContributors = contributors && contributors.length > 0;
|
const hasContributors = contributors && contributors.length > 0;
|
||||||
@@ -79,20 +49,8 @@ const HistoryItem = memo(function HistoryItem({
|
|||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onMouseLeave={onHoverEnd}
|
onMouseLeave={onHoverEnd}
|
||||||
className={clsx(classes.history, { [classes.active]: isActive })}
|
className={clsx(classes.history, { [classes.active]: isActive })}
|
||||||
// #370 — dim autosnapshots so intentional versions stand out.
|
|
||||||
style={{ opacity: kindMeta.version ? 1 : 0.55 }}
|
|
||||||
>
|
>
|
||||||
<Group gap={6} wrap="nowrap" justify="space-between">
|
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
|
||||||
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
|
|
||||||
<Badge
|
|
||||||
size="xs"
|
|
||||||
radius="sm"
|
|
||||||
variant={kindMeta.version ? "filled" : "light"}
|
|
||||||
color={kindMeta.color}
|
|
||||||
>
|
|
||||||
{t(kindMeta.labelKey)}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Group gap={6} wrap="nowrap" mt={4}>
|
<Group gap={6} wrap="nowrap" mt={4}>
|
||||||
{hasContributors ? (
|
{hasContributors ? (
|
||||||
|
|||||||
@@ -2,16 +2,14 @@ import {
|
|||||||
usePageHistoryListQuery,
|
usePageHistoryListQuery,
|
||||||
prefetchPageHistory,
|
prefetchPageHistory,
|
||||||
} from "@/features/page-history/queries/page-history-query";
|
} from "@/features/page-history/queries/page-history-query";
|
||||||
import HistoryItem, {
|
import HistoryItem from "@/features/page-history/components/history-item";
|
||||||
historyKindMeta,
|
|
||||||
} from "@/features/page-history/components/history-item";
|
|
||||||
import {
|
import {
|
||||||
activeHistoryIdAtom,
|
activeHistoryIdAtom,
|
||||||
activeHistoryPrevIdAtom,
|
activeHistoryPrevIdAtom,
|
||||||
historyAtoms,
|
historyAtoms,
|
||||||
} from "@/features/page-history/atoms/history-atoms";
|
} from "@/features/page-history/atoms/history-atoms";
|
||||||
import { useAtom, useSetAtom } from "jotai";
|
import { useAtom, useSetAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
@@ -19,12 +17,9 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
Loader,
|
Loader,
|
||||||
Center,
|
Center,
|
||||||
Switch,
|
|
||||||
Text,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useHistoryRestore } from "@/features/page-history/hooks";
|
import { useHistoryRestore } from "@/features/page-history/hooks";
|
||||||
import { resolvePrevSnapshotId } from "@/features/page-history/utils/resolve-prev-snapshot";
|
|
||||||
|
|
||||||
const PREFETCH_DELAY_MS = 150;
|
const PREFETCH_DELAY_MS = 150;
|
||||||
|
|
||||||
@@ -52,22 +47,6 @@ function HistoryList({ pageId }: Props) {
|
|||||||
[pageHistoryData],
|
[pageHistoryData],
|
||||||
);
|
);
|
||||||
|
|
||||||
// #370 — "only versions" filter: hide autosnapshots (idle/boundary/legacy
|
|
||||||
// null), keep only intentional points (manual/agent). Filtering is over the
|
|
||||||
// already-loaded pages; the diff/restore still targets the true previous
|
|
||||||
// snapshot, so items carry their index within the FULL list.
|
|
||||||
const [onlyVersions, setOnlyVersions] = useState(false);
|
|
||||||
// Reuse historyKindMeta().version — the SAME predicate the badge (HistoryItem)
|
|
||||||
// uses to mark intentional points — so the "Only versions" filter and the badge
|
|
||||||
// can never drift apart when a future intentional kind is added.
|
|
||||||
const visibleItems = useMemo(
|
|
||||||
() =>
|
|
||||||
onlyVersions
|
|
||||||
? historyItems.filter((item) => historyKindMeta(item.kind).version)
|
|
||||||
: historyItems,
|
|
||||||
[historyItems, onlyVersions],
|
|
||||||
);
|
|
||||||
|
|
||||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||||
const prefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const prefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
@@ -81,13 +60,11 @@ function HistoryList({ pageId }: Props) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleHover = useCallback(
|
const handleHover = useCallback(
|
||||||
(historyId: string) => {
|
(historyId: string, index: number) => {
|
||||||
clearPrefetchTimeout();
|
clearPrefetchTimeout();
|
||||||
prefetchTimeoutRef.current = setTimeout(() => {
|
prefetchTimeoutRef.current = setTimeout(() => {
|
||||||
prefetchPageHistory(historyId);
|
prefetchPageHistory(historyId);
|
||||||
// The true previous snapshot in the FULL list (not the previous visible
|
const prevId = historyItems[index + 1]?.id;
|
||||||
// one under the "only versions" filter).
|
|
||||||
const prevId = resolvePrevSnapshotId(historyItems, historyId);
|
|
||||||
if (prevId) {
|
if (prevId) {
|
||||||
prefetchPageHistory(prevId);
|
prefetchPageHistory(prevId);
|
||||||
}
|
}
|
||||||
@@ -101,11 +78,9 @@ function HistoryList({ pageId }: Props) {
|
|||||||
}, [clearPrefetchTimeout]);
|
}, [clearPrefetchTimeout]);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(id: string) => {
|
(id: string, index: number) => {
|
||||||
setActiveHistoryId(id);
|
setActiveHistoryId(id);
|
||||||
// Baseline = true previous snapshot in the FULL list, so the "only
|
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
|
||||||
// versions" filter never diffs/restores against the wrong item.
|
|
||||||
setActiveHistoryPrevId(resolvePrevSnapshotId(historyItems, id));
|
|
||||||
},
|
},
|
||||||
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
|
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
|
||||||
);
|
);
|
||||||
@@ -153,27 +128,12 @@ function HistoryList({ pageId }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Group px="xs" py={6} justify="flex-end">
|
|
||||||
<Switch
|
|
||||||
size="xs"
|
|
||||||
checked={onlyVersions}
|
|
||||||
onChange={(e) => setOnlyVersions(e.currentTarget.checked)}
|
|
||||||
label={t("Only versions")}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<ScrollArea h={620} w="100%" type="scroll" scrollbarSize={5}>
|
<ScrollArea h={620} w="100%" type="scroll" scrollbarSize={5}>
|
||||||
{onlyVersions && visibleItems.length === 0 && (
|
{historyItems.map((historyItem, index) => (
|
||||||
<Center py="md">
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("No saved versions yet.")}
|
|
||||||
</Text>
|
|
||||||
</Center>
|
|
||||||
)}
|
|
||||||
{visibleItems.map((historyItem) => (
|
|
||||||
<HistoryItem
|
<HistoryItem
|
||||||
key={historyItem.id}
|
key={historyItem.id}
|
||||||
historyItem={historyItem}
|
historyItem={historyItem}
|
||||||
|
index={index}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onHover={handleHover}
|
onHover={handleHover}
|
||||||
onHoverEnd={clearPrefetchTimeout}
|
onHoverEnd={clearPrefetchTimeout}
|
||||||
|
|||||||
@@ -24,10 +24,6 @@ export interface IPageHistory {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
lastUpdatedBy: IPageHistoryUser;
|
lastUpdatedBy: IPageHistoryUser;
|
||||||
contributors?: IPageHistoryUser[];
|
contributors?: IPageHistoryUser[];
|
||||||
// #370 — intentionality tier: 'manual'/'agent' are versions (intentional
|
|
||||||
// points), 'idle'/'boundary' are autosnapshots; null/undefined = legacy
|
|
||||||
// autosave. Derived server-side, drives the history badge + "versions" filter.
|
|
||||||
kind?: "manual" | "agent" | "idle" | "boundary" | null;
|
|
||||||
// Provenance markers copied off the page row when the snapshot was saved.
|
// Provenance markers copied off the page row when the snapshot was saved.
|
||||||
// `'agent'` marks a version written by the AI agent; `lastUpdatedAiChatId`
|
// `'agent'` marks a version written by the AI agent; `lastUpdatedAiChatId`
|
||||||
// (when present) deep-links to the chat that produced the edit.
|
// (when present) deep-links to the chat that produced the edit.
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { resolvePrevSnapshotId } from "./resolve-prev-snapshot";
|
|
||||||
|
|
||||||
// #370 F4 — the risky client path: with the "only versions" filter active, diff
|
|
||||||
// and restore must still baseline against the TRUE previous snapshot in the FULL
|
|
||||||
// list, never the previous VISIBLE version (which would skip the autosnapshots
|
|
||||||
// between two versions). These pin that the resolution is by FULL-list order.
|
|
||||||
describe("resolvePrevSnapshotId", () => {
|
|
||||||
// Newest-first, as the history list stores it: a version, then two autosaves,
|
|
||||||
// then an older version.
|
|
||||||
const full = [
|
|
||||||
{ id: "v2", kind: "manual" },
|
|
||||||
{ id: "a2", kind: "idle" },
|
|
||||||
{ id: "a1", kind: "boundary" },
|
|
||||||
{ id: "v1", kind: "manual" },
|
|
||||||
{ id: "a0", kind: null },
|
|
||||||
];
|
|
||||||
|
|
||||||
it("returns the immediate FULL-list successor, not the previous visible version", () => {
|
|
||||||
// Selecting v2 while filtered to versions-only must baseline against a2 (the
|
|
||||||
// real chronological predecessor), NOT v1 (the previous visible version).
|
|
||||||
expect(resolvePrevSnapshotId(full, "v2")).toBe("a2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolves an autosnapshot's predecessor by full-list order", () => {
|
|
||||||
expect(resolvePrevSnapshotId(full, "a1")).toBe("v1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns '' for the oldest item (no predecessor)", () => {
|
|
||||||
expect(resolvePrevSnapshotId(full, "a0")).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns '' for an id not in the list", () => {
|
|
||||||
expect(resolvePrevSnapshotId(full, "missing")).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not depend on a filtered subset — same result whatever is visible", () => {
|
|
||||||
// The helper only ever sees the full list; a filtered view cannot change the
|
|
||||||
// baseline it computes.
|
|
||||||
expect(resolvePrevSnapshotId(full, "v1")).toBe("a0");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* #370 — resolve the TRUE previous snapshot for a history item.
|
|
||||||
*
|
|
||||||
* The history panel can be filtered to "only versions" (manual/agent), but diff
|
|
||||||
* and restore must always compare against the immediately-preceding snapshot in
|
|
||||||
* the FULL, unfiltered list — NOT the previous VISIBLE item. Comparing against
|
|
||||||
* the previous visible version would silently skip the autosnapshots between two
|
|
||||||
* versions and diff/restore the wrong baseline.
|
|
||||||
*
|
|
||||||
* Given the full (newest-first) list and an item id, this returns the id of the
|
|
||||||
* item right after it in the full list (its chronological predecessor), or "" if
|
|
||||||
* it is the oldest / not found. Pure and list-order-preserving so it can be unit
|
|
||||||
* tested without mounting the component.
|
|
||||||
*/
|
|
||||||
export function resolvePrevSnapshotId(
|
|
||||||
fullItems: ReadonlyArray<{ id: string }>,
|
|
||||||
id: string,
|
|
||||||
): string {
|
|
||||||
const index = fullItems.findIndex((item) => item.id === id);
|
|
||||||
if (index === -1) return "";
|
|
||||||
return fullItems[index + 1]?.id ?? "";
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/**
|
|
||||||
* #370 — page-version stateless wire formats. Kept in one place so the client
|
|
||||||
* emitter (Save hotkey / button) and the client listener (page-editor) agree
|
|
||||||
* with the server (PersistenceExtension) on the message shapes.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Client → server: "save a version now". The server derives the tier
|
|
||||||
* (manual/agent) from the signed connection actor, never from this payload. */
|
|
||||||
export const SAVE_VERSION_MESSAGE_TYPE = "save-version";
|
|
||||||
|
|
||||||
/** Server → all clients: a version was saved (or promoted / already existed). */
|
|
||||||
export const VERSION_SAVED_MESSAGE_TYPE = "version.saved";
|
|
||||||
|
|
||||||
export interface VersionSavedMessage {
|
|
||||||
type: typeof VERSION_SAVED_MESSAGE_TYPE;
|
|
||||||
historyId: string;
|
|
||||||
kind: "manual" | "agent";
|
|
||||||
/** True when the latest snapshot was already a manual version (a no-op save). */
|
|
||||||
alreadySaved: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cross-component coordination flag so only the client that pressed Save shows
|
|
||||||
* the confirmation toast, while every other client silently refreshes its
|
|
||||||
* history panel on the broadcast. A module-level ref avoids stale-closure
|
|
||||||
* pitfalls in the editor's long-lived stateless handler.
|
|
||||||
*/
|
|
||||||
export const saveVersionPending = { current: false };
|
|
||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
IconArrowRight,
|
IconArrowRight,
|
||||||
IconArrowsHorizontal,
|
IconArrowsHorizontal,
|
||||||
IconClockHour4,
|
IconClockHour4,
|
||||||
IconDeviceFloppy,
|
|
||||||
IconDots,
|
IconDots,
|
||||||
IconEye,
|
IconEye,
|
||||||
IconEyeOff,
|
IconEyeOff,
|
||||||
@@ -18,7 +17,7 @@ import {
|
|||||||
IconTrash,
|
IconTrash,
|
||||||
IconWifiOff,
|
IconWifiOff,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx";
|
import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||||
@@ -40,14 +39,9 @@ import { Trans, useTranslation } from "react-i18next";
|
|||||||
import ExportModal from "@/components/common/export-modal";
|
import ExportModal from "@/components/common/export-modal";
|
||||||
import { convertProseMirrorToMarkdown } from "@docmost/prosemirror-markdown/browser";
|
import { convertProseMirrorToMarkdown } from "@docmost/prosemirror-markdown/browser";
|
||||||
import {
|
import {
|
||||||
collabProviderAtom,
|
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
import {
|
|
||||||
SAVE_VERSION_MESSAGE_TYPE,
|
|
||||||
saveVersionPending,
|
|
||||||
} from "@/features/page-history/version-messages.ts";
|
|
||||||
import { formattedDate } from "@/lib/time.ts";
|
import { formattedDate } from "@/lib/time.ts";
|
||||||
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
|
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
|
||||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||||
@@ -78,34 +72,9 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
});
|
});
|
||||||
const isDeleted = !!page?.deletedAt;
|
const isDeleted = !!page?.deletedAt;
|
||||||
const [workspace] = useAtom(workspaceAtom);
|
const [workspace] = useAtom(workspaceAtom);
|
||||||
const collabProvider = useAtomValue(collabProviderAtom);
|
|
||||||
// Community public-sharing entry point (replaces the removed EE PageShareModal)
|
// Community public-sharing entry point (replaces the removed EE PageShareModal)
|
||||||
const workspaceSharingDisabled = workspace?.settings?.sharing?.disabled === true;
|
const workspaceSharingDisabled = workspace?.settings?.sharing?.disabled === true;
|
||||||
|
|
||||||
// #370 — explicit "save a version" (Cmd+S / Save button). One path for the
|
|
||||||
// human; the server derives the tier from the signed actor. Readers can't save
|
|
||||||
// (the button is hidden and the collab connection is read-only server-side).
|
|
||||||
const handleSaveVersion = useCallback(() => {
|
|
||||||
if (readOnly || !collabProvider) return;
|
|
||||||
// Flag this client as the initiator so only it shows the confirmation toast;
|
|
||||||
// a safety timeout clears it if no broadcast comes back (e.g. offline).
|
|
||||||
saveVersionPending.current = true;
|
|
||||||
window.setTimeout(() => {
|
|
||||||
saveVersionPending.current = false;
|
|
||||||
}, 5000);
|
|
||||||
collabProvider.sendStateless(
|
|
||||||
JSON.stringify({ type: SAVE_VERSION_MESSAGE_TYPE }),
|
|
||||||
);
|
|
||||||
}, [readOnly, collabProvider]);
|
|
||||||
|
|
||||||
// mod+S must also block the browser's "Save page" dialog. `triggerOnContent-
|
|
||||||
// Editable` + empty ignore-list so it fires while typing in the editor/title.
|
|
||||||
useHotkeys(
|
|
||||||
[["mod+S", handleSaveVersion, { preventDefault: true }]],
|
|
||||||
[],
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
@@ -164,16 +133,15 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<PageActionMenu readOnly={readOnly} onSaveVersion={handleSaveVersion} />
|
<PageActionMenu readOnly={readOnly} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PageActionMenuProps {
|
interface PageActionMenuProps {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
onSaveVersion?: () => void;
|
|
||||||
}
|
}
|
||||||
function PageActionMenu({ readOnly, onSaveVersion }: PageActionMenuProps) {
|
function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [, setHistoryModalOpen] = useAtom(historyAtoms);
|
const [, setHistoryModalOpen] = useAtom(historyAtoms);
|
||||||
const clipboard = useClipboard({ timeout: 500 });
|
const clipboard = useClipboard({ timeout: 500 });
|
||||||
@@ -335,20 +303,6 @@ function PageActionMenu({ readOnly, onSaveVersion }: PageActionMenuProps) {
|
|||||||
</Group>
|
</Group>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
{!readOnly && (
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconDeviceFloppy size={16} />}
|
|
||||||
onClick={onSaveVersion}
|
|
||||||
rightSection={
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("Ctrl+S")}
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("Save version")}
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconHistory size={16} />}
|
leftSection={<IconHistory size={16} />}
|
||||||
onClick={openHistoryModal}
|
onClick={openHistoryModal}
|
||||||
|
|||||||
@@ -281,12 +281,10 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
|
|||||||
setOpenTreeNodes((prev) => ({ ...prev, [id]: isOpen }));
|
setOpenTreeNodes((prev) => ({ ...prev, [id]: isOpen }));
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
const node = treeModel.find(data, id) as SpaceTreeNode | null;
|
const node = treeModel.find(data, id) as SpaceTreeNode | null;
|
||||||
// Same "unloaded branch" predicate the realtime insert paths use
|
if (
|
||||||
// (`isUnloadedBranch`) so the lazy-load gate and the realtime inserts
|
node?.hasChildren &&
|
||||||
// (`insertByPosition` / `placeByPosition`) can never disagree about what
|
(!node.children || node.children.length === 0)
|
||||||
// counts as unloaded (#525). Note: local raw `insert` (DnD/create-page)
|
) {
|
||||||
// does not yet route through it — see #525 follow-up.
|
|
||||||
if (treeModel.isUnloadedBranch(node)) {
|
|
||||||
const fetched = await fetchAllAncestorChildren({
|
const fetched = await fetchAllAncestorChildren({
|
||||||
pageId: id,
|
pageId: id,
|
||||||
spaceId: node.spaceId,
|
spaceId: node.spaceId,
|
||||||
|
|||||||
@@ -74,48 +74,6 @@ describe("treeModel.isDescendant", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// #525: the single "is this branch unloaded?" predicate shared by the lazy-load
|
|
||||||
// gate and the insert paths. Unloaded == server says hasChildren but none are
|
|
||||||
// present locally (canonical form `children: []`, also `undefined`). A parent
|
|
||||||
// without hasChildren is genuinely empty, not unloaded.
|
|
||||||
describe("treeModel.isUnloadedBranch", () => {
|
|
||||||
type PH = TreeNode<{ name: string; hasChildren?: boolean }>;
|
|
||||||
it("true for hasChildren + empty array (canonical unloaded form)", () => {
|
|
||||||
const n: PH = { id: "p", name: "P", hasChildren: true, children: [] };
|
|
||||||
expect(treeModel.isUnloadedBranch(n)).toBe(true);
|
|
||||||
});
|
|
||||||
it("true for hasChildren + undefined children", () => {
|
|
||||||
const n: PH = { id: "p", name: "P", hasChildren: true };
|
|
||||||
expect(treeModel.isUnloadedBranch(n)).toBe(true);
|
|
||||||
});
|
|
||||||
it("false for hasChildren + already-loaded children", () => {
|
|
||||||
const n: PH = {
|
|
||||||
id: "p",
|
|
||||||
name: "P",
|
|
||||||
hasChildren: true,
|
|
||||||
children: [{ id: "c", name: "C" }],
|
|
||||||
};
|
|
||||||
expect(treeModel.isUnloadedBranch(n)).toBe(false);
|
|
||||||
});
|
|
||||||
it("false for a genuinely-empty parent (no hasChildren)", () => {
|
|
||||||
expect(
|
|
||||||
treeModel.isUnloadedBranch({
|
|
||||||
id: "p",
|
|
||||||
name: "P",
|
|
||||||
hasChildren: false,
|
|
||||||
children: [],
|
|
||||||
} as PH),
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
treeModel.isUnloadedBranch({ id: "p", name: "P" } as PH),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
it("false for null/undefined", () => {
|
|
||||||
expect(treeModel.isUnloadedBranch(null)).toBe(false);
|
|
||||||
expect(treeModel.isUnloadedBranch(undefined)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("treeModel.visible", () => {
|
describe("treeModel.visible", () => {
|
||||||
it("returns only root nodes when no openIds", () => {
|
it("returns only root nodes when no openIds", () => {
|
||||||
const v = treeModel.visible(fixture, new Set());
|
const v = treeModel.visible(fixture, new Set());
|
||||||
@@ -239,64 +197,43 @@ describe("treeModel.insertByPosition", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
type PH = TreeNode<{
|
// #159 #1: inserting/moving a node under a parent whose children are NOT
|
||||||
name: string;
|
// loaded (`children === undefined`, e.g. a collapsed page) must NOT materialize
|
||||||
position?: string;
|
// a partial `[node]` list — that would defeat the lazy-load gate and hide the
|
||||||
hasChildren?: boolean;
|
// parent's other real children. The node is left to be lazy-loaded; only
|
||||||
}>;
|
// `hasChildren` is flagged so the chevron appears.
|
||||||
|
it("does NOT materialize a child under an UNLOADED parent (children undefined)", () => {
|
||||||
// #159 #1 / #525: inserting/moving a node under an UNLOADED parent must NOT
|
type PH = TreeNode<{
|
||||||
// materialize a partial `[node]` list — that would defeat the lazy-load gate and
|
name: string;
|
||||||
// hide the parent's other real children. The canonical unloaded form here is
|
position?: string;
|
||||||
// `children: []` + `hasChildren: true` (from `pageToTreeNode` /
|
hasChildren?: boolean;
|
||||||
// `pruneCollapsedChildren`), which the pre-#525 `=== undefined` guard MISSED.
|
}>;
|
||||||
// The node is left to be lazy-loaded; the chevron stays enabled.
|
|
||||||
it("does NOT materialize a child under an UNLOADED parent (children: [], hasChildren: true)", () => {
|
|
||||||
const tree: PH[] = [
|
|
||||||
{ id: "p", name: "P", position: "a0", hasChildren: true, children: [] },
|
|
||||||
];
|
|
||||||
const node: PH = { id: "x", name: "X", position: "a1" };
|
|
||||||
const t = treeModel.insertByPosition(tree, "p", node);
|
|
||||||
const parent = treeModel.find(t, "p");
|
|
||||||
// The node was NOT inserted (children stay unloaded -> lazy-load fetches the
|
|
||||||
// full set, including this node, on expand). MUTATION: the pre-#525 predicate
|
|
||||||
// `children === undefined` does not fire for `[]`, so it would insert `[x]`
|
|
||||||
// here and reredden this expectation.
|
|
||||||
expect(parent?.children).toEqual([]);
|
|
||||||
expect(treeModel.find(t, "x")).toBeNull();
|
|
||||||
// ...and the chevron stays enabled so the user can expand to load it.
|
|
||||||
expect((parent as PH).hasChildren).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT materialize a child under an UNLOADED parent (children undefined, hasChildren: true)", () => {
|
|
||||||
const tree: PH[] = [
|
|
||||||
{ id: "p", name: "P", position: "a0", hasChildren: true }, // children: undefined
|
|
||||||
];
|
|
||||||
const node: PH = { id: "x", name: "X", position: "a1" };
|
|
||||||
const t = treeModel.insertByPosition(tree, "p", node);
|
|
||||||
const parent = treeModel.find(t, "p");
|
|
||||||
expect(parent?.children).toBeUndefined();
|
|
||||||
expect(treeModel.find(t, "x")).toBeNull();
|
|
||||||
expect((parent as PH).hasChildren).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("DOES insert under a genuinely-empty parent (children: [], hasChildren: false)", () => {
|
|
||||||
const tree: PH[] = [
|
|
||||||
{ id: "p", name: "P", position: "a0", hasChildren: false, children: [] },
|
|
||||||
];
|
|
||||||
const node: PH = { id: "x", name: "X", position: "a1" };
|
|
||||||
const t = treeModel.insertByPosition(tree, "p", node);
|
|
||||||
// No server children (`hasChildren: false`), so materializing the first child
|
|
||||||
// is correct — nothing is hidden.
|
|
||||||
expect(treeModel.find(t, "p")?.children?.map((n) => n.id)).toEqual(["x"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("DOES insert under a genuinely-empty parent (children undefined, hasChildren: false)", () => {
|
|
||||||
const tree: PH[] = [
|
const tree: PH[] = [
|
||||||
{ id: "p", name: "P", position: "a0", hasChildren: false }, // children: undefined
|
{ id: "p", name: "P", position: "a0", hasChildren: false }, // children: undefined
|
||||||
];
|
];
|
||||||
const node: PH = { id: "x", name: "X", position: "a1" };
|
const node: PH = { id: "x", name: "X", position: "a1" };
|
||||||
const t = treeModel.insertByPosition(tree, "p", node);
|
const t = treeModel.insertByPosition(tree, "p", node);
|
||||||
|
const parent = treeModel.find(t, "p");
|
||||||
|
// The node was NOT inserted (children stay unloaded -> lazy-load fetches the
|
||||||
|
// full set, including this node, on expand).
|
||||||
|
expect(parent?.children).toBeUndefined();
|
||||||
|
expect(treeModel.find(t, "x")).toBeNull();
|
||||||
|
// ...but the chevron is enabled so the user can expand to load it.
|
||||||
|
expect((parent as PH).hasChildren).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DOES insert under a LOADED-but-empty parent (children: [])", () => {
|
||||||
|
type PH = TreeNode<{
|
||||||
|
name: string;
|
||||||
|
position?: string;
|
||||||
|
hasChildren?: boolean;
|
||||||
|
}>;
|
||||||
|
const tree: PH[] = [
|
||||||
|
{ id: "p", name: "P", position: "a0", hasChildren: false, children: [] },
|
||||||
|
];
|
||||||
|
const node: PH = { id: "x", name: "X", position: "a1" };
|
||||||
|
const t = treeModel.insertByPosition(tree, "p", node);
|
||||||
|
// A loaded (empty) child list is complete, so the node IS inserted.
|
||||||
expect(treeModel.find(t, "p")?.children?.map((n) => n.id)).toEqual(["x"]);
|
expect(treeModel.find(t, "p")?.children?.map((n) => n.id)).toEqual(["x"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -43,26 +43,6 @@ export const treeModel = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
// A branch is "unloaded" when the server says it HAS children (`hasChildren`)
|
|
||||||
// but none are present locally. The canonical unloaded form in this codebase
|
|
||||||
// is `children: []` (produced by `pageToTreeNode` and by `pruneCollapsedChildren`
|
|
||||||
// resetting collapsed branches), NOT `children: undefined` — so a predicate that
|
|
||||||
// only checks `=== undefined` misses the real case and materializes a misleading
|
|
||||||
// partial list (#525). This is the SINGLE source of truth for "should a
|
|
||||||
// fetch/materialize be deferred?", shared by the lazy-load gate (`handleToggle`)
|
|
||||||
// and the realtime insert paths (`insertByPosition` / `placeByPosition`), so they
|
|
||||||
// can never drift apart again. (The local raw `insert` primitive and its DnD/
|
|
||||||
// create-page callers do NOT yet route through this predicate — see #525
|
|
||||||
// follow-up.) A parent WITHOUT `hasChildren` is genuinely empty
|
|
||||||
// (no server children) — inserting its first child is correct, not deferred.
|
|
||||||
isUnloadedBranch<T extends object>(
|
|
||||||
node: TreeNode<T> | null | undefined,
|
|
||||||
): boolean {
|
|
||||||
if (!node) return false;
|
|
||||||
const hasChildren = (node as { hasChildren?: boolean }).hasChildren === true;
|
|
||||||
return hasChildren && (node.children == null || node.children.length === 0);
|
|
||||||
},
|
|
||||||
|
|
||||||
isDescendant<T extends object>(
|
isDescendant<T extends object>(
|
||||||
tree: TreeNode<T>[],
|
tree: TreeNode<T>[],
|
||||||
ancestorId: string,
|
ancestorId: string,
|
||||||
@@ -147,15 +127,14 @@ export const treeModel = {
|
|||||||
}
|
}
|
||||||
const parent = treeModel.find(tree, parentId);
|
const parent = treeModel.find(tree, parentId);
|
||||||
// The parent is in the tree but its children have NOT been lazy-loaded yet
|
// The parent is in the tree but its children have NOT been lazy-loaded yet
|
||||||
// (`hasChildren` set + children absent/empty — see `isUnloadedBranch`; the
|
// (`children === undefined`, distinct from a loaded-but-empty `[]`). Inserting
|
||||||
// canonical unloaded form is `children: []`, NOT just `undefined`). Inserting
|
|
||||||
// here would MATERIALIZE a misleading partial child list (`[node]`) that
|
// here would MATERIALIZE a misleading partial child list (`[node]`) that
|
||||||
// defeats the lazy-load gate — which fetches only when children are
|
// defeats the lazy-load gate — which fetches only when children are
|
||||||
// absent/empty — so the parent's OTHER real children would never load and the
|
// absent/empty — so the parent's OTHER real children would never load and the
|
||||||
// moved/added node would be the only one shown (a silent data loss, #159 #1).
|
// moved/added node would be the only one shown (a silent data loss, #159 #1).
|
||||||
// Instead, leave the children unloaded and just flag `hasChildren` so the
|
// Instead, leave the children unloaded and just flag `hasChildren` so the
|
||||||
// chevron appears; expanding fetches the FULL set (including this node).
|
// chevron appears; expanding fetches the FULL set (including this node).
|
||||||
if (parent && treeModel.isUnloadedBranch(parent)) {
|
if (parent && parent.children === undefined) {
|
||||||
return treeModel.update(
|
return treeModel.update(
|
||||||
tree,
|
tree,
|
||||||
parentId,
|
parentId,
|
||||||
|
|||||||
@@ -168,10 +168,6 @@ export default function ShareAiWidget({
|
|||||||
// Anonymous reader: suppress the tool-argument summary line so the
|
// Anonymous reader: suppress the tool-argument summary line so the
|
||||||
// agent's raw query/argument text isn't shown on the public share.
|
// agent's raw query/argument text isn't shown on the public share.
|
||||||
showInput={false}
|
showInput={false}
|
||||||
// Anonymous reader: never paint a tool's raw errorText (it can carry
|
|
||||||
// internal detail). This is the render gate; the bytes are also
|
|
||||||
// sanitized server-side in PublicShareChatToolsService.forShare (#394).
|
|
||||||
showErrors={false}
|
|
||||||
// Anonymous reader: neutralize internal/relative links in the
|
// Anonymous reader: neutralize internal/relative links in the
|
||||||
// assistant's markdown so internal UUIDs/auth-gated routes don't
|
// assistant's markdown so internal UUIDs/auth-gated routes don't
|
||||||
// leak as clickable links (external http(s) links are kept).
|
// leak as clickable links (external http(s) links are kept).
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ let currentAlias: IShareAlias | null = null;
|
|||||||
let availabilityResult: {
|
let availabilityResult: {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
} = { valid: true, available: true };
|
currentPageId: string | null;
|
||||||
|
} = { valid: true, available: true, currentPageId: null };
|
||||||
|
|
||||||
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||||
useShareAliasForPageQuery: () => ({ data: currentAlias }),
|
useShareAliasForPageQuery: () => ({ data: currentAlias }),
|
||||||
@@ -55,7 +56,7 @@ describe("ShareAliasSection — taken-name handling is never a dead end", () =>
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setMutateAsync.mockReset();
|
setMutateAsync.mockReset();
|
||||||
currentAlias = null;
|
currentAlias = null;
|
||||||
availabilityResult = { valid: true, available: true };
|
availabilityResult = { valid: true, available: true, currentPageId: null };
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a 'will move it here' HINT (not a terminal error) when the name belongs to another page, and keeps Save enabled", async () => {
|
it("shows a 'will move it here' HINT (not a terminal error) when the name belongs to another page, and keeps Save enabled", async () => {
|
||||||
@@ -64,6 +65,7 @@ describe("ShareAliasSection — taken-name handling is never a dead end", () =>
|
|||||||
availabilityResult = {
|
availabilityResult = {
|
||||||
valid: true,
|
valid: true,
|
||||||
available: false,
|
available: false,
|
||||||
|
currentPageId: "page-X",
|
||||||
};
|
};
|
||||||
|
|
||||||
renderSection("page-Y");
|
renderSection("page-Y");
|
||||||
@@ -95,6 +97,7 @@ describe("ShareAliasSection — taken-name handling is never a dead end", () =>
|
|||||||
availabilityResult = {
|
availabilityResult = {
|
||||||
valid: true,
|
valid: true,
|
||||||
available: false,
|
available: false,
|
||||||
|
currentPageId: "page-X",
|
||||||
};
|
};
|
||||||
// The server rejects the un-confirmed save asking the client to confirm.
|
// The server rejects the un-confirmed save asking the client to confirm.
|
||||||
setMutateAsync.mockRejectedValueOnce({
|
setMutateAsync.mockRejectedValueOnce({
|
||||||
@@ -103,6 +106,7 @@ describe("ShareAliasSection — taken-name handling is never a dead end", () =>
|
|||||||
status: 409,
|
status: 409,
|
||||||
data: {
|
data: {
|
||||||
code: "ALIAS_REASSIGN_REQUIRED",
|
code: "ALIAS_REASSIGN_REQUIRED",
|
||||||
|
currentPageId: "page-X",
|
||||||
currentPageTitle: "Alias Test Page X",
|
currentPageTitle: "Alias Test Page X",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export default function ShareAliasSection({
|
|||||||
const [availability, setAvailability] = useState<{
|
const [availability, setAvailability] = useState<{
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
|
currentPageId: string | null;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [reassign, setReassign] = useState<{
|
const [reassign, setReassign] = useState<{
|
||||||
alias: string;
|
alias: string;
|
||||||
@@ -75,6 +76,7 @@ export default function ShareAliasSection({
|
|||||||
setAvailability({
|
setAvailability({
|
||||||
valid: res.valid,
|
valid: res.valid,
|
||||||
available: res.available,
|
available: res.available,
|
||||||
|
currentPageId: res.currentPageId,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setAvailability(null);
|
setAvailability(null);
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export interface IShareAliasAvailability {
|
|||||||
alias: string;
|
alias: string;
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
|
currentPageId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISharedPageTree {
|
export interface ISharedPageTree {
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
||||||
import { render, act, cleanup } from "@testing-library/react";
|
|
||||||
import { MemoryRouter, useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
// Mocks for the dirty shell's side-effecting collaborators.
|
|
||||||
vi.mock("@mantine/notifications", () => ({
|
|
||||||
notifications: { show: vi.fn() },
|
|
||||||
}));
|
|
||||||
vi.mock("@/i18n.ts", () => ({ default: { t: (k: string) => k } }));
|
|
||||||
vi.mock("@/lib/reload-guard", () => ({
|
|
||||||
hasAutoReloaded: vi.fn(() => false),
|
|
||||||
markAutoReloaded: vi.fn(() => true),
|
|
||||||
recordReloadBreadcrumb: vi.fn(),
|
|
||||||
takeReloadBreadcrumb: vi.fn(() => null),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { hasAutoReloaded, markAutoReloaded } from "@/lib/reload-guard";
|
|
||||||
import {
|
|
||||||
triggerGuardedReload,
|
|
||||||
useVersionReloadOnNavigation,
|
|
||||||
__resetGuardedReloadForTests,
|
|
||||||
} from "./guarded-reload";
|
|
||||||
|
|
||||||
const show = notifications.show as unknown as ReturnType<typeof vi.fn>;
|
|
||||||
const mockHasAutoReloaded = hasAutoReloaded as unknown as ReturnType<
|
|
||||||
typeof vi.fn
|
|
||||||
>;
|
|
||||||
const mockMarkAutoReloaded = markAutoReloaded as unknown as ReturnType<
|
|
||||||
typeof vi.fn
|
|
||||||
>;
|
|
||||||
|
|
||||||
let reload: ReturnType<typeof vi.fn>;
|
|
||||||
let visibility: DocumentVisibilityState;
|
|
||||||
|
|
||||||
// Test harness mounted inside a router: it installs the navigation hook and
|
|
||||||
// exposes `navigate` so a test can drive an in-app router navigation.
|
|
||||||
let doNavigate: (to: string) => void;
|
|
||||||
function Harness() {
|
|
||||||
useVersionReloadOnNavigation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
doNavigate = navigate;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mountHarness() {
|
|
||||||
render(
|
|
||||||
<MemoryRouter initialEntries={["/start"]}>
|
|
||||||
<Harness />
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateTo(path: string) {
|
|
||||||
act(() => {
|
|
||||||
doNavigate(path);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
__resetGuardedReloadForTests();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockHasAutoReloaded.mockReturnValue(false);
|
|
||||||
mockMarkAutoReloaded.mockReturnValue(true);
|
|
||||||
|
|
||||||
vi.stubGlobal("APP_VERSION", "test-A");
|
|
||||||
|
|
||||||
reload = vi.fn();
|
|
||||||
Object.defineProperty(window, "location", {
|
|
||||||
configurable: true,
|
|
||||||
value: { reload },
|
|
||||||
});
|
|
||||||
|
|
||||||
visibility = "visible";
|
|
||||||
Object.defineProperty(document, "visibilityState", {
|
|
||||||
configurable: true,
|
|
||||||
get: () => visibility,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("triggerGuardedReload (variant C)", () => {
|
|
||||||
it("noop when versions match: no banner, no reload", () => {
|
|
||||||
triggerGuardedReload("test-A");
|
|
||||||
expect(show).not.toHaveBeenCalled();
|
|
||||||
expect(reload).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("noop when the server version is empty (fail-safe)", () => {
|
|
||||||
triggerGuardedReload("");
|
|
||||||
triggerGuardedReload(undefined);
|
|
||||||
expect(show).not.toHaveBeenCalled();
|
|
||||||
expect(reload).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("real mismatch shows the banner but does NOT reload immediately", () => {
|
|
||||||
mountHarness();
|
|
||||||
triggerGuardedReload("test-B");
|
|
||||||
expect(show).toHaveBeenCalledTimes(1);
|
|
||||||
expect(show.mock.calls[0][0]).toMatchObject({
|
|
||||||
id: "app-version-reload",
|
|
||||||
autoClose: false,
|
|
||||||
withCloseButton: true,
|
|
||||||
});
|
|
||||||
expect(reload).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reloads EXACTLY ONCE on the first in-app navigation after a mismatch", () => {
|
|
||||||
mountHarness();
|
|
||||||
triggerGuardedReload("test-B");
|
|
||||||
expect(reload).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
navigateTo("/next");
|
|
||||||
expect(reload).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// A second navigation must NOT reload again (one-shot was consumed).
|
|
||||||
navigateTo("/again");
|
|
||||||
expect(reload).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT reload on a 2nd mismatch after the first was armed/consumed (one-shot)", () => {
|
|
||||||
mountHarness();
|
|
||||||
triggerGuardedReload("test-B");
|
|
||||||
navigateTo("/next");
|
|
||||||
expect(reload).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// Another app-version mismatch arrives (reconnect): must not re-arm.
|
|
||||||
triggerGuardedReload("test-C");
|
|
||||||
navigateTo("/again");
|
|
||||||
expect(reload).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT reload merely from the tab going to the background", () => {
|
|
||||||
mountHarness();
|
|
||||||
triggerGuardedReload("test-B");
|
|
||||||
expect(reload).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
visibility = "hidden";
|
|
||||||
act(() => {
|
|
||||||
document.dispatchEvent(new Event("visibilitychange"));
|
|
||||||
});
|
|
||||||
expect(reload).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a hidden-at-receipt tab is also NOT reloaded immediately (variant C uniform); reloads on next navigation", () => {
|
|
||||||
visibility = "hidden";
|
|
||||||
mountHarness();
|
|
||||||
triggerGuardedReload("test-B");
|
|
||||||
expect(reload).not.toHaveBeenCalled();
|
|
||||||
expect(show).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
navigateTo("/next");
|
|
||||||
expect(reload).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("the banner's Update button reloads immediately", () => {
|
|
||||||
triggerGuardedReload("test-B");
|
|
||||||
const message = show.mock.calls[0][0].message as {
|
|
||||||
props: { onClick: () => void };
|
|
||||||
};
|
|
||||||
message.props.onClick();
|
|
||||||
expect(reload).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("banner-only (auto-reload already spent): banner, never auto-reload on navigation", () => {
|
|
||||||
mockHasAutoReloaded.mockReturnValue(true);
|
|
||||||
mountHarness();
|
|
||||||
triggerGuardedReload("test-B");
|
|
||||||
expect(show).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
navigateTo("/next");
|
|
||||||
expect(reload).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT reload when the flag write fails; falls back to the banner", () => {
|
|
||||||
mockMarkAutoReloaded.mockReturnValue(false);
|
|
||||||
mountHarness();
|
|
||||||
triggerGuardedReload("test-B");
|
|
||||||
navigateTo("/next");
|
|
||||||
expect(reload).not.toHaveBeenCalled();
|
|
||||||
// performAutoReload falls back to showing the banner (initial + fallback).
|
|
||||||
expect(show).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is idempotent within a tab-load: repeated emits do not stack banners", () => {
|
|
||||||
triggerGuardedReload("test-B");
|
|
||||||
triggerGuardedReload("test-B");
|
|
||||||
triggerGuardedReload("test-C");
|
|
||||||
expect(show).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { useLocation } from "react-router-dom";
|
|
||||||
import { Button } from "@mantine/core";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import i18n from "@/i18n.ts";
|
|
||||||
import {
|
|
||||||
hasAutoReloaded,
|
|
||||||
markAutoReloaded,
|
|
||||||
recordReloadBreadcrumb,
|
|
||||||
takeReloadBreadcrumb,
|
|
||||||
} from "@/lib/reload-guard";
|
|
||||||
import { decideVersionAction } from "@/features/user/version-coherence";
|
|
||||||
|
|
||||||
// Dirty shell around the pure `decideVersionAction`: it reads globals
|
|
||||||
// (APP_VERSION), touches sessionStorage via the shared reload-guard, drives the
|
|
||||||
// Mantine notification, and arms the router-navigation reload hook. Kept
|
|
||||||
// separate from the pure module so the decision stays unit-testable without a
|
|
||||||
// DOM.
|
|
||||||
|
|
||||||
// One fixed id so repeated app-version signals (e.g. every reconnect) update a
|
|
||||||
// single banner instead of stacking a new one each time.
|
|
||||||
const BANNER_ID = "app-version-reload";
|
|
||||||
|
|
||||||
// Module-level idempotency for the current tab-load: once a mismatch has been
|
|
||||||
// handled we don't re-arm the navigation reload or re-show the banner on
|
|
||||||
// subsequent app-version emits.
|
|
||||||
let handled = false;
|
|
||||||
|
|
||||||
// Variant C: on a real mismatch we do NOT reload the tab when it merely goes to
|
|
||||||
// the background (that would silently drop a half-written comment/form). Instead
|
|
||||||
// we arm a one-shot reload for the NEXT in-app router navigation — a point where
|
|
||||||
// the user is already leaving the current page, so an in-app navigation would
|
|
||||||
// discard that unsaved component-state anyway and the reload adds no extra loss.
|
|
||||||
let pendingNavReload = false;
|
|
||||||
|
|
||||||
// Remembered from the last detected mismatch for the pre-reload breadcrumb and
|
|
||||||
// the (already-visible) banner.
|
|
||||||
let lastServerVersion = "";
|
|
||||||
let lastClientVersion = "";
|
|
||||||
|
|
||||||
// Read the build version baked into THIS bundle. The `typeof` guard avoids a
|
|
||||||
// ReferenceError where the `APP_VERSION` global is absent (e.g. under vitest,
|
|
||||||
// where Vite's `define` did not run) — an unknown client version makes the
|
|
||||||
// pure decision no-op (fail-safe).
|
|
||||||
function readClientVersion(): string {
|
|
||||||
return (typeof APP_VERSION !== "undefined" ? APP_VERSION : "").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform the actual reload — but only after the shared one-shot flag is
|
|
||||||
// persisted. If the write fails (storage unavailable) we must NOT reload
|
|
||||||
// (mirrors the reactive chunk-load boundary's `catch → return`), and fall back
|
|
||||||
// to the manual banner so the user can still recover.
|
|
||||||
function performAutoReload(): void {
|
|
||||||
if (!markAutoReloaded()) {
|
|
||||||
showReloadBanner();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Trace right before the reload (which clears the console): a persistent
|
|
||||||
// breadcrumb + a log line so the auto-reload is observable in a field report.
|
|
||||||
recordReloadBreadcrumb({
|
|
||||||
path: "proactive",
|
|
||||||
serverVersion: lastServerVersion,
|
|
||||||
clientVersion: lastClientVersion,
|
|
||||||
});
|
|
||||||
console.warn(
|
|
||||||
`[version-coherence] auto-reloading: client=${lastClientVersion} -> server=${lastServerVersion}`,
|
|
||||||
);
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
function showReloadBanner(): void {
|
|
||||||
notifications.show({
|
|
||||||
id: BANNER_ID,
|
|
||||||
title: i18n.t("A new version is available"),
|
|
||||||
message: (
|
|
||||||
<Button size="xs" mt="xs" onClick={() => performAutoReload()}>
|
|
||||||
{i18n.t("Update")}
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
autoClose: false,
|
|
||||||
withCloseButton: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle a server `app-version` announcement: compare it to this bundle's
|
|
||||||
* version and, on a real mismatch, show the banner and arm a guarded reload for
|
|
||||||
* the next in-app navigation (variant C).
|
|
||||||
*
|
|
||||||
* - real mismatch (window budget available) → banner + arm navigation reload.
|
|
||||||
* The banner's "Update" button reloads immediately (same shared window guard).
|
|
||||||
* The tab is NOT reloaded on visibility change.
|
|
||||||
* - auto-reload already used this window / storage error → banner only (no arm),
|
|
||||||
* so there is at most one automatic reload per RELOAD_WINDOW_MS window (loop
|
|
||||||
* safety).
|
|
||||||
* - in sync / unknown version → noop (fail-safe).
|
|
||||||
*/
|
|
||||||
export function triggerGuardedReload(
|
|
||||||
rawServerVersion: string | undefined | null,
|
|
||||||
): void {
|
|
||||||
const serverVersion = (rawServerVersion ?? "").trim();
|
|
||||||
const clientVersion = readClientVersion();
|
|
||||||
|
|
||||||
// A storage read error surfaces as autoReloadUsed=true → fail toward NOT
|
|
||||||
// reloading (banner only).
|
|
||||||
const autoReloadUsed = hasAutoReloaded();
|
|
||||||
|
|
||||||
const action = decideVersionAction({
|
|
||||||
serverVersion,
|
|
||||||
clientVersion,
|
|
||||||
autoReloadUsed,
|
|
||||||
});
|
|
||||||
if (action === "noop") return;
|
|
||||||
|
|
||||||
// Idempotent per tab-load: don't re-arm or re-stack the banner across repeated
|
|
||||||
// emits (reconnects) once we've already acted.
|
|
||||||
if (handled) return;
|
|
||||||
handled = true;
|
|
||||||
|
|
||||||
lastServerVersion = serverVersion;
|
|
||||||
lastClientVersion = clientVersion;
|
|
||||||
|
|
||||||
if (action === "banner") {
|
|
||||||
// Entered banner-only (permanent skew, node oscillation, or the window's
|
|
||||||
// auto-reload budget already spent). Log for diagnosability; show the banner.
|
|
||||||
console.warn(
|
|
||||||
`[version-coherence] server=${serverVersion} client=${clientVersion}: ` +
|
|
||||||
"auto-reload budget already spent this window — showing manual banner",
|
|
||||||
);
|
|
||||||
showReloadBanner();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// action === "reload" (variant C): show the banner and defer the auto-reload
|
|
||||||
// to the next in-app navigation instead of reloading now / on visibility.
|
|
||||||
showReloadBanner();
|
|
||||||
pendingNavReload = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Consume the armed one-shot navigation reload, if any. Called by
|
|
||||||
* `useVersionReloadOnNavigation` on each in-app router navigation.
|
|
||||||
*/
|
|
||||||
export function consumeNavigationReload(): void {
|
|
||||||
if (!pendingNavReload) return;
|
|
||||||
pendingNavReload = false;
|
|
||||||
performAutoReload();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook (mounted inside the Router) that fires the armed one-shot reload on the
|
|
||||||
* NEXT in-app router navigation after a version mismatch. Skips the initial
|
|
||||||
* render so it only reacts to real navigations, not the first location.
|
|
||||||
*/
|
|
||||||
export function useVersionReloadOnNavigation(): void {
|
|
||||||
const location = useLocation();
|
|
||||||
const firstRender = useRef(true);
|
|
||||||
useEffect(() => {
|
|
||||||
if (firstRender.current) {
|
|
||||||
firstRender.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
consumeNavigationReload();
|
|
||||||
}, [location.key]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Surface (log once) the breadcrumb left by an auto-reload in the previous page
|
|
||||||
* load — the reload cleared the console, so this makes a "tab reloaded itself"
|
|
||||||
* report diagnosable. Call once on app startup.
|
|
||||||
*/
|
|
||||||
export function surfacePreviousReloadBreadcrumb(): void {
|
|
||||||
const crumb = takeReloadBreadcrumb();
|
|
||||||
if (!crumb) return;
|
|
||||||
console.info(
|
|
||||||
`[version-coherence] previous auto-reload: path=${crumb.path} ` +
|
|
||||||
`client=${crumb.clientVersion ?? ""} -> server=${crumb.serverVersion ?? ""} ` +
|
|
||||||
`at=${new Date(crumb.at).toISOString()}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test-only: reset module-level latches between cases.
|
|
||||||
export function __resetGuardedReloadForTests(): void {
|
|
||||||
handled = false;
|
|
||||||
pendingNavReload = false;
|
|
||||||
lastServerVersion = "";
|
|
||||||
lastClientVersion = "";
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
||||||
import { render, act, cleanup } from "@testing-library/react";
|
|
||||||
import { MemoryRouter, useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
// Integration test for the SHARED, window-based auto-reload budget (invariant a):
|
|
||||||
// the reactive chunk-load boundary and the proactive version-coherence path both
|
|
||||||
// route through the REAL @/lib/reload-guard, so at most one automatic reload
|
|
||||||
// happens per RELOAD_WINDOW_MS across BOTH paths combined. Only the two paths'
|
|
||||||
// side-effecting collaborators are mocked — the reload guard is intentionally
|
|
||||||
// REAL so this exercises the actual shared sessionStorage budget.
|
|
||||||
vi.mock("@mantine/notifications", () => ({
|
|
||||||
notifications: { show: vi.fn() },
|
|
||||||
}));
|
|
||||||
vi.mock("@/i18n.ts", () => ({ default: { t: (k: string) => k } }));
|
|
||||||
|
|
||||||
import { handleError } from "@/components/chunk-load-error-boundary";
|
|
||||||
import {
|
|
||||||
triggerGuardedReload,
|
|
||||||
useVersionReloadOnNavigation,
|
|
||||||
__resetGuardedReloadForTests,
|
|
||||||
} from "./guarded-reload";
|
|
||||||
import { RELOAD_WINDOW_MS } from "@/lib/reload-guard";
|
|
||||||
|
|
||||||
const CHUNK_ERROR = { name: "ChunkLoadError", message: "boom" };
|
|
||||||
const T0 = 1_000_000_000_000;
|
|
||||||
|
|
||||||
let reload: ReturnType<typeof vi.fn>;
|
|
||||||
let nowMock: ReturnType<typeof vi.spyOn>;
|
|
||||||
|
|
||||||
// Harness mounted inside a router: installs the navigation hook and exposes
|
|
||||||
// `navigate` so a test can drive an in-app router navigation (the point where the
|
|
||||||
// proactive path fires its armed reload).
|
|
||||||
let doNavigate: (to: string) => void;
|
|
||||||
function Harness() {
|
|
||||||
useVersionReloadOnNavigation();
|
|
||||||
doNavigate = useNavigate();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
function mountHarness() {
|
|
||||||
render(
|
|
||||||
<MemoryRouter initialEntries={["/start"]}>
|
|
||||||
<Harness />
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function navigateTo(path: string) {
|
|
||||||
act(() => {
|
|
||||||
doNavigate(path);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function setNow(t: number) {
|
|
||||||
nowMock.mockReturnValue(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
sessionStorage.clear();
|
|
||||||
__resetGuardedReloadForTests();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
nowMock = vi.spyOn(Date, "now").mockReturnValue(T0);
|
|
||||||
vi.stubGlobal("APP_VERSION", "test-A");
|
|
||||||
reload = vi.fn();
|
|
||||||
Object.defineProperty(window, "location", {
|
|
||||||
configurable: true,
|
|
||||||
value: { reload },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
sessionStorage.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("shared window-based reload budget (invariant a)", () => {
|
|
||||||
it("a chunk-load reload spends the budget: a version-coherence mismatch within the window shows the banner but does NOT reload", () => {
|
|
||||||
// Path 1 (reactive): a stale-chunk 404 auto-reloads once and stamps the window.
|
|
||||||
handleError(CHUNK_ERROR);
|
|
||||||
expect(reload).toHaveBeenCalledTimes(1);
|
|
||||||
reload.mockClear();
|
|
||||||
|
|
||||||
// Path 2 (proactive), 1 min later — still inside the window. The shared budget
|
|
||||||
// is spent, so the version mismatch degrades to the banner and never reloads.
|
|
||||||
setNow(T0 + 60_000);
|
|
||||||
mountHarness();
|
|
||||||
triggerGuardedReload("test-B");
|
|
||||||
navigateTo("/next");
|
|
||||||
expect(reload).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a version-coherence reload spends the SAME budget: a chunk-load error within the window does NOT reload", () => {
|
|
||||||
// Path 2 (proactive) first: real mismatch → arm → fire on navigation.
|
|
||||||
mountHarness();
|
|
||||||
triggerGuardedReload("test-B");
|
|
||||||
navigateTo("/next");
|
|
||||||
expect(reload).toHaveBeenCalledTimes(1);
|
|
||||||
reload.mockClear();
|
|
||||||
|
|
||||||
// Path 1 (reactive), 2 min later — inside the window. Budget already spent by
|
|
||||||
// the proactive path, so the stale-chunk error must NOT trigger a second reload.
|
|
||||||
setNow(T0 + 2 * 60_000);
|
|
||||||
handleError(CHUNK_ERROR);
|
|
||||||
expect(reload).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("recovers after the window: a reload strictly older than the window is allowed again (second deploy)", () => {
|
|
||||||
// First auto-reload (reactive) stamps the window.
|
|
||||||
handleError(CHUNK_ERROR);
|
|
||||||
expect(reload).toHaveBeenCalledTimes(1);
|
|
||||||
reload.mockClear();
|
|
||||||
|
|
||||||
// A second deploy arrives after the window has fully elapsed → the proactive
|
|
||||||
// path is allowed to reload again (window, not a permanent one-shot).
|
|
||||||
__resetGuardedReloadForTests();
|
|
||||||
setNow(T0 + RELOAD_WINDOW_MS + 1);
|
|
||||||
mountHarness();
|
|
||||||
triggerGuardedReload("test-B");
|
|
||||||
navigateTo("/next");
|
|
||||||
expect(reload).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reactive path fails closed when storage READS but cannot WRITE (quota / Safari private): no unguarded reload", () => {
|
|
||||||
// getItem→null makes hasAutoReloaded() report the budget as available, so
|
|
||||||
// handleError passes the first guard and reaches `if (!markAutoReloaded())
|
|
||||||
// return;`. setItem throws → the stamp cannot stick, so markAutoReloaded()
|
|
||||||
// returns false and that guard MUST bail — otherwise the reactive path would
|
|
||||||
// reload on every stale-chunk error with no persisted budget (an unguarded
|
|
||||||
// loop). This is the asymmetric gap: the proactive path's equivalent is
|
|
||||||
// covered by guarded-reload.test.tsx "does NOT reload when the flag write
|
|
||||||
// fails".
|
|
||||||
vi.stubGlobal("sessionStorage", {
|
|
||||||
getItem: () => null,
|
|
||||||
setItem: () => {
|
|
||||||
throw new Error("quota exceeded");
|
|
||||||
},
|
|
||||||
removeItem: () => {},
|
|
||||||
clear: () => {},
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
handleError(CHUNK_ERROR);
|
|
||||||
expect(reload).not.toHaveBeenCalled();
|
|
||||||
} finally {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sessionStorage unavailable: neither path performs an unguarded reload", () => {
|
|
||||||
// The real guard fails toward NOT reloading when storage throws.
|
|
||||||
vi.stubGlobal("sessionStorage", {
|
|
||||||
getItem: () => {
|
|
||||||
throw new Error("storage disabled");
|
|
||||||
},
|
|
||||||
setItem: () => {
|
|
||||||
throw new Error("storage disabled");
|
|
||||||
},
|
|
||||||
removeItem: () => {},
|
|
||||||
clear: () => {},
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
handleError(CHUNK_ERROR);
|
|
||||||
expect(reload).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
mountHarness();
|
|
||||||
triggerGuardedReload("test-B");
|
|
||||||
navigateTo("/next");
|
|
||||||
expect(reload).not.toHaveBeenCalled();
|
|
||||||
} finally {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -13,12 +13,6 @@ import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
|||||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
import { makeConnectHandler } from "@/features/user/connect-resync.ts";
|
import { makeConnectHandler } from "@/features/user/connect-resync.ts";
|
||||||
import {
|
|
||||||
triggerGuardedReload,
|
|
||||||
useVersionReloadOnNavigation,
|
|
||||||
surfacePreviousReloadBreadcrumb,
|
|
||||||
} from "@/features/user/guarded-reload.tsx";
|
|
||||||
import type { AppVersionSocketPayload } from "@/features/user/version-coherence.ts";
|
|
||||||
|
|
||||||
export function UserProvider({ children }: React.PropsWithChildren) {
|
export function UserProvider({ children }: React.PropsWithChildren) {
|
||||||
const [, setCurrentUser] = useAtom(currentUserAtom);
|
const [, setCurrentUser] = useAtom(currentUserAtom);
|
||||||
@@ -28,16 +22,6 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
|||||||
// fetch collab token on load
|
// fetch collab token on load
|
||||||
const { data: collab } = useCollabToken();
|
const { data: collab } = useCollabToken();
|
||||||
|
|
||||||
// version-coherence: fire the armed one-shot reload on the next in-app
|
|
||||||
// navigation (variant C — a safe point, not on tab backgrounding).
|
|
||||||
useVersionReloadOnNavigation();
|
|
||||||
|
|
||||||
// Surface any breadcrumb left by an auto-reload in the previous page load
|
|
||||||
// (the reload cleared the console) so a field report stays diagnosable.
|
|
||||||
useEffect(() => {
|
|
||||||
surfacePreviousReloadBreadcrumb();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading || isError) {
|
if (isLoading || isError) {
|
||||||
return;
|
return;
|
||||||
@@ -63,16 +47,6 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
|||||||
handleConnect();
|
handleConnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register the version-coherence listener SYNCHRONOUSLY, before the socket
|
|
||||||
// connects: the server emits `app-version` immediately in handleConnection,
|
|
||||||
// so a listener attached after connect would miss it on a fast localhost
|
|
||||||
// connect. On a version mismatch the client shows a banner and defers the
|
|
||||||
// auto-reload to the next in-app navigation (variant C — avoids reloading a
|
|
||||||
// backgrounded tab that may hold unsaved input) before it hits a stale chunk.
|
|
||||||
newSocket.on("app-version", (payload?: AppVersionSocketPayload) => {
|
|
||||||
triggerGuardedReload(payload?.version);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
console.log("ws disconnected");
|
console.log("ws disconnected");
|
||||||
newSocket.disconnect();
|
newSocket.disconnect();
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { decideVersionAction } from "./version-coherence";
|
|
||||||
|
|
||||||
describe("decideVersionAction", () => {
|
|
||||||
it("noop when the server version is empty (fail-safe)", () => {
|
|
||||||
expect(
|
|
||||||
decideVersionAction({
|
|
||||||
serverVersion: "",
|
|
||||||
clientVersion: "v1",
|
|
||||||
autoReloadUsed: false,
|
|
||||||
}),
|
|
||||||
).toBe("noop");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("noop when the client version is empty (fail-safe)", () => {
|
|
||||||
expect(
|
|
||||||
decideVersionAction({
|
|
||||||
serverVersion: "v1",
|
|
||||||
clientVersion: "",
|
|
||||||
autoReloadUsed: false,
|
|
||||||
}),
|
|
||||||
).toBe("noop");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("noop when versions are equal (in sync)", () => {
|
|
||||||
expect(
|
|
||||||
decideVersionAction({
|
|
||||||
serverVersion: "v1",
|
|
||||||
clientVersion: "v1",
|
|
||||||
autoReloadUsed: false,
|
|
||||||
}),
|
|
||||||
).toBe("noop");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reload on a real mismatch the first time this session", () => {
|
|
||||||
expect(
|
|
||||||
decideVersionAction({
|
|
||||||
serverVersion: "test-B",
|
|
||||||
clientVersion: "test-A",
|
|
||||||
autoReloadUsed: false,
|
|
||||||
}),
|
|
||||||
).toBe("reload");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("banner on a mismatch once the session auto-reload is spent", () => {
|
|
||||||
expect(
|
|
||||||
decideVersionAction({
|
|
||||||
serverVersion: "test-B",
|
|
||||||
clientVersion: "test-A",
|
|
||||||
autoReloadUsed: true,
|
|
||||||
}),
|
|
||||||
).toBe("banner");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("equal versions stay noop even if auto-reload was already used", () => {
|
|
||||||
expect(
|
|
||||||
decideVersionAction({
|
|
||||||
serverVersion: "v1",
|
|
||||||
clientVersion: "v1",
|
|
||||||
autoReloadUsed: true,
|
|
||||||
}),
|
|
||||||
).toBe("noop");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
// Payload of the per-connect `app-version` socket.io event announced by the
|
|
||||||
// server (ws.gateway.ts) after a successful auth. A dedicated event — NOT a
|
|
||||||
// member of the room-scoped `WebSocketEvent` union (which is discriminated by
|
|
||||||
// `operation`), so it never touches use-query-subscription.
|
|
||||||
export type AppVersionSocketPayload = { version: string };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure decision for the version-coherence guard.
|
|
||||||
*
|
|
||||||
* All inputs are injected (no globals, no side effects) so it is unit-testable
|
|
||||||
* without a DOM or the build-time `APP_VERSION` global (undefined under vitest).
|
|
||||||
*
|
|
||||||
* - `autoReloadUsed` = an automatic reload has already happened within the
|
|
||||||
* current ~5-min window, so we must not auto-reload again (loop safety,
|
|
||||||
* shared window budget with the reactive chunk-load boundary).
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - "noop" — do nothing (unknown version on either side, or already in sync).
|
|
||||||
* - "banner" — show the manual "update available" banner only (no auto-reload).
|
|
||||||
* - "reload" — real first-time mismatch: eligible for a guarded auto-reload.
|
|
||||||
*/
|
|
||||||
export function decideVersionAction(args: {
|
|
||||||
serverVersion: string;
|
|
||||||
clientVersion: string;
|
|
||||||
autoReloadUsed: boolean;
|
|
||||||
}): "reload" | "banner" | "noop" {
|
|
||||||
const { serverVersion, clientVersion, autoReloadUsed } = args;
|
|
||||||
if (!serverVersion || !clientVersion) return "noop"; // fail-safe: unknown version → never act
|
|
||||||
if (serverVersion === clientVersion) return "noop"; // in sync
|
|
||||||
if (autoReloadUsed) return "banner"; // one auto-reload per RELOAD_WINDOW_MS window already spent
|
|
||||||
return "reload"; // real mismatch, window budget available
|
|
||||||
}
|
|
||||||
@@ -82,19 +82,17 @@ describe("applyMoveTreeNode", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does NOT create a partial child list when the destination is loaded-but-collapsed (children unloaded) — keeps it lazy-loadable (#159 #525)", () => {
|
it("does NOT create a partial child list when the destination is loaded-but-collapsed (children unloaded) — keeps it lazy-loadable (#159)", () => {
|
||||||
// `dstCollapsed` is in the tree but its children were never lazy-loaded. The
|
// `dstCollapsed` is in the tree but its children were never lazy-loaded
|
||||||
// CANONICAL unloaded form here is `hasChildren: true` + `children: []` (from
|
// (children === undefined). The OLD behavior inserted `src` as the ONLY
|
||||||
// `pageToTreeNode` / `pruneCollapsedChildren`), NOT `children: undefined`.
|
// child ([src]), which defeated the lazy-load gate and HID the parent's
|
||||||
// The pre-#525 predicate (`children === undefined`) missed this form and
|
// other real children. Now the move leaves children unloaded (so expanding
|
||||||
// inserted `src` as the ONLY child ([src]), defeating the lazy-load gate and
|
// fetches the FULL set, including src) and just flags hasChildren.
|
||||||
// HIDING the parent's other real children. Now the move leaves children
|
|
||||||
// unloaded (so expanding fetches the FULL set, including src).
|
|
||||||
const tree: SpaceTreeNode[] = [
|
const tree: SpaceTreeNode[] = [
|
||||||
node("dstCollapsed", {
|
node("dstCollapsed", {
|
||||||
position: "a0",
|
position: "a0",
|
||||||
hasChildren: true,
|
hasChildren: false,
|
||||||
children: [],
|
children: undefined as unknown as SpaceTreeNode[],
|
||||||
}),
|
}),
|
||||||
node("src", { position: "a9" }),
|
node("src", { position: "a9" }),
|
||||||
];
|
];
|
||||||
@@ -107,10 +105,9 @@ describe("applyMoveTreeNode", () => {
|
|||||||
pageData: {},
|
pageData: {},
|
||||||
});
|
});
|
||||||
const dst = treeModel.find(next, "dstCollapsed");
|
const dst = treeModel.find(next, "dstCollapsed");
|
||||||
// Children stay unloaded ([] not materialized to [src]) -> the lazy-load gate
|
// Children stay unloaded -> the lazy-load gate fetches the FULL set (incl.
|
||||||
// fetches the FULL set (incl. src) on expand. MUTATION: the pre-#525
|
// src) on expand, rather than showing a misleading partial [src] list.
|
||||||
// `=== undefined` predicate would insert [src] here and redden this.
|
expect(dst?.children).toBeUndefined();
|
||||||
expect(dst?.children).toEqual([]);
|
|
||||||
expect(dst?.hasChildren).toBe(true);
|
expect(dst?.hasChildren).toBe(true);
|
||||||
// src moved away from its old root slot (it lives under dstCollapsed
|
// src moved away from its old root slot (it lives under dstCollapsed
|
||||||
// server-side and reappears when the parent is expanded/loaded).
|
// server-side and reappears when the parent is expanded/loaded).
|
||||||
|
|||||||
+2
-10
@@ -28,7 +28,6 @@ import {
|
|||||||
IAiMcpServerCreate,
|
IAiMcpServerCreate,
|
||||||
IAiMcpServerUpdate,
|
IAiMcpServerUpdate,
|
||||||
} from "@/features/workspace/services/ai-mcp-server-service.ts";
|
} from "@/features/workspace/services/ai-mcp-server-service.ts";
|
||||||
import { resolveToolAllowlist } from "./ai-mcp-server-form.utils.ts";
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
@@ -122,20 +121,13 @@ export default function AiMcpServerForm({
|
|||||||
async function handleSubmit(values: FormValues) {
|
async function handleSubmit(values: FormValues) {
|
||||||
const headers = resolveHeaders();
|
const headers = resolveHeaders();
|
||||||
|
|
||||||
// An empty tag field means "no restriction" (sent as null) — since #476 the
|
|
||||||
// server persists a literal `[]` as deny-all (zero tools). But a server that
|
|
||||||
// was ALREADY deny-all loads into an empty field too; sending null there
|
|
||||||
// would silently widen it to allow-all on a routine edit, so preserve `[]`.
|
|
||||||
// See resolveToolAllowlist for the full rationale.
|
|
||||||
const toolAllowlist = resolveToolAllowlist(values.toolAllowlist, server);
|
|
||||||
|
|
||||||
if (isEdit && server) {
|
if (isEdit && server) {
|
||||||
const payload: IAiMcpServerUpdate = {
|
const payload: IAiMcpServerUpdate = {
|
||||||
id: server.id,
|
id: server.id,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
transport: values.transport,
|
transport: values.transport,
|
||||||
url: values.url,
|
url: values.url,
|
||||||
toolAllowlist,
|
toolAllowlist: values.toolAllowlist,
|
||||||
// Always sent: a blank value clears the stored guidance (server -> null).
|
// Always sent: a blank value clears the stored guidance (server -> null).
|
||||||
instructions: values.instructions,
|
instructions: values.instructions,
|
||||||
enabled: values.enabled,
|
enabled: values.enabled,
|
||||||
@@ -148,7 +140,7 @@ export default function AiMcpServerForm({
|
|||||||
name: values.name,
|
name: values.name,
|
||||||
transport: values.transport,
|
transport: values.transport,
|
||||||
url: values.url,
|
url: values.url,
|
||||||
toolAllowlist,
|
toolAllowlist: values.toolAllowlist,
|
||||||
// Blank => server stores null (no guidance).
|
// Blank => server stores null (no guidance).
|
||||||
instructions: values.instructions,
|
instructions: values.instructions,
|
||||||
enabled: values.enabled,
|
enabled: values.enabled,
|
||||||
|
|||||||
-29
@@ -1,29 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { resolveToolAllowlist } from "./ai-mcp-server-form.utils.ts";
|
|
||||||
|
|
||||||
describe("resolveToolAllowlist", () => {
|
|
||||||
it("sends the typed tools when the field is non-empty", () => {
|
|
||||||
expect(resolveToolAllowlist(["a", "b"], { toolAllowlist: null })).toEqual([
|
|
||||||
"a",
|
|
||||||
"b",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates as null (unrestricted) when empty and there is no server", () => {
|
|
||||||
expect(resolveToolAllowlist([], undefined)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends null for an empty field on a previously-unrestricted server", () => {
|
|
||||||
expect(resolveToolAllowlist([], { toolAllowlist: null })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves deny-all: an empty field on a `[]` server stays `[]`, not null", () => {
|
|
||||||
// The core #476/#477 guard: editing a deny-all server (rename/toggle) with
|
|
||||||
// an empty tag field must NOT silently widen it to allow-all.
|
|
||||||
expect(resolveToolAllowlist([], { toolAllowlist: [] })).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still sends explicit tools even if the server was deny-all", () => {
|
|
||||||
expect(resolveToolAllowlist(["x"], { toolAllowlist: [] })).toEqual(["x"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
import { IAiMcpServer } from "@/features/workspace/services/ai-mcp-server-service.ts";
|
|
||||||
|
|
||||||
// Resolve the tool allowlist value to persist from the form field.
|
|
||||||
//
|
|
||||||
// An empty tag field normally means "no restriction" and is sent as null so
|
|
||||||
// the server drops the column (all tools allowed). But a server that was
|
|
||||||
// ALREADY deny-all (a stored literal `[]`, meaning zero tools — creatable via
|
|
||||||
// the API) loads into the form as an empty field too. Coercing that empty
|
|
||||||
// field to null on submit would SILENTLY widen a deny-all server to allow-all
|
|
||||||
// on any routine edit (rename, toggle) — the exact silent-widen class #476
|
|
||||||
// closed on the read side. So when the edited server was deny-all, preserve
|
|
||||||
// `[]` (deny-all); only a genuinely-unrestricted server (stored null/absent)
|
|
||||||
// stays null.
|
|
||||||
export function resolveToolAllowlist(
|
|
||||||
fieldValue: string[],
|
|
||||||
server?: Pick<IAiMcpServer, "toolAllowlist">,
|
|
||||||
): string[] | null {
|
|
||||||
if (fieldValue.length > 0) return fieldValue;
|
|
||||||
const wasDenyAll =
|
|
||||||
Array.isArray(server?.toolAllowlist) && server.toolAllowlist.length === 0;
|
|
||||||
return wasDenyAll ? [] : null;
|
|
||||||
}
|
|
||||||
-124
@@ -6,8 +6,6 @@ import {
|
|||||||
nextReindexPollInterval,
|
nextReindexPollInterval,
|
||||||
isReindexComplete,
|
isReindexComplete,
|
||||||
isReindexButtonLoading,
|
isReindexButtonLoading,
|
||||||
reindexRunKey,
|
|
||||||
isNewReindexRun,
|
|
||||||
} from './ai-provider-settings';
|
} from './ai-provider-settings';
|
||||||
|
|
||||||
describe('resolveCardStatus', () => {
|
describe('resolveCardStatus', () => {
|
||||||
@@ -223,128 +221,6 @@ describe('isReindexComplete', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('reindexRunKey', () => {
|
|
||||||
it('is null when the status carries no run identity', () => {
|
|
||||||
expect(reindexRunKey(undefined)).toBeNull();
|
|
||||||
expect(
|
|
||||||
reindexRunKey({ reindexing: false, indexedPages: 5, totalPages: 5 }),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is null for a legacy/degraded record with an empty runId', () => {
|
|
||||||
// The server sends runId='' for a record written before the field existed;
|
|
||||||
// the client must treat that as "no identity" (fall back to prior behaviour).
|
|
||||||
expect(
|
|
||||||
reindexRunKey({
|
|
||||||
reindexing: true,
|
|
||||||
indexedPages: 0,
|
|
||||||
totalPages: 10,
|
|
||||||
runId: '',
|
|
||||||
reindexStartedAt: 1000,
|
|
||||||
}),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('folds runId and startedAt into one stable key', () => {
|
|
||||||
expect(
|
|
||||||
reindexRunKey({
|
|
||||||
reindexing: true,
|
|
||||||
indexedPages: 0,
|
|
||||||
totalPages: 10,
|
|
||||||
runId: 'run-a',
|
|
||||||
reindexStartedAt: 1000,
|
|
||||||
}),
|
|
||||||
).toBe('run-a:1000');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('changes when the runId changes for the same startedAt', () => {
|
|
||||||
const a = reindexRunKey({
|
|
||||||
reindexing: true,
|
|
||||||
indexedPages: 0,
|
|
||||||
totalPages: 10,
|
|
||||||
runId: 'run-a',
|
|
||||||
reindexStartedAt: 1000,
|
|
||||||
});
|
|
||||||
const b = reindexRunKey({
|
|
||||||
reindexing: true,
|
|
||||||
indexedPages: 0,
|
|
||||||
totalPages: 10,
|
|
||||||
runId: 'run-b',
|
|
||||||
reindexStartedAt: 1000,
|
|
||||||
});
|
|
||||||
expect(a).not.toBe(b);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('changes when the same runId restarts at a new startedAt', () => {
|
|
||||||
const a = reindexRunKey({
|
|
||||||
reindexing: true,
|
|
||||||
indexedPages: 0,
|
|
||||||
totalPages: 10,
|
|
||||||
runId: 'run-a',
|
|
||||||
reindexStartedAt: 1000,
|
|
||||||
});
|
|
||||||
const b = reindexRunKey({
|
|
||||||
reindexing: true,
|
|
||||||
indexedPages: 0,
|
|
||||||
totalPages: 10,
|
|
||||||
runId: 'run-a',
|
|
||||||
reindexStartedAt: 2000,
|
|
||||||
});
|
|
||||||
expect(a).not.toBe(b);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isNewReindexRun (poll keying on runId)', () => {
|
|
||||||
// Derive the status shape from the helper itself so the test needs no export
|
|
||||||
// of the component-internal ReindexStatus type.
|
|
||||||
type ReindexStatusLike = NonNullable<Parameters<typeof reindexRunKey>[0]>;
|
|
||||||
const run = (runId: string, startedAt: number): ReindexStatusLike => ({
|
|
||||||
reindexing: true,
|
|
||||||
indexedPages: 0,
|
|
||||||
totalPages: 10,
|
|
||||||
runId,
|
|
||||||
reindexStartedAt: startedAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
it('first identity after none latched is a NEW run', () => {
|
|
||||||
expect(isNewReindexRun(null, run('run-a', 1000))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('the SAME identity is not a new run (same run being watched)', () => {
|
|
||||||
const key = reindexRunKey(run('run-a', 1000));
|
|
||||||
expect(isNewReindexRun(key, run('run-a', 1000))).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a DIFFERENT runId is a new run (reset per-run poll state)', () => {
|
|
||||||
const key = reindexRunKey(run('run-a', 1000));
|
|
||||||
expect(isNewReindexRun(key, run('run-b', 1000))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('an identity-less poll (no runId / cleared record) is never a new run', () => {
|
|
||||||
const key = reindexRunKey(run('run-a', 1000));
|
|
||||||
expect(
|
|
||||||
isNewReindexRun(key, {
|
|
||||||
reindexing: false,
|
|
||||||
indexedPages: 10,
|
|
||||||
totalPages: 10,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a legacy empty-runId poll does not spuriously reset a latched run', () => {
|
|
||||||
const key = reindexRunKey(run('run-a', 1000));
|
|
||||||
expect(
|
|
||||||
isNewReindexRun(key, {
|
|
||||||
reindexing: true,
|
|
||||||
indexedPages: 3,
|
|
||||||
totalPages: 10,
|
|
||||||
runId: '',
|
|
||||||
reindexStartedAt: 1000,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isReindexButtonLoading', () => {
|
describe('isReindexButtonLoading', () => {
|
||||||
it('loads while the POST mutation is pending', () => {
|
it('loads while the POST mutation is pending', () => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
+1
-54
@@ -173,43 +173,9 @@ export function resolveKeyField(
|
|||||||
// Subset of the status payload that drives the reindex poll decisions.
|
// Subset of the status payload that drives the reindex poll decisions.
|
||||||
type ReindexStatus = Pick<
|
type ReindexStatus = Pick<
|
||||||
IAiSettings,
|
IAiSettings,
|
||||||
"reindexing" | "indexedPages" | "totalPages" | "runId" | "reindexStartedAt"
|
"reindexing" | "indexedPages" | "totalPages"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/**
|
|
||||||
* A stable per-RUN key for the reindex poll: `runId:startedAt`, or `null` when
|
|
||||||
* the status carries no run identity (no active run, or a legacy/degraded
|
|
||||||
* server record with an empty runId). Two polls of the SAME run share a key; a
|
|
||||||
* new run mints a fresh runId and so a different key.
|
|
||||||
*
|
|
||||||
* This is the single place the client turns the server's run identity into the
|
|
||||||
* value it keys on — it removes the "is this the same run I've been watching or
|
|
||||||
* a brand-new one?" ambiguity that made a class of reindex-status bugs (a stale
|
|
||||||
* pre-reindex snapshot vs a fresh run) get fixed twice (#262). `startedAt` is
|
|
||||||
* folded in so a run that somehow reuses a runId but restarted is still new.
|
|
||||||
*/
|
|
||||||
export function reindexRunKey(status: ReindexStatus | undefined): string | null {
|
|
||||||
const runId = status?.runId;
|
|
||||||
if (!runId) return null;
|
|
||||||
return `${runId}:${status?.reindexStartedAt ?? ""}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decide whether the latest poll represents a NEW reindex run relative to the
|
|
||||||
* run key the client last latched (`prevKey`, `null` if none yet). True only
|
|
||||||
* when the status carries an identity AND it differs from the latched one — the
|
|
||||||
* signal to reset any per-run poll state (the "seen active" latch / progress the
|
|
||||||
* UI held). The same identity (or no identity) is NOT a new run, so an unchanged
|
|
||||||
* or identity-less poll never resets mid-run.
|
|
||||||
*/
|
|
||||||
export function isNewReindexRun(
|
|
||||||
prevKey: string | null,
|
|
||||||
status: ReindexStatus | undefined,
|
|
||||||
): boolean {
|
|
||||||
const key = reindexRunKey(status);
|
|
||||||
return key !== null && key !== prevKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decide the TanStack Query `refetchInterval` while a reindex may be running.
|
* Decide the TanStack Query `refetchInterval` while a reindex may be running.
|
||||||
* Returns the poll interval (ms) to keep polling, or `false` to stop.
|
* Returns the poll interval (ms) to keep polling, or `false` to stop.
|
||||||
@@ -354,13 +320,6 @@ export default function AiProviderSettings() {
|
|||||||
// counter at 0 until a manual reload. A ref (not state) because it must not
|
// counter at 0 until a manual reload. A ref (not state) because it must not
|
||||||
// trigger a render and is only ever read where `reindexing` is already false.
|
// trigger a render and is only ever read where `reindexing` is already false.
|
||||||
const reindexSeenActiveRef = useRef(false);
|
const reindexSeenActiveRef = useRef(false);
|
||||||
// The run identity (runId:startedAt) the current poll window is keyed on. When
|
|
||||||
// a poll reports a DIFFERENT runId the server has started a NEW run, so we
|
|
||||||
// re-latch to it and reset `reindexSeenActiveRef` — a fresh run must never
|
|
||||||
// inherit the previous run's "seen active"/completion state (which would stop
|
|
||||||
// polling immediately or read the old run's counters as this run's). null =
|
|
||||||
// no run keyed yet (steady state, or a legacy record without a runId).
|
|
||||||
const reindexRunKeyRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
// Only admins may read the (masked) AI settings; the server enforces this too.
|
// Only admins may read the (masked) AI settings; the server enforces this too.
|
||||||
const { data: settings, isLoading } = useAiSettingsQuery(isAdmin, (query) =>
|
const { data: settings, isLoading } = useAiSettingsQuery(isAdmin, (query) =>
|
||||||
@@ -377,14 +336,6 @@ export default function AiProviderSettings() {
|
|||||||
// unmount because the deadline state goes away with the component.
|
// unmount because the deadline state goes away with the component.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (reindexDeadline === null) return;
|
if (reindexDeadline === null) return;
|
||||||
// Key the poll on the run identity: if this poll carries a runId different
|
|
||||||
// from the one we latched, the server started a NEW run, so adopt it and
|
|
||||||
// drop the per-run "seen active" latch (a fresh run must not inherit the
|
|
||||||
// previous run's completion state). Same runId => same run, leave it alone.
|
|
||||||
if (isNewReindexRun(reindexRunKeyRef.current, settings)) {
|
|
||||||
reindexRunKeyRef.current = reindexRunKey(settings);
|
|
||||||
reindexSeenActiveRef.current = false;
|
|
||||||
}
|
|
||||||
// Latch "we have seen the active run" the moment a poll reports it, so the
|
// Latch "we have seen the active run" the moment a poll reports it, so the
|
||||||
// completion check below (and the refetchInterval's) only fires once the run
|
// completion check below (and the refetchInterval's) only fires once the run
|
||||||
// has genuinely started — never on the stale pre-reindex snapshot.
|
// has genuinely started — never on the stale pre-reindex snapshot.
|
||||||
@@ -1269,10 +1220,6 @@ export default function AiProviderSettings() {
|
|||||||
// immediately.
|
// immediately.
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
reindexSeenActiveRef.current = false;
|
reindexSeenActiveRef.current = false;
|
||||||
// Forget the previous run's identity so the first poll of
|
|
||||||
// this window (carrying the new run's runId) is recognized
|
|
||||||
// as a new run and keyed afresh.
|
|
||||||
reindexRunKeyRef.current = null;
|
|
||||||
setReindexDeadline(Date.now() + REINDEX_POLL_CAP_MS);
|
setReindexDeadline(Date.now() + REINDEX_POLL_CAP_MS);
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,9 +27,7 @@ export interface IAiMcpServerCreate {
|
|||||||
// Auth headers map (e.g. { Authorization: 'Bearer ...' }). Encrypted on save;
|
// Auth headers map (e.g. { Authorization: 'Bearer ...' }). Encrypted on save;
|
||||||
// never returned.
|
// never returned.
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
// Omit/null => no restriction; `[]` is persisted verbatim and means
|
toolAllowlist?: string[];
|
||||||
// deny-all (zero tools) since #476.
|
|
||||||
toolAllowlist?: string[] | null;
|
|
||||||
// Admin-authored prompt guidance (#180). Blank => stored as null.
|
// Admin-authored prompt guidance (#180). Blank => stored as null.
|
||||||
instructions?: string;
|
instructions?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@@ -45,9 +43,7 @@ export interface IAiMcpServerUpdate {
|
|||||||
transport?: McpTransport;
|
transport?: McpTransport;
|
||||||
url?: string;
|
url?: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
// Absent => unchanged; null => no restriction; `[]` is persisted verbatim
|
toolAllowlist?: string[];
|
||||||
// and means deny-all (zero tools) since #476.
|
|
||||||
toolAllowlist?: string[] | null;
|
|
||||||
// Admin-authored prompt guidance (#180). Absent => unchanged; blank => cleared.
|
// Admin-authored prompt guidance (#180). Absent => unchanged; blank => cleared.
|
||||||
instructions?: string;
|
instructions?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
|||||||
@@ -51,14 +51,6 @@ export interface IAiSettings {
|
|||||||
// True while a full workspace reindex is actively running; the counts above
|
// True while a full workspace reindex is actively running; the counts above
|
||||||
// then reflect the live run progress (done climbs 0 -> total).
|
// then reflect the live run progress (done climbs 0 -> total).
|
||||||
reindexing?: boolean;
|
reindexing?: boolean;
|
||||||
// Identity of the ACTIVE reindex run (present only while `reindexing`). The
|
|
||||||
// poll keys on `runId`: a changed value means a NEW run (reset the per-run
|
|
||||||
// poll state the UI latched), the same value is the run already being watched.
|
|
||||||
// Absent/empty ('') => no identity available; the client keeps prior behaviour.
|
|
||||||
runId?: string;
|
|
||||||
// Epoch-ms the active run started; paired with `runId` so a restart with a
|
|
||||||
// recycled id is still detected as a new run.
|
|
||||||
reindexStartedAt?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update payload. Key semantics (same for `apiKey` and `embeddingApiKey`):
|
// Update payload. Key semantics (same for `apiKey` and `embeddingApiKey`):
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
||||||
import {
|
|
||||||
hasAutoReloaded,
|
|
||||||
markAutoReloaded,
|
|
||||||
shouldAutoReload,
|
|
||||||
recordReloadBreadcrumb,
|
|
||||||
takeReloadBreadcrumb,
|
|
||||||
RELOAD_WINDOW_MS,
|
|
||||||
} from "./reload-guard";
|
|
||||||
|
|
||||||
// The shared budget is a single sessionStorage timestamp keyed here; both the
|
|
||||||
// reactive chunk-load boundary and the proactive version-coherence path read and
|
|
||||||
// stamp it, so at most one auto-reload happens per RELOAD_WINDOW_MS across BOTH.
|
|
||||||
const RELOAD_AT_KEY = "chunk-reload-at";
|
|
||||||
const NOW = 1_000_000_000_000;
|
|
||||||
|
|
||||||
describe("reload-guard", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
sessionStorage.clear();
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
sessionStorage.clear();
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hasAutoReloaded is false before any reload, true within the window after mark", () => {
|
|
||||||
expect(hasAutoReloaded(NOW)).toBe(false);
|
|
||||||
expect(markAutoReloaded(NOW)).toBe(true);
|
|
||||||
// Same key both paths share; stores the reload timestamp, not a flag.
|
|
||||||
expect(sessionStorage.getItem(RELOAD_AT_KEY)).toBe(String(NOW));
|
|
||||||
// Inside the window → budget spent → true (fall through to manual UI).
|
|
||||||
expect(hasAutoReloaded(NOW)).toBe(true);
|
|
||||||
expect(hasAutoReloaded(NOW + RELOAD_WINDOW_MS)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hasAutoReloaded is false again once the window has elapsed (a later deploy recovers)", () => {
|
|
||||||
markAutoReloaded(NOW);
|
|
||||||
// Strictly older than the window → a new deploy's mismatch may reload again.
|
|
||||||
expect(hasAutoReloaded(NOW + RELOAD_WINDOW_MS + 1)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hasAutoReloaded returns true when reading storage throws (fail toward not reloading)", () => {
|
|
||||||
vi.stubGlobal("sessionStorage", {
|
|
||||||
getItem: () => {
|
|
||||||
throw new Error("storage disabled");
|
|
||||||
},
|
|
||||||
setItem: () => {
|
|
||||||
throw new Error("storage disabled");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
expect(hasAutoReloaded()).toBe(true);
|
|
||||||
} finally {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hasAutoReloaded treats an unparseable stored timestamp as never-reloaded", () => {
|
|
||||||
sessionStorage.setItem(RELOAD_AT_KEY, "not-a-number");
|
|
||||||
expect(hasAutoReloaded(NOW)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("markAutoReloaded returns false when writing storage throws", () => {
|
|
||||||
vi.stubGlobal("sessionStorage", {
|
|
||||||
getItem: () => null,
|
|
||||||
setItem: () => {
|
|
||||||
throw new Error("storage disabled");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
expect(markAutoReloaded()).toBe(false);
|
|
||||||
} finally {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("records and then takes a breadcrumb once (cleared on read)", () => {
|
|
||||||
recordReloadBreadcrumb({
|
|
||||||
path: "proactive",
|
|
||||||
serverVersion: "test-B",
|
|
||||||
clientVersion: "test-A",
|
|
||||||
});
|
|
||||||
const crumb = takeReloadBreadcrumb();
|
|
||||||
expect(crumb).toMatchObject({
|
|
||||||
path: "proactive",
|
|
||||||
serverVersion: "test-B",
|
|
||||||
clientVersion: "test-A",
|
|
||||||
});
|
|
||||||
expect(typeof crumb?.at).toBe("number");
|
|
||||||
// Cleared on read → a second take returns null.
|
|
||||||
expect(takeReloadBreadcrumb()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("takeReloadBreadcrumb returns null when nothing was recorded", () => {
|
|
||||||
expect(takeReloadBreadcrumb()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("recordReloadBreadcrumb swallows a storage-write error (diagnostics only)", () => {
|
|
||||||
vi.stubGlobal("sessionStorage", {
|
|
||||||
getItem: () => null,
|
|
||||||
setItem: () => {
|
|
||||||
throw new Error("storage disabled");
|
|
||||||
},
|
|
||||||
removeItem: () => {},
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
expect(() =>
|
|
||||||
recordReloadBreadcrumb({ path: "chunk-boundary" }),
|
|
||||||
).not.toThrow();
|
|
||||||
} finally {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// The pure window gate replaces the old one-shot flag: it must permit recovery
|
|
||||||
// across several deploys in one tab (each > window apart) while still stopping an
|
|
||||||
// infinite reload loop when a lazy chunk is permanently broken (a second failure
|
|
||||||
// < window). Moved here from the chunk-load boundary now that it is the shared
|
|
||||||
// guard both paths route through.
|
|
||||||
describe("shouldAutoReload", () => {
|
|
||||||
const WINDOW = RELOAD_WINDOW_MS;
|
|
||||||
|
|
||||||
it("allows a reload when we have never auto-reloaded", () => {
|
|
||||||
expect(shouldAutoReload(NOW, null, WINDOW)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows a reload when the last one was 6 minutes ago (outside the window)", () => {
|
|
||||||
expect(shouldAutoReload(NOW, NOW - 6 * 60 * 1000, WINDOW)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("blocks a reload when the last one was 1 minute ago (inside the window)", () => {
|
|
||||||
expect(shouldAutoReload(NOW, NOW - 1 * 60 * 1000, WINDOW)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("blocks a reload exactly at the window boundary (not strictly older)", () => {
|
|
||||||
expect(shouldAutoReload(NOW, NOW - WINDOW, WINDOW)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows a reload when the stored timestamp is unparseable (NaN)", () => {
|
|
||||||
expect(shouldAutoReload(NOW, NaN, WINDOW)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
// Shared, window-based auto-reload budget.
|
|
||||||
//
|
|
||||||
// Both auto-reload paths — the reactive chunk-load-error-boundary (recovers
|
|
||||||
// AFTER a stale lazy chunk 404s) and the proactive version-coherence feature
|
|
||||||
// (reloads BEFORE the tab hits a stale chunk) — go through these functions so
|
|
||||||
// they share ONE window-scoped reload budget: at most a single automatic
|
|
||||||
// reload per RELOAD_WINDOW_MS across BOTH paths. A window (rather than a
|
|
||||||
// permanent one-shot flag) lets a SECOND deploy in the same tab's lifetime
|
|
||||||
// recover too, while a permanent skew, node oscillation, or a genuinely-missing
|
|
||||||
// chunk still degrades to a manual banner/UI after the first reload instead of
|
|
||||||
// looping. When sessionStorage is unavailable every mismatch degrades to the
|
|
||||||
// manual UI — no unguarded reload.
|
|
||||||
|
|
||||||
// sessionStorage key holding the epoch-ms timestamp of the last automatic reload
|
|
||||||
// (shared by both paths).
|
|
||||||
const RELOAD_AT_KEY = "chunk-reload-at";
|
|
||||||
|
|
||||||
// Allow at most one automatic reload per this window. A stale-deploy 404 is cured
|
|
||||||
// by a single reload, so anything inside the window is treated as a reload loop
|
|
||||||
// (permanently-broken chunk / permanent skew) and falls through to the manual UI.
|
|
||||||
export const RELOAD_WINDOW_MS = 5 * 60 * 1000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure window decision, unit-tested in isolation: auto-reload only if we have
|
|
||||||
* never auto-reloaded (lastReloadAt null/NaN) or the last one was strictly older
|
|
||||||
* than the window. Anything inside the window is suppressed to break an infinite
|
|
||||||
* reload loop.
|
|
||||||
*/
|
|
||||||
export function shouldAutoReload(
|
|
||||||
now: number,
|
|
||||||
lastReloadAt: number | null,
|
|
||||||
windowMs: number,
|
|
||||||
): boolean {
|
|
||||||
if (lastReloadAt === null || Number.isNaN(lastReloadAt)) return true;
|
|
||||||
return now - lastReloadAt > windowMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Has an automatic reload already happened within the current window (so the
|
|
||||||
* shared budget is spent right now)? Both paths check this before reloading; a
|
|
||||||
* `true` return means fall through to the manual banner/UI instead of reloading.
|
|
||||||
*
|
|
||||||
* A storage read error (private mode / disabled) is reported as `true` so the
|
|
||||||
* caller fails toward NOT reloading — an unguarded loop is worse than a stale
|
|
||||||
* tab the user can reload manually. Note a window (not a permanent flag): once
|
|
||||||
* the window elapses a later deploy's mismatch is allowed to reload again.
|
|
||||||
*/
|
|
||||||
export function hasAutoReloaded(now: number = Date.now()): boolean {
|
|
||||||
try {
|
|
||||||
const raw = sessionStorage.getItem(RELOAD_AT_KEY);
|
|
||||||
const lastReloadAt = raw === null ? null : Number.parseInt(raw, 10);
|
|
||||||
return !shouldAutoReload(now, lastReloadAt, RELOAD_WINDOW_MS);
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stamp the shared window as consumed now — record that an automatic reload is
|
|
||||||
* being performed within the current RELOAD_WINDOW_MS window.
|
|
||||||
*
|
|
||||||
* Returns whether the write succeeded. A `false` return (storage unavailable)
|
|
||||||
* means the caller MUST NOT reload — otherwise the stamp would never stick and
|
|
||||||
* the reload could loop.
|
|
||||||
*/
|
|
||||||
export function markAutoReloaded(now: number = Date.now()): boolean {
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem(RELOAD_AT_KEY, String(now));
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Diagnostic breadcrumb for an automatic reload. Written right before
|
|
||||||
// window.location.reload() (which clears the console) and read back on the next
|
|
||||||
// page load, so a "the tab reloaded itself / it's looping" field report is
|
|
||||||
// diagnosable: which path fired (proactive version-coherence vs the reactive
|
|
||||||
// chunk-load boundary) and which version pair triggered it. sessionStorage
|
|
||||||
// survives a same-tab reload, unlike the console.
|
|
||||||
const RELOAD_BREADCRUMB_KEY = "reload-breadcrumb";
|
|
||||||
|
|
||||||
export type ReloadBreadcrumb = {
|
|
||||||
path: "proactive" | "chunk-boundary";
|
|
||||||
serverVersion?: string;
|
|
||||||
clientVersion?: string;
|
|
||||||
at: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persist a best-effort breadcrumb just before an automatic reload. Failures
|
|
||||||
* (storage unavailable) are swallowed — this is diagnostics only and must never
|
|
||||||
* block or alter the reload decision.
|
|
||||||
*/
|
|
||||||
export function recordReloadBreadcrumb(
|
|
||||||
entry: Omit<ReloadBreadcrumb, "at">,
|
|
||||||
): void {
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem(
|
|
||||||
RELOAD_BREADCRUMB_KEY,
|
|
||||||
JSON.stringify({ ...entry, at: Date.now() }),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// best-effort diagnostics only
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read and clear the breadcrumb left by an auto-reload in the previous page
|
|
||||||
* load. Cleared on read so it surfaces exactly once per reload.
|
|
||||||
*/
|
|
||||||
export function takeReloadBreadcrumb(): ReloadBreadcrumb | null {
|
|
||||||
try {
|
|
||||||
const raw = sessionStorage.getItem(RELOAD_BREADCRUMB_KEY);
|
|
||||||
if (!raw) return null;
|
|
||||||
sessionStorage.removeItem(RELOAD_BREADCRUMB_KEY);
|
|
||||||
return JSON.parse(raw) as ReloadBreadcrumb;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { templateRoute, KNOWN_ROUTE_TEMPLATES } from "./route-template";
|
import { templateRoute } from "./route-template";
|
||||||
|
|
||||||
describe("templateRoute", () => {
|
describe("templateRoute", () => {
|
||||||
it("templates a space page path (never leaks slugs)", () => {
|
it("templates a space page path (never leaks slugs)", () => {
|
||||||
@@ -32,30 +32,4 @@ describe("templateRoute", () => {
|
|||||||
expect(templateRoute("/weird/unknown/thing")).toBe("other");
|
expect(templateRoute("/weird/unknown/thing")).toBe("other");
|
||||||
expect(templateRoute("/s/team/p/slug/extra/segments")).toBe("other");
|
expect(templateRoute("/s/team/p/slug/extra/segments")).toBe("other");
|
||||||
});
|
});
|
||||||
|
|
||||||
// The server's /api/telemetry/vitals mirror (ALLOWED_ROUTE_TEMPLATES) drops any
|
|
||||||
// route outside KNOWN_ROUTE_TEMPLATES, so templateRoute must NEVER emit a label
|
|
||||||
// that is not in that dictionary — otherwise legit client metrics get dropped.
|
|
||||||
it("only ever emits labels contained in KNOWN_ROUTE_TEMPLATES (#495)", () => {
|
|
||||||
const samples = [
|
|
||||||
"/",
|
|
||||||
"/home",
|
|
||||||
"/settings/members",
|
|
||||||
"/settings/groups/g-1",
|
|
||||||
"/s/team",
|
|
||||||
"/s/team/trash",
|
|
||||||
"/s/team/p/slug",
|
|
||||||
"/p/slug",
|
|
||||||
"/share/abc",
|
|
||||||
"/share/abc/p/slug",
|
|
||||||
"/share/p/slug",
|
|
||||||
"/labels/urgent",
|
|
||||||
"/invites/inv-1",
|
|
||||||
"/weird/unknown/thing", // -> "other"
|
|
||||||
"/deep/unmatched/x/y/z", // -> "other"
|
|
||||||
];
|
|
||||||
for (const path of samples) {
|
|
||||||
expect(KNOWN_ROUTE_TEMPLATES.has(templateRoute(path))).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,22 +44,6 @@ const STATIC_ROUTES = new Set<string>([
|
|||||||
'/settings/sharing',
|
'/settings/sharing',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
|
||||||
* The COMPLETE, finite vocabulary `templateRoute` can ever emit: the two
|
|
||||||
* synthetic labels (`/` and `other`), the static routes, and the dynamic
|
|
||||||
* templates. Exported so the public `/api/telemetry/vitals` endpoint can reject
|
|
||||||
* any `route` outside this dictionary server-side (the endpoint is anonymous, so
|
|
||||||
* an un-checked `route` is a free-text write surface). The server keeps a mirror
|
|
||||||
* (`ALLOWED_ROUTE_TEMPLATES` in client-metrics.constants.ts) — this is the
|
|
||||||
* canonical source; keep them in lockstep.
|
|
||||||
*/
|
|
||||||
export const KNOWN_ROUTE_TEMPLATES: ReadonlySet<string> = new Set<string>([
|
|
||||||
'/',
|
|
||||||
'other',
|
|
||||||
...STATIC_ROUTES,
|
|
||||||
...ROUTE_PATTERNS.map((p) => p.template),
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function templateRoute(pathname: string): string {
|
export function templateRoute(pathname: string): string {
|
||||||
// Normalise a trailing slash (except root).
|
// Normalise a trailing slash (except root).
|
||||||
const path =
|
const path =
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import "@mantine/spotlight/styles.css";
|
|||||||
import "@mantine/notifications/styles.css";
|
import "@mantine/notifications/styles.css";
|
||||||
import '@mantine/dates/styles.css';
|
import '@mantine/dates/styles.css';
|
||||||
import "@/styles/a11y-overrides.css";
|
import "@/styles/a11y-overrides.css";
|
||||||
import "@/styles/notification-overrides.css";
|
|
||||||
|
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
@@ -48,15 +47,7 @@ function renderApp() {
|
|||||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||||
<ModalsProvider>
|
<ModalsProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
{/* top-center: toasts sit in the top of the viewport, in the line
|
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
||||||
of sight, and no longer cover centered content (e.g. "Load
|
|
||||||
more"). The below-chrome vertical offset is applied via a
|
|
||||||
position-scoped CSS rule in notification-overrides.css (NOT an
|
|
||||||
inline `style`): Mantine renders all six position containers at
|
|
||||||
once and an inline root style would land on every one, giving the
|
|
||||||
bottom-* containers both top+bottom → full-viewport transparent
|
|
||||||
overlays that swallow clicks. */}
|
|
||||||
<Notifications position="top-center" limit={3} zIndex={10000} />
|
|
||||||
<HelmetProvider>
|
<HelmetProvider>
|
||||||
{/* Root boundary above every lazy route's Suspense: a stale-chunk
|
{/* Root boundary above every lazy route's Suspense: a stale-chunk
|
||||||
404 after a deploy is caught and recovered here instead of
|
404 after a deploy is caught and recovered here instead of
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
/*
|
|
||||||
* Toast (Mantine Notification) visibility overrides.
|
|
||||||
* Mantine renders colorless toasts on --mantine-color-body (== the page
|
|
||||||
* background: white in light mode) with a faint shadow, so on white pages the
|
|
||||||
* card has no visible edge. These rules give every toast a type-tinted
|
|
||||||
* background, a WCAG-checked border and a stronger shadow so it separates from
|
|
||||||
* the page. The [data-mantine-color-scheme] + static-class selector (0,2,0)
|
|
||||||
* beats Mantine's own (0,1,0) rules regardless of stylesheet order (Mantine's
|
|
||||||
* bg/border rules wrap the scheme attribute in :where(), so they stay (0,1,0)).
|
|
||||||
* --notification-color is defined on the same element (defaults to primary,
|
|
||||||
* set per `color` prop), so tint/border follow the toast type. This also covers
|
|
||||||
* the loading/import toast (no accent bar, since the spinner takes the icon
|
|
||||||
* slot): its visibility comes from tone + border + shadow + the colored spinner.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Push the top-anchored toast containers below the top chrome (fixed 45px
|
|
||||||
* header + optional 45px format toolbar + ~6px gap) so a toast (z-index 10000)
|
|
||||||
* neither covers nor intercepts clicks on the header/toolbar (both z-index 99).
|
|
||||||
*
|
|
||||||
* Scoped to [data-position^='top'] on purpose. Mantine renders ALL SIX position
|
|
||||||
* containers simultaneously (`position` only routes toasts into one via the
|
|
||||||
* store); the root `style` prop would be applied to every one of them by
|
|
||||||
* getStyles("root"). A blanket `top` would land on the bottom-* containers too
|
|
||||||
* (which carry `bottom:16px`) → position:fixed + both edges + height:auto makes
|
|
||||||
* them stretch the full viewport height, and the container root has neither
|
|
||||||
* pointer-events:none nor a background, so those transparent z-10000 overlays
|
|
||||||
* would swallow clicks across the whole page. Restricting to top-* leaves the
|
|
||||||
* bottom containers at height:0.
|
|
||||||
*
|
|
||||||
* Specificity: `.mantine-Notifications-root[data-position^='top']` is (0,2,0)
|
|
||||||
* (class + attribute) and beats Mantine's own top rule
|
|
||||||
* `.m_b37d9ac7:where([data-position='top-center']){top:16px}` which is (0,1,0)
|
|
||||||
* (the :where() contributes 0), regardless of stylesheet order.
|
|
||||||
*/
|
|
||||||
.mantine-Notifications-root[data-position^='top'] {
|
|
||||||
top: 96px;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-mantine-color-scheme='light'] .mantine-Notification-root {
|
|
||||||
/* ~10% type color over white: clearly off-white, text contrast preserved */
|
|
||||||
background-color: color-mix(in srgb, var(--notification-color) 10%, var(--mantine-color-white));
|
|
||||||
/* Border must clear WCAG 3:1 non-text contrast on white. The repo rejects
|
|
||||||
gray-4 for this (a11y-overrides.css); gray-6 base (~3.32:1) darkened by the
|
|
||||||
type color stays >= 3:1. */
|
|
||||||
border: 1px solid color-mix(in srgb, var(--notification-color) 45%, var(--mantine-color-gray-6));
|
|
||||||
box-shadow: var(--mantine-shadow-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-mantine-color-scheme='dark'] .mantine-Notification-root {
|
|
||||||
/* Dark page (dark-7/8) vs toast (dark-6) already separate a little; border +
|
|
||||||
shadow carry the type cue here (a 7% dark tint was near-invisible). */
|
|
||||||
background-color: color-mix(in srgb, var(--notification-color) 14%, var(--mantine-color-dark-6));
|
|
||||||
border: 1px solid color-mix(in srgb, var(--notification-color) 45%, var(--mantine-color-dark-3));
|
|
||||||
box-shadow: var(--mantine-shadow-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mantine's message-with-title color is gray-6 (#868e96, already only ~3.32:1
|
|
||||||
on white — below AA 4.5:1); the new tint pushes it lower. Bump to gray-7 to
|
|
||||||
keep multi-line colored toasts readable, consistent with the repo's existing
|
|
||||||
WCAG tuning (theme.ts already bumps this same gray-6 up elsewhere). */
|
|
||||||
[data-mantine-color-scheme='light'] .mantine-Notification-description[data-with-title] {
|
|
||||||
color: var(--mantine-color-gray-7);
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { defineConfig, loadEnv, type Plugin } from "vite";
|
import { defineConfig, loadEnv } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { compression } from "vite-plugin-compression2";
|
import { compression } from "vite-plugin-compression2";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import * as fs from "node:fs";
|
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
|
|
||||||
const envPath = path.resolve(process.cwd(), "..", "..");
|
const envPath = path.resolve(process.cwd(), "..", "..");
|
||||||
@@ -25,32 +24,7 @@ function resolveAppVersion(cwd: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit <outDir>/version.json = { "version": appVersion } so the server can read
|
|
||||||
// the exact same build id the bundle was compiled with. The value is the SAME
|
|
||||||
// `appVersion` fed into `define.APP_VERSION`, so version.json and the baked-in
|
|
||||||
// global are identical by construction — the single source of truth (no
|
|
||||||
// runtime-env second copy that could drift and cause a false version mismatch).
|
|
||||||
function versionJsonPlugin(version: string): Plugin {
|
|
||||||
let outDir = "dist";
|
|
||||||
return {
|
|
||||||
name: "emit-version-json",
|
|
||||||
apply: "build",
|
|
||||||
configResolved(config) {
|
|
||||||
outDir = config.build.outDir;
|
|
||||||
},
|
|
||||||
writeBundle() {
|
|
||||||
const root = path.resolve(process.cwd(), outDir);
|
|
||||||
fs.mkdirSync(root, { recursive: true });
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(root, "version.json"),
|
|
||||||
JSON.stringify({ version }),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const appVersion = resolveAppVersion(envPath);
|
|
||||||
const {
|
const {
|
||||||
APP_URL,
|
APP_URL,
|
||||||
FILE_UPLOAD_SIZE_LIMIT,
|
FILE_UPLOAD_SIZE_LIMIT,
|
||||||
@@ -78,11 +52,10 @@ export default defineConfig(({ mode }) => {
|
|||||||
POSTHOG_HOST,
|
POSTHOG_HOST,
|
||||||
POSTHOG_KEY,
|
POSTHOG_KEY,
|
||||||
},
|
},
|
||||||
APP_VERSION: JSON.stringify(appVersion),
|
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
versionJsonPlugin(appVersion),
|
|
||||||
// Emit .br and .gz next to every built asset so the server can serve the
|
// Emit .br and .gz next to every built asset so the server can serve the
|
||||||
// precompressed copy (see @fastify/static preCompressed in static.module.ts).
|
// precompressed copy (see @fastify/static preCompressed in static.module.ts).
|
||||||
compression({
|
compression({
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
|
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
|
||||||
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
|
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/prosemirror-markdown build && pnpm --filter @docmost/token-estimate build",
|
"pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/prosemirror-markdown build",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:int": "jest --config test/jest-integration.json",
|
"test:int": "jest --config test/jest-integration.json",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
@@ -41,10 +41,10 @@
|
|||||||
"@aws-sdk/s3-request-presigner": "3.1050.0",
|
"@aws-sdk/s3-request-presigner": "3.1050.0",
|
||||||
"@azure/storage-blob": "12.31.0",
|
"@azure/storage-blob": "12.31.0",
|
||||||
"@clickhouse/client": "^1.18.2",
|
"@clickhouse/client": "^1.18.2",
|
||||||
|
"@docmost/editor-ext": "workspace:*",
|
||||||
"@docmost/mcp": "workspace:*",
|
"@docmost/mcp": "workspace:*",
|
||||||
"@docmost/pdf-inspector": "1.9.6",
|
"@docmost/pdf-inspector": "1.9.6",
|
||||||
"@docmost/prosemirror-markdown": "workspace:*",
|
"@docmost/prosemirror-markdown": "workspace:*",
|
||||||
"@docmost/token-estimate": "workspace:*",
|
|
||||||
"@fastify/compress": "^9.0.0",
|
"@fastify/compress": "^9.0.0",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/multipart": "^10.0.0",
|
"@fastify/multipart": "^10.0.0",
|
||||||
@@ -207,7 +207,6 @@
|
|||||||
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
||||||
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
||||||
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
|
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
|
||||||
"^@docmost/token-estimate$": "<rootDir>/../../../packages/token-estimate/src/index.ts",
|
|
||||||
"^src/(.*)$": "<rootDir>/$1",
|
"^src/(.*)$": "<rootDir>/$1",
|
||||||
"^@tiptap/react$": "<rootDir>/../test/stubs/tiptap-react.js"
|
"^@tiptap/react$": "<rootDir>/../test/stubs/tiptap-react.js"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { CacheModule } from '@nestjs/cache-manager';
|
|||||||
import KeyvRedis from '@keyv/redis';
|
import KeyvRedis from '@keyv/redis';
|
||||||
import { LoggerModule } from './common/logger/logger.module';
|
import { LoggerModule } from './common/logger/logger.module';
|
||||||
import { ClsModule } from 'nestjs-cls';
|
import { ClsModule } from 'nestjs-cls';
|
||||||
import { AuditModule } from './integrations/audit/audit.module';
|
import { NoopAuditModule } from './integrations/audit/audit.module';
|
||||||
import { ThrottleModule } from './integrations/throttle/throttle.module';
|
import { ThrottleModule } from './integrations/throttle/throttle.module';
|
||||||
import { McpModule } from './integrations/mcp/mcp.module';
|
import { McpModule } from './integrations/mcp/mcp.module';
|
||||||
import { SandboxModule } from './integrations/sandbox/sandbox.module';
|
import { SandboxModule } from './integrations/sandbox/sandbox.module';
|
||||||
@@ -55,7 +55,7 @@ try {
|
|||||||
middleware: { mount: true },
|
middleware: { mount: true },
|
||||||
}),
|
}),
|
||||||
LoggerModule,
|
LoggerModule,
|
||||||
AuditModule,
|
NoopAuditModule,
|
||||||
CoreModule,
|
CoreModule,
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
EnvironmentModule,
|
EnvironmentModule,
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { TransclusionService } from '../core/page/transclusion/transclusion.serv
|
|||||||
import { TransclusionModule } from '../core/page/transclusion/transclusion.module';
|
import { TransclusionModule } from '../core/page/transclusion/transclusion.module';
|
||||||
import { StorageModule } from '../integrations/storage/storage.module';
|
import { StorageModule } from '../integrations/storage/storage.module';
|
||||||
import { EnvironmentModule } from '../integrations/environment/environment.module';
|
import { EnvironmentModule } from '../integrations/environment/environment.module';
|
||||||
import { ApiKeyModule } from '../core/api-key/api-key.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -32,7 +31,6 @@ import { ApiKeyModule } from '../core/api-key/api-key.module';
|
|||||||
exports: [CollaborationGateway],
|
exports: [CollaborationGateway],
|
||||||
imports: [
|
imports: [
|
||||||
TokenModule,
|
TokenModule,
|
||||||
ApiKeyModule,
|
|
||||||
WatcherModule,
|
WatcherModule,
|
||||||
StorageModule.forRootAsync({
|
StorageModule.forRootAsync({
|
||||||
imports: [EnvironmentModule],
|
imports: [EnvironmentModule],
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
FootnotesList,
|
FootnotesList,
|
||||||
FootnoteDefinition,
|
FootnoteDefinition,
|
||||||
PageEmbed,
|
PageEmbed,
|
||||||
|
Code,
|
||||||
} from '@docmost/editor-ext';
|
} from '@docmost/editor-ext';
|
||||||
import { convertProseMirrorToMarkdown } from '@docmost/prosemirror-markdown';
|
import { convertProseMirrorToMarkdown } from '@docmost/prosemirror-markdown';
|
||||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||||
@@ -67,7 +68,12 @@ export const tiptapExtensions = [
|
|||||||
link: false,
|
link: false,
|
||||||
trailingNode: false,
|
trailingNode: false,
|
||||||
heading: false,
|
heading: false,
|
||||||
|
// #515: replace StarterKit's bundled inline `code` (which inherits tiptap's
|
||||||
|
// `excludes: "_"`) with the shared editor-ext `Code` below, so the server's
|
||||||
|
// HTML->PM parse/export keeps code co-occurring with other marks.
|
||||||
|
code: false,
|
||||||
}),
|
}),
|
||||||
|
Code,
|
||||||
Heading,
|
Heading,
|
||||||
UniqueID.configure({
|
UniqueID.configure({
|
||||||
types: ['heading', 'paragraph', 'transclusionSource'],
|
types: ['heading', 'paragraph', 'transclusionSource'],
|
||||||
|
|||||||
@@ -1,36 +1,10 @@
|
|||||||
|
export const HISTORY_INTERVAL = 5 * 60 * 1000;
|
||||||
|
export const HISTORY_FAST_INTERVAL = 60 * 1000;
|
||||||
|
export const HISTORY_FAST_THRESHOLD = 5 * 60 * 1000;
|
||||||
|
|
||||||
// #348 — debounce window for the per-page RAG re-embed job. Repeated saves
|
// #348 — debounce window for the per-page RAG re-embed job. Repeated saves
|
||||||
// within this window collapse to a single delayed job (coalesced by a stable
|
// within this window collapse to a single delayed job (coalesced by a stable
|
||||||
// jobId), so active editing does not pile up expensive re-embeds (external API
|
// jobId), so active editing does not pile up expensive re-embeds (external API
|
||||||
// + page_embeddings rewrite, concurrency 1). The worker reads the CURRENT page
|
// + page_embeddings rewrite, concurrency 1). The worker reads the CURRENT page
|
||||||
// state at run time, so the last content within the window wins.
|
// state at run time, so the last content within the window wins.
|
||||||
export const EMBED_DEBOUNCE_MS = 30 * 1000;
|
export const EMBED_DEBOUNCE_MS = 30 * 1000;
|
||||||
|
|
||||||
/**
|
|
||||||
* #370 — page-history intentionality tiers. Domain of `page_history.kind`.
|
|
||||||
* - 'manual' / 'agent' → Tier 1 versions (intentional points)
|
|
||||||
* - 'idle' / 'boundary' → Tier 0 autosnapshots (safety net)
|
|
||||||
* A legacy `null` kind is treated as an autosave.
|
|
||||||
*/
|
|
||||||
export type PageHistoryKind = 'manual' | 'agent' | 'idle' | 'boundary';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #370 — trailing idle-flush windows. A page's pending idle snapshot is
|
|
||||||
* re-armed on every store and fires this long after edits go quiet, so a burst
|
|
||||||
* of edits collapses into a single autosnapshot instead of one-per-store. Human
|
|
||||||
* sessions are noisier and less risky, so they flush less often than the agent.
|
|
||||||
*/
|
|
||||||
export const IDLE_INTERVAL_USER = 60 * 60 * 1000; // 60m
|
|
||||||
export const IDLE_INTERVAL_AGENT = 15 * 60 * 1000; // 15m
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #370 — max-wait ceiling for the idle flush. Pure trailing debounce starves the
|
|
||||||
* safety net: hocuspocus stores at least every ~45s, so a CONTINUOUS editing
|
|
||||||
* session would re-arm the trailing timer forever and never take an idle
|
|
||||||
* snapshot until edits finally go quiet (up to IDLE_INTERVAL_USER = 60m). This
|
|
||||||
* ceiling bounds the actual wait from the FIRST edit of a burst, so an idle
|
|
||||||
* snapshot fires at least this often during a long unbroken session — restoring
|
|
||||||
* a recovery point cadence closer to the old heuristic without one-per-store
|
|
||||||
* noise. Mirrors hocuspocus's own maxDebounce idea.
|
|
||||||
*/
|
|
||||||
export const IDLE_MAX_WAIT_USER = 10 * 60 * 1000; // 10m
|
|
||||||
export const IDLE_MAX_WAIT_AGENT = 5 * 60 * 1000; // 5m
|
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ describe('AuthenticationExtension.onAuthenticate', () => {
|
|||||||
let pageRepo: { findById: jest.Mock };
|
let pageRepo: { findById: jest.Mock };
|
||||||
let spaceMemberRepo: { getUserSpaceRoles: jest.Mock };
|
let spaceMemberRepo: { getUserSpaceRoles: jest.Mock };
|
||||||
let pagePermissionRepo: { canUserEditPage: jest.Mock };
|
let pagePermissionRepo: { canUserEditPage: jest.Mock };
|
||||||
let apiKeyService: { validate: jest.Mock };
|
|
||||||
|
|
||||||
// Build the hocuspocus onAuthenticate payload. connectionConfig.readOnly
|
// Build the hocuspocus onAuthenticate payload. connectionConfig.readOnly
|
||||||
// starts false; the extension flips it to true on a read-only downgrade.
|
// starts false; the extension flips it to true on a read-only downgrade.
|
||||||
@@ -80,15 +79,12 @@ describe('AuthenticationExtension.onAuthenticate', () => {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
apiKeyService = { validate: jest.fn().mockResolvedValue({ user: {}, workspace: {} }) };
|
|
||||||
|
|
||||||
ext = new AuthenticationExtension(
|
ext = new AuthenticationExtension(
|
||||||
tokenService as any,
|
tokenService as any,
|
||||||
userRepo as any,
|
userRepo as any,
|
||||||
pageRepo as any,
|
pageRepo as any,
|
||||||
spaceMemberRepo as any,
|
spaceMemberRepo as any,
|
||||||
pagePermissionRepo as any,
|
pagePermissionRepo as any,
|
||||||
apiKeyService as any,
|
|
||||||
);
|
);
|
||||||
// Silence the extension's logger (it warns/debugs on denial branches).
|
// Silence the extension's logger (it warns/debugs on denial branches).
|
||||||
jest.spyOn(ext['logger'], 'warn').mockImplementation(() => undefined);
|
jest.spyOn(ext['logger'], 'warn').mockImplementation(() => undefined);
|
||||||
@@ -235,73 +231,4 @@ describe('AuthenticationExtension.onAuthenticate', () => {
|
|||||||
// No internal ai_chats row for an MCP/service-account collab edit → null.
|
// No internal ai_chats row for an MCP/service-account collab edit → null.
|
||||||
expect(ctx.aiChatId).toBeNull();
|
expect(ctx.aiChatId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- #501: api-key laundering guard (fail-closed discriminator) ----------
|
|
||||||
describe('api-key laundering guard', () => {
|
|
||||||
it('api_key principal → row-checks the key on connect (valid key proceeds)', async () => {
|
|
||||||
tokenService.verifyJwt.mockResolvedValue(
|
|
||||||
buildJwt({ principal: 'api_key', apiKeyId: 'key-1' }),
|
|
||||||
);
|
|
||||||
const data = buildData();
|
|
||||||
await ext.onAuthenticate(data as any);
|
|
||||||
|
|
||||||
expect(apiKeyService.validate).toHaveBeenCalledTimes(1);
|
|
||||||
expect(apiKeyService.validate).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ apiKeyId: 'key-1', type: JwtType.API_KEY }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('REVOKED api_key → Unauthorized on connect, BEFORE any page/user lookup', async () => {
|
|
||||||
tokenService.verifyJwt.mockResolvedValue(
|
|
||||||
buildJwt({ principal: 'api_key', apiKeyId: 'key-1' }),
|
|
||||||
);
|
|
||||||
// The shared validator denies a revoked key.
|
|
||||||
apiKeyService.validate.mockRejectedValue(new UnauthorizedException());
|
|
||||||
|
|
||||||
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
|
|
||||||
UnauthorizedException,
|
|
||||||
);
|
|
||||||
// No new collab connection: the key check gates before page access.
|
|
||||||
expect(pageRepo.findById).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('api_key principal missing apiKeyId → Unauthorized (malformed)', async () => {
|
|
||||||
tokenService.verifyJwt.mockResolvedValue(buildJwt({ principal: 'api_key' }));
|
|
||||||
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
|
|
||||||
UnauthorizedException,
|
|
||||||
);
|
|
||||||
expect(apiKeyService.validate).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('session principal → NO api-key check (session-backed, incl. internal agent)', async () => {
|
|
||||||
tokenService.verifyJwt.mockResolvedValue(buildJwt({ principal: 'session' }));
|
|
||||||
await ext.onAuthenticate(buildData() as any);
|
|
||||||
expect(apiKeyService.validate).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('claimless token WITHIN the grace window → trusted (legacy pre-rollout)', async () => {
|
|
||||||
// Default rolloutAt = now, so we are inside the grace window.
|
|
||||||
tokenService.verifyJwt.mockResolvedValue(buildJwt()); // no principal
|
|
||||||
await expect(ext.onAuthenticate(buildData() as any)).resolves.toBeDefined();
|
|
||||||
expect(apiKeyService.validate).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('claimless token AFTER the grace window → Unauthorized (fail-closed)', async () => {
|
|
||||||
// Move the rollout reference far into the past so the grace has elapsed.
|
|
||||||
(ext as any).rolloutAt = Date.now() - 25 * 60 * 60 * 1000;
|
|
||||||
tokenService.verifyJwt.mockResolvedValue(buildJwt()); // no principal
|
|
||||||
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
|
|
||||||
UnauthorizedException,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('infra error from the api-key row-check propagates (not masked)', async () => {
|
|
||||||
tokenService.verifyJwt.mockResolvedValue(
|
|
||||||
buildJwt({ principal: 'api_key', apiKeyId: 'key-1' }),
|
|
||||||
);
|
|
||||||
const boom = new Error('db down');
|
|
||||||
apiKeyService.validate.mockRejectedValue(boom);
|
|
||||||
await expect(ext.onAuthenticate(buildData() as any)).rejects.toBe(boom);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,37 +14,20 @@ import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
|
|||||||
import { SpaceRole } from '../../common/helpers/types/permission';
|
import { SpaceRole } from '../../common/helpers/types/permission';
|
||||||
import { isUserDisabled } from '../../common/helpers';
|
import { isUserDisabled } from '../../common/helpers';
|
||||||
import { getPageId } from '../collaboration.util';
|
import { getPageId } from '../collaboration.util';
|
||||||
import {
|
import { JwtCollabPayload, JwtType } from '../../core/auth/dto/jwt-payload';
|
||||||
JwtApiKeyPayload,
|
|
||||||
JwtCollabPayload,
|
|
||||||
JwtType,
|
|
||||||
} from '../../core/auth/dto/jwt-payload';
|
|
||||||
import { resolveProvenance } from '../../common/decorators/auth-provenance.decorator';
|
import { resolveProvenance } from '../../common/decorators/auth-provenance.decorator';
|
||||||
import { observeCollabAuth } from '../../integrations/metrics/metrics.registry';
|
import { observeCollabAuth } from '../../integrations/metrics/metrics.registry';
|
||||||
import { ApiKeyService } from '../../core/api-key/api-key.service';
|
|
||||||
|
|
||||||
// Max lifetime of a collab token (generateCollabToken uses expiresIn '24h'). Used
|
|
||||||
// as the rollout grace window below: once this long has elapsed since this
|
|
||||||
// process started serving the #501 code, every STILL-VALID collab token was
|
|
||||||
// necessarily minted post-rollout and MUST carry the `principal` discriminator,
|
|
||||||
// so a claimless one is a bug and is rejected (fail-closed) rather than trusted.
|
|
||||||
const COLLAB_TOKEN_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthenticationExtension implements Extension {
|
export class AuthenticationExtension implements Extension {
|
||||||
private readonly logger = new Logger(AuthenticationExtension.name);
|
private readonly logger = new Logger(AuthenticationExtension.name);
|
||||||
|
|
||||||
// Reference instant for the claimless-rejection grace window. Overridable so a
|
|
||||||
// unit test can drive the pre-/post-grace boundary without wall-clock waits.
|
|
||||||
protected rolloutAt = Date.now();
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private tokenService: TokenService,
|
private tokenService: TokenService,
|
||||||
private userRepo: UserRepo,
|
private userRepo: UserRepo,
|
||||||
private pageRepo: PageRepo,
|
private pageRepo: PageRepo,
|
||||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||||
private readonly apiKeyService: ApiKeyService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onAuthenticate(data: onAuthenticatePayload) {
|
async onAuthenticate(data: onAuthenticatePayload) {
|
||||||
@@ -71,36 +54,6 @@ export class AuthenticationExtension implements Extension {
|
|||||||
throw new UnauthorizedException('Invalid collab token');
|
throw new UnauthorizedException('Invalid collab token');
|
||||||
}
|
}
|
||||||
|
|
||||||
// #501 — fail-closed api-key laundering guard. A collab token minted by an
|
|
||||||
// api-key principal carries principal='api_key' + apiKeyId; re-check the key
|
|
||||||
// on connect so a REVOKED key gets NO new collab connections (a collab token
|
|
||||||
// outlives its 24h, but a revoked key can no longer open fresh ones). An
|
|
||||||
// api-key token missing its apiKeyId is malformed → reject. A claimless token
|
|
||||||
// (no recognized principal) is trusted only DURING the rollout grace window
|
|
||||||
// (a legacy pre-rollout session token, which api keys could never mint);
|
|
||||||
// once the grace has elapsed every valid token must carry the discriminator,
|
|
||||||
// so a claimless one is a bug and is rejected (not silently trusted for 24h).
|
|
||||||
const principal = jwtPayload.principal;
|
|
||||||
if (principal === 'api_key') {
|
|
||||||
if (!jwtPayload.apiKeyId) {
|
|
||||||
throw new UnauthorizedException();
|
|
||||||
}
|
|
||||||
// Row-check via the SHARED validator: throws Unauthorized on a revoked/
|
|
||||||
// expired/disabled key; an infra error propagates (not masked). No new
|
|
||||||
// connection for a dead key.
|
|
||||||
await this.apiKeyService.validate({
|
|
||||||
sub: jwtPayload.sub,
|
|
||||||
workspaceId: jwtPayload.workspaceId,
|
|
||||||
apiKeyId: jwtPayload.apiKeyId,
|
|
||||||
type: JwtType.API_KEY,
|
|
||||||
} as JwtApiKeyPayload);
|
|
||||||
} else if (principal !== 'session') {
|
|
||||||
// Unrecognized/absent discriminator: reject once past the grace window.
|
|
||||||
if (Date.now() - this.rolloutAt >= COLLAB_TOKEN_GRACE_MS) {
|
|
||||||
throw new UnauthorizedException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = jwtPayload.sub;
|
const userId = jwtPayload.sub;
|
||||||
const workspaceId = jwtPayload.workspaceId;
|
const workspaceId = jwtPayload.workspaceId;
|
||||||
|
|
||||||
|
|||||||
@@ -1,93 +1,84 @@
|
|||||||
import { computeHistoryJob, resolveSource } from './persistence.extension';
|
|
||||||
import {
|
import {
|
||||||
IDLE_INTERVAL_AGENT,
|
computeHistoryJob,
|
||||||
IDLE_INTERVAL_USER,
|
resolveSource,
|
||||||
IDLE_MAX_WAIT_AGENT,
|
} from './persistence.extension';
|
||||||
IDLE_MAX_WAIT_USER,
|
import {
|
||||||
|
HISTORY_FAST_INTERVAL,
|
||||||
|
HISTORY_FAST_THRESHOLD,
|
||||||
|
HISTORY_INTERVAL,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
|
|
||||||
|
// A fixed clock + fixed createdAt make pageAge deterministic.
|
||||||
|
const NOW = 1_700_000_000_000;
|
||||||
const PAGE_ID = '550e8400-e29b-41d4-a716-446655440000';
|
const PAGE_ID = '550e8400-e29b-41d4-a716-446655440000';
|
||||||
|
|
||||||
const page = { id: PAGE_ID };
|
// Build a minimal page whose age (NOW - createdAt) is exactly `ageMs`.
|
||||||
|
const pageAged = (ageMs: number) => ({
|
||||||
|
id: PAGE_ID,
|
||||||
|
createdAt: new Date(NOW - ageMs),
|
||||||
|
});
|
||||||
|
|
||||||
describe('computeHistoryJob (#370 — shared trailing idle pipeline)', () => {
|
describe('computeHistoryJob', () => {
|
||||||
it('human edit → user idle window, bare page.id job', () => {
|
it('agent edit → delay MUST be 0 and job id is source-keyed', () => {
|
||||||
// Humans and the agent now share ONE idle job per page (jobId = page.id).
|
// INVARIANT (§15 H2 / persistence.extension): the agent delay MUST stay 0.
|
||||||
// The agent's old delay=0 fast path is GONE — intentional agent points now
|
// The worker re-reads the page row at run time, so any non-zero delay risks
|
||||||
// arrive via the explicit save-version signal, not a zero-delay snapshot.
|
// snapshotting content a later human edit has already overwritten. This is
|
||||||
const { jobId, delay } = computeHistoryJob(page, 'user');
|
// the load-bearing assertion of this spec — do not relax it.
|
||||||
expect(delay).toBe(IDLE_INTERVAL_USER);
|
const { jobId, delay } = computeHistoryJob(pageAged(0), 'agent', NOW);
|
||||||
|
expect(delay).toBe(0);
|
||||||
|
expect(jobId).toBe(`${PAGE_ID}-agent`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('agent edit on an OLD page is still delay 0 (age never applies to agents)', () => {
|
||||||
|
// Even when the page is far older than the fast threshold, the agent path
|
||||||
|
// must short-circuit to 0 — age-based debounce is a human-only concern.
|
||||||
|
const { jobId, delay } = computeHistoryJob(
|
||||||
|
pageAged(HISTORY_FAST_THRESHOLD + 60_000),
|
||||||
|
'agent',
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect(delay).toBe(0);
|
||||||
|
expect(jobId).toBe(`${PAGE_ID}-agent`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('human edit on a YOUNG page (age < threshold) → fast interval, bare job id', () => {
|
||||||
|
const { jobId, delay } = computeHistoryJob(
|
||||||
|
pageAged(HISTORY_FAST_THRESHOLD - 1),
|
||||||
|
'user',
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect(delay).toBe(HISTORY_FAST_INTERVAL);
|
||||||
expect(jobId).toBe(PAGE_ID);
|
expect(jobId).toBe(PAGE_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('agent edit → agent idle window (shorter), still the bare page.id job', () => {
|
it('human edit on an OLD page (age > threshold) → standard interval', () => {
|
||||||
const { jobId, delay } = computeHistoryJob(page, 'agent');
|
const { jobId, delay } = computeHistoryJob(
|
||||||
expect(delay).toBe(IDLE_INTERVAL_AGENT);
|
pageAged(HISTORY_FAST_THRESHOLD + 1),
|
||||||
// No `-agent` suffix anymore: the agent joins the common idle pipeline.
|
'user',
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect(delay).toBe(HISTORY_INTERVAL);
|
||||||
expect(jobId).toBe(PAGE_ID);
|
expect(jobId).toBe(PAGE_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('agent flushes sooner than a human', () => {
|
it('boundary: pageAge EXACTLY === threshold takes the slow branch (the `<` is strict)', () => {
|
||||||
expect(IDLE_INTERVAL_AGENT).toBeLessThan(IDLE_INTERVAL_USER);
|
// Off-by-one guard: the condition is `pageAge < HISTORY_FAST_THRESHOLD`, so
|
||||||
|
// an age of exactly the threshold is NOT "fast" — it must use HISTORY_INTERVAL.
|
||||||
|
const { delay } = computeHistoryJob(
|
||||||
|
pageAged(HISTORY_FAST_THRESHOLD),
|
||||||
|
'user',
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect(delay).toBe(HISTORY_INTERVAL);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats any non-"agent" source string as human (keys strictly on === agent)', () => {
|
it('treats any non-"agent" source string as human', () => {
|
||||||
const { jobId, delay } = computeHistoryJob(page, 'user');
|
// resolveSource only ever yields 'agent' | 'user', but guard the contract:
|
||||||
expect(delay).toBe(IDLE_INTERVAL_USER);
|
// the agent branch keys strictly on === 'agent'.
|
||||||
|
const { jobId, delay } = computeHistoryJob(pageAged(0), 'user', NOW);
|
||||||
|
expect(delay).toBe(HISTORY_FAST_INTERVAL);
|
||||||
expect(jobId).toBe(PAGE_ID);
|
expect(jobId).toBe(PAGE_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
// #370 review round-1 WARNING: the max-wait ceiling prevents autosnapshot
|
|
||||||
// starvation during a continuous editing session (the trailing timer would
|
|
||||||
// otherwise re-arm forever and never fire).
|
|
||||||
describe('max-wait ceiling', () => {
|
|
||||||
const T0 = 1_000_000; // arbitrary fixed epoch for deterministic tests
|
|
||||||
|
|
||||||
it('once a burst is armed, delay clamps to the remaining max-wait budget', () => {
|
|
||||||
// 1 minute into the burst the USER interval (60m) far exceeds the remaining
|
|
||||||
// max-wait budget (10m - 1m = 9m), so the delay is clamped DOWN to that
|
|
||||||
// remaining budget — the full interval is NOT used once a ceiling applies.
|
|
||||||
const { delay } = computeHistoryJob(page, 'user', T0, T0 + 60_000);
|
|
||||||
expect(delay).toBe(IDLE_MAX_WAIT_USER - 60_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never waits longer than the max-wait budget from the burst start', () => {
|
|
||||||
// A store arriving right at the ceiling → delay 0 (fire promptly).
|
|
||||||
const { delay } = computeHistoryJob(
|
|
||||||
page,
|
|
||||||
'user',
|
|
||||||
T0,
|
|
||||||
T0 + IDLE_MAX_WAIT_USER,
|
|
||||||
);
|
|
||||||
expect(delay).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('past the ceiling never returns a negative delay', () => {
|
|
||||||
const { delay } = computeHistoryJob(
|
|
||||||
page,
|
|
||||||
'user',
|
|
||||||
T0,
|
|
||||||
T0 + IDLE_MAX_WAIT_USER + 5 * 60_000,
|
|
||||||
);
|
|
||||||
expect(delay).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('the agent ceiling is shorter than the user ceiling', () => {
|
|
||||||
expect(IDLE_MAX_WAIT_AGENT).toBeLessThan(IDLE_MAX_WAIT_USER);
|
|
||||||
const { delay } = computeHistoryJob(
|
|
||||||
page,
|
|
||||||
'agent',
|
|
||||||
T0,
|
|
||||||
T0 + IDLE_MAX_WAIT_AGENT,
|
|
||||||
);
|
|
||||||
expect(delay).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('without a burstStart there is no ceiling (backward-compatible)', () => {
|
|
||||||
expect(computeHistoryJob(page, 'user').delay).toBe(IDLE_INTERVAL_USER);
|
|
||||||
expect(computeHistoryJob(page, 'agent').delay).toBe(IDLE_INTERVAL_AGENT);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('resolveSource (truth table)', () => {
|
describe('resolveSource (truth table)', () => {
|
||||||
|
|||||||
@@ -40,12 +40,11 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
|
|||||||
let pageHistoryRepo: {
|
let pageHistoryRepo: {
|
||||||
saveHistory: jest.Mock;
|
saveHistory: jest.Mock;
|
||||||
findPageLastHistory: jest.Mock;
|
findPageLastHistory: jest.Mock;
|
||||||
updateHistoryKind: jest.Mock;
|
|
||||||
};
|
};
|
||||||
let aiQueue: { add: jest.Mock };
|
let aiQueue: { add: jest.Mock };
|
||||||
let historyQueue: { add: jest.Mock; remove: jest.Mock };
|
let historyQueue: { add: jest.Mock };
|
||||||
let notificationQueue: { add: jest.Mock };
|
let notificationQueue: { add: jest.Mock };
|
||||||
let collabHistory: { addContributors: jest.Mock; popContributors: jest.Mock };
|
let collabHistory: { addContributors: jest.Mock };
|
||||||
let transclusionService: {
|
let transclusionService: {
|
||||||
syncPageTransclusions: jest.Mock;
|
syncPageTransclusions: jest.Mock;
|
||||||
syncPageReferences: jest.Mock;
|
syncPageReferences: jest.Mock;
|
||||||
@@ -94,22 +93,13 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
|
|||||||
pageHistoryRepo = {
|
pageHistoryRepo = {
|
||||||
saveHistory: jest.fn().mockImplementation(async () => {
|
saveHistory: jest.fn().mockImplementation(async () => {
|
||||||
callOrder.push('saveHistory');
|
callOrder.push('saveHistory');
|
||||||
return { id: 'history-1' };
|
|
||||||
}),
|
}),
|
||||||
findPageLastHistory: jest.fn().mockResolvedValue(null),
|
findPageLastHistory: jest.fn().mockResolvedValue(null),
|
||||||
updateHistoryKind: jest.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
};
|
||||||
aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||||
historyQueue = {
|
historyQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||||
add: jest.fn().mockResolvedValue(undefined),
|
|
||||||
// #370 — enqueuePageHistory now removes any pending idle job before re-adding.
|
|
||||||
remove: jest.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||||
collabHistory = {
|
collabHistory = { addContributors: jest.fn().mockResolvedValue(undefined) };
|
||||||
addContributors: jest.fn().mockResolvedValue(undefined),
|
|
||||||
popContributors: jest.fn().mockResolvedValue([]),
|
|
||||||
};
|
|
||||||
transclusionService = {
|
transclusionService = {
|
||||||
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
|
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
|
||||||
syncPageReferences: jest.fn().mockResolvedValue(undefined),
|
syncPageReferences: jest.fn().mockResolvedValue(undefined),
|
||||||
@@ -175,50 +165,6 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
|
|||||||
expect(pageRepo.updatePage.mock.calls[0][0].lastUpdatedSource).toBe('user');
|
expect(pageRepo.updatePage.mock.calls[0][0].lastUpdatedSource).toBe('user');
|
||||||
});
|
});
|
||||||
|
|
||||||
// #370 review round-1 SUGGESTION: the boundary was GENERALIZED from a
|
|
||||||
// user→agent special-case to ANY lastUpdatedSource transition. These pin the
|
|
||||||
// generalized behaviour it was rebuilt for.
|
|
||||||
describe('generalized boundary — any source transition', () => {
|
|
||||||
// Same persisted page but with an explicit prior source.
|
|
||||||
const pageWithPriorSource = (prior: string | null) => ({
|
|
||||||
...persistedHumanPage('NEW CONTENT'),
|
|
||||||
lastUpdatedSource: prior,
|
|
||||||
});
|
|
||||||
|
|
||||||
it('agent→user transition fires the boundary (pins the prior agent revision)', async () => {
|
|
||||||
const document = ydocFor(doc('NEW CONTENT'));
|
|
||||||
pageRepo.findById.mockResolvedValue(pageWithPriorSource('agent'));
|
|
||||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await ext.onStoreDocument(buildData(document, 'user') as any);
|
|
||||||
|
|
||||||
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledTimes(1);
|
|
||||||
expect(callOrder).toEqual(['saveHistory', 'updatePage']);
|
|
||||||
expect(pageRepo.updatePage.mock.calls[0][0].lastUpdatedSource).toBe('user');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('git→user transition fires the boundary (git-sync overwrite is a source change)', async () => {
|
|
||||||
const document = ydocFor(doc('NEW CONTENT'));
|
|
||||||
pageRepo.findById.mockResolvedValue(pageWithPriorSource('git'));
|
|
||||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await ext.onStoreDocument(buildData(document, 'user') as any);
|
|
||||||
|
|
||||||
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledTimes(1);
|
|
||||||
expect(callOrder).toEqual(['saveHistory', 'updatePage']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a null prior source (first-ever edit) does NOT fire the boundary', async () => {
|
|
||||||
const document = ydocFor(doc('NEW CONTENT'));
|
|
||||||
pageRepo.findById.mockResolvedValue(pageWithPriorSource(null));
|
|
||||||
|
|
||||||
await ext.onStoreDocument(buildData(document, 'agent') as any);
|
|
||||||
|
|
||||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
|
||||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('idempotency: unchanged content → no updatePage, no history, no queues', async () => {
|
it('idempotency: unchanged content → no updatePage, no history, no queues', async () => {
|
||||||
// The Y.Doc content equals the persisted content deeply → early skip.
|
// The Y.Doc content equals the persisted content deeply → early skip.
|
||||||
// A Y.Doc round-trip normalizes attrs (e.g. paragraph indent), so derive
|
// A Y.Doc round-trip normalizes attrs (e.g. paragraph indent), so derive
|
||||||
@@ -533,231 +479,4 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
|
|||||||
// Contributors keyed by the UUID so they match the PAGE_HISTORY job (page.id).
|
// Contributors keyed by the UUID so they match the PAGE_HISTORY job (page.id).
|
||||||
expect(collabHistory.addContributors.mock.calls[0][0]).toBe(PAGE_ID);
|
expect(collabHistory.addContributors.mock.calls[0][0]).toBe(PAGE_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
// #370 — explicit save-version (Cmd+S / agent save tool) over the stateless
|
|
||||||
// seam. The tier is derived from the SIGNED connection actor, the store path
|
|
||||||
// is reused, and promote-not-dup avoids duplicating heavy content rows.
|
|
||||||
describe('save-version (#370)', () => {
|
|
||||||
const emitSave = (document: any, actor: 'user' | 'agent') =>
|
|
||||||
ext.onStateless({
|
|
||||||
connection: {
|
|
||||||
readOnly: false,
|
|
||||||
context: { user: { id: USER_ID, name: 'Alice' }, actor },
|
|
||||||
} as any,
|
|
||||||
documentName: `page.${PAGE_ID}`,
|
|
||||||
document: document as any,
|
|
||||||
payload: JSON.stringify({ type: 'save-version' }),
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
// findById returns a page whose content already equals the live doc, so the
|
|
||||||
// store path is a no-op and we isolate the versioning decision.
|
|
||||||
const pageMatchingDoc = (document: any) => ({
|
|
||||||
...persistedHumanPage('IGNORED'),
|
|
||||||
content: TiptapTransformer.fromYdoc(document, 'default'),
|
|
||||||
});
|
|
||||||
|
|
||||||
it('human save with no prior snapshot → writes a manual version + broadcasts', async () => {
|
|
||||||
const document = ydocFor(doc('VERSION ME'));
|
|
||||||
pageRepo.findById.mockResolvedValue(pageMatchingDoc(document));
|
|
||||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await emitSave(document, 'user');
|
|
||||||
|
|
||||||
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledTimes(1);
|
|
||||||
expect(pageHistoryRepo.saveHistory.mock.calls[0][1]).toEqual(
|
|
||||||
expect.objectContaining({ kind: 'manual' }),
|
|
||||||
);
|
|
||||||
// The pending idle autosnapshot is cancelled by the explicit version.
|
|
||||||
expect(historyQueue.remove).toHaveBeenCalledWith(PAGE_ID);
|
|
||||||
const msg = JSON.parse(
|
|
||||||
(document as any).broadcastStateless.mock.calls[(document as any).broadcastStateless.mock.calls.length - 1][0],
|
|
||||||
);
|
|
||||||
expect(msg).toMatchObject({
|
|
||||||
type: 'version.saved',
|
|
||||||
kind: 'manual',
|
|
||||||
alreadySaved: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('agent save derives kind=agent from the signed actor', async () => {
|
|
||||||
const document = ydocFor(doc('AGENT VERSION'));
|
|
||||||
pageRepo.findById.mockResolvedValue(pageMatchingDoc(document));
|
|
||||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await emitSave(document, 'agent');
|
|
||||||
|
|
||||||
expect(pageHistoryRepo.saveHistory.mock.calls[pageHistoryRepo.saveHistory.mock.calls.length - 1][1]).toEqual(
|
|
||||||
expect.objectContaining({ kind: 'agent' }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('promote-not-dup: latest snapshot is an autosave with identical content → upgrades in place', async () => {
|
|
||||||
const document = ydocFor(doc('SAME'));
|
|
||||||
const page = pageMatchingDoc(document);
|
|
||||||
pageRepo.findById.mockResolvedValue(page);
|
|
||||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
|
|
||||||
id: 'auto-1',
|
|
||||||
content: page.content,
|
|
||||||
kind: 'idle',
|
|
||||||
});
|
|
||||||
|
|
||||||
await emitSave(document, 'user');
|
|
||||||
|
|
||||||
// No heavy new content row — the existing autosave is promoted to manual.
|
|
||||||
expect(pageHistoryRepo.updateHistoryKind).toHaveBeenCalledWith(
|
|
||||||
'auto-1',
|
|
||||||
'manual',
|
|
||||||
expect.anything(),
|
|
||||||
);
|
|
||||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
|
||||||
const msg = JSON.parse(
|
|
||||||
(document as any).broadcastStateless.mock.calls[(document as any).broadcastStateless.mock.calls.length - 1][0],
|
|
||||||
);
|
|
||||||
expect(msg).toMatchObject({ historyId: 'auto-1', alreadySaved: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('no-op when the latest snapshot is already a manual version of this content', async () => {
|
|
||||||
const document = ydocFor(doc('ALREADY SAVED'));
|
|
||||||
const page = pageMatchingDoc(document);
|
|
||||||
pageRepo.findById.mockResolvedValue(page);
|
|
||||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
|
|
||||||
id: 'ver-1',
|
|
||||||
content: page.content,
|
|
||||||
kind: 'manual',
|
|
||||||
});
|
|
||||||
|
|
||||||
await emitSave(document, 'user');
|
|
||||||
|
|
||||||
expect(pageHistoryRepo.updateHistoryKind).not.toHaveBeenCalled();
|
|
||||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
|
||||||
const msg = JSON.parse(
|
|
||||||
(document as any).broadcastStateless.mock.calls[(document as any).broadcastStateless.mock.calls.length - 1][0],
|
|
||||||
);
|
|
||||||
expect(msg).toMatchObject({ alreadySaved: true, kind: 'manual' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a read-only connection cannot save a version', async () => {
|
|
||||||
const document = ydocFor(doc('READER'));
|
|
||||||
pageRepo.findById.mockResolvedValue(pageMatchingDoc(document));
|
|
||||||
|
|
||||||
await ext.onStateless({
|
|
||||||
connection: {
|
|
||||||
readOnly: true,
|
|
||||||
context: { user: { id: USER_ID }, actor: 'user' },
|
|
||||||
} as any,
|
|
||||||
documentName: `page.${PAGE_ID}`,
|
|
||||||
document: document as any,
|
|
||||||
payload: JSON.stringify({ type: 'save-version' }),
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
|
||||||
expect(pageHistoryRepo.updateHistoryKind).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
// #370 F8-twin — a COMMIT abort (serialization/deadlock/conn-drop) rejects
|
|
||||||
// OUTSIDE the tx callback, AFTER the destructive popContributors (SPOP) and
|
|
||||||
// saveHistory ran but the INSERT rolled back. onStateless has no retry, so
|
|
||||||
// the outer catch MUST re-add (SADD) the popped set or attribution is lost
|
|
||||||
// irrecoverably. MUTATION: drop the outer catch → addContributors is never
|
|
||||||
// called → this reddens.
|
|
||||||
it('restores popped contributors when the commit aborts after the callback', async () => {
|
|
||||||
const document = ydocFor(doc('VERSION ME'));
|
|
||||||
pageRepo.findById.mockResolvedValue(pageMatchingDoc(document));
|
|
||||||
// No matching snapshot → fresh version branch → pops contributors.
|
|
||||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
|
|
||||||
collabHistory.popContributors.mockResolvedValue(['u1', 'u2']);
|
|
||||||
|
|
||||||
// A db whose commit REJECTS after the callback body resolved: the SPOP and
|
|
||||||
// saveHistory already ran, then the tx aborts. onStoreDocument's flush uses
|
|
||||||
// the same db but its content matches (no-op branch) and its own retry loop
|
|
||||||
// swallows the throw, so only the versioning tx exercises the restore.
|
|
||||||
const commitFailingDb = {
|
|
||||||
transaction: () => ({
|
|
||||||
execute: async (fn: (trx: any) => Promise<any>) => {
|
|
||||||
await fn(trxStub);
|
|
||||||
throw new Error('commit aborted (serialization_failure)');
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const ext2 = new PersistenceExtension(
|
|
||||||
pageRepo as any,
|
|
||||||
pageHistoryRepo as any,
|
|
||||||
commitFailingDb as any,
|
|
||||||
aiQueue as any,
|
|
||||||
historyQueue as any,
|
|
||||||
notificationQueue as any,
|
|
||||||
collabHistory as any,
|
|
||||||
transclusionService as any,
|
|
||||||
);
|
|
||||||
jest.spyOn(ext2['logger'], 'debug').mockImplementation(() => undefined);
|
|
||||||
jest.spyOn(ext2['logger'], 'warn').mockImplementation(() => undefined);
|
|
||||||
jest.spyOn(ext2['logger'], 'error').mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
ext2.onStateless({
|
|
||||||
connection: {
|
|
||||||
readOnly: false,
|
|
||||||
context: { user: { id: USER_ID, name: 'Alice' }, actor: 'user' },
|
|
||||||
} as any,
|
|
||||||
documentName: `page.${PAGE_ID}`,
|
|
||||||
document: document as any,
|
|
||||||
payload: JSON.stringify({ type: 'save-version' }),
|
|
||||||
} as any),
|
|
||||||
).rejects.toThrow();
|
|
||||||
|
|
||||||
// Attribution preserved: the popped set is SADD-restored, keyed by the page
|
|
||||||
// UUID it was popped under.
|
|
||||||
expect(collabHistory.addContributors).toHaveBeenCalledWith(PAGE_ID, [
|
|
||||||
'u1',
|
|
||||||
'u2',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// #370 #260 — for a `page.<slugId>` document the idle job is armed under the
|
|
||||||
// page UUID (computeHistoryJob's jobId = page.id), so the supersede-remove
|
|
||||||
// must target page.id, not the raw slugId doc-name id, or it silently misses.
|
|
||||||
it('cancels the superseded idle job by the page UUID for a slugId doc', async () => {
|
|
||||||
const SLUG = 'slug-1'; // persistedHumanPage.slugId
|
|
||||||
const document = ydocFor(doc('VERSION ME'));
|
|
||||||
pageRepo.findById.mockResolvedValue(pageMatchingDoc(document));
|
|
||||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await ext.onStateless({
|
|
||||||
connection: {
|
|
||||||
readOnly: false,
|
|
||||||
context: { user: { id: USER_ID, name: 'Alice' }, actor: 'user' },
|
|
||||||
} as any,
|
|
||||||
documentName: `page.${SLUG}`,
|
|
||||||
document: document as any,
|
|
||||||
payload: JSON.stringify({ type: 'save-version' }),
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
// remove() keyed by the UUID (the real jobId), never the slugId.
|
|
||||||
expect(historyQueue.remove).toHaveBeenCalledWith(PAGE_ID);
|
|
||||||
expect(historyQueue.remove).not.toHaveBeenCalledWith(SLUG);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// #370 — the in-memory idle-burst marker must be dropped on doc unload (like
|
|
||||||
// its sibling per-document maps) or it grows unbounded for every page that was
|
|
||||||
// edited but never manually saved. MUTATION: drop the afterUnloadDocument
|
|
||||||
// delete → the entry survives → this reddens.
|
|
||||||
describe('idleBurstStart housekeeping', () => {
|
|
||||||
it('afterUnloadDocument clears the idle-burst marker armed by a store', async () => {
|
|
||||||
const document = ydocFor(doc('EDIT'));
|
|
||||||
pageRepo.findById.mockResolvedValue(persistedHumanPage('EDIT'));
|
|
||||||
|
|
||||||
await ext.onStoreDocument(buildData(document, 'user') as any);
|
|
||||||
|
|
||||||
const map = ext['idleBurstStart'] as Map<string, number>;
|
|
||||||
// Keyed by documentName (buildData uses `page.${PAGE_ID}`).
|
|
||||||
expect(map.has(`page.${PAGE_ID}`)).toBe(true);
|
|
||||||
|
|
||||||
await ext.afterUnloadDocument({
|
|
||||||
documentName: `page.${PAGE_ID}`,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
expect(map.has(`page.${PAGE_ID}`)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,11 +37,9 @@ import { Page } from '@docmost/db/types/entity.types';
|
|||||||
import { CollabHistoryService } from '../services/collab-history.service';
|
import { CollabHistoryService } from '../services/collab-history.service';
|
||||||
import {
|
import {
|
||||||
EMBED_DEBOUNCE_MS,
|
EMBED_DEBOUNCE_MS,
|
||||||
IDLE_INTERVAL_AGENT,
|
HISTORY_FAST_INTERVAL,
|
||||||
IDLE_INTERVAL_USER,
|
HISTORY_FAST_THRESHOLD,
|
||||||
IDLE_MAX_WAIT_AGENT,
|
HISTORY_INTERVAL,
|
||||||
IDLE_MAX_WAIT_USER,
|
|
||||||
PageHistoryKind,
|
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
|
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
|
||||||
import {
|
import {
|
||||||
@@ -58,16 +56,6 @@ import { hasTransclusionFamilyNodes } from '../../core/page/transclusion/utils/t
|
|||||||
*/
|
*/
|
||||||
export const INTENTIONAL_CLEAR_MESSAGE_TYPE = 'intentional-clear';
|
export const INTENTIONAL_CLEAR_MESSAGE_TYPE = 'intentional-clear';
|
||||||
|
|
||||||
/**
|
|
||||||
* #370 — wire format of the client→server "save a version" signal. Sent by the
|
|
||||||
* human (Cmd+S / Save button) and by the agent's explicit save tool over the
|
|
||||||
* SAME stateless channel. The intentionality tier ('manual' vs 'agent') is
|
|
||||||
* derived SERVER-SIDE from the signed connection actor, never from this
|
|
||||||
* payload, so a version's type is unforgeable. The document is taken from the
|
|
||||||
* connection (not the payload), so the signal cannot be aimed at another page.
|
|
||||||
*/
|
|
||||||
export const SAVE_VERSION_MESSAGE_TYPE = 'save-version';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* #251 — how long an intentional-clear signal stays "pending" before it is
|
* #251 — how long an intentional-clear signal stays "pending" before it is
|
||||||
* ignored. The signal is set on the clearing keystroke but consumed by the
|
* ignored. The signal is set on the clearing keystroke but consumed by the
|
||||||
@@ -104,39 +92,35 @@ export function resolveSource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* #370 — compute the BullMQ job id + delay for a page's trailing idle-flush
|
* Compute the BullMQ job id + delay for a page-history snapshot job. Pure so
|
||||||
* autosnapshot. Pure so the timing is unit-testable.
|
* the data-loss-sensitive timing arithmetic is unit-testable; `now` is injected
|
||||||
|
* (caller passes `Date.now()`) for determinism.
|
||||||
*
|
*
|
||||||
* Both humans and the agent now share ONE idle pipeline (the agent's old
|
* - Agent edits: delay 0 and a source-keyed job id `${page.id}-agent`. The
|
||||||
* `delay=0` fast path is gone — intentional agent points arrive via the
|
* delay MUST stay 0 — the worker re-reads the page row at run time, so any
|
||||||
* explicit save-version signal instead). The job id is the bare `page.id`, so a
|
* delay risks reading content a later human edit has already overwritten
|
||||||
* page has at most one pending idle job; the caller removes-and-re-adds it on
|
* (mis-tagged snapshot). 0 minimizes that window. The `-agent` suffix keeps
|
||||||
* every store to keep it debounced to the trailing edge of an edit burst. The
|
* the job from coalescing with the bare-page.id human job.
|
||||||
* window differs by source only: the agent flushes sooner than a human.
|
* - Human edits: age-based debounce so rapid human edits coalesce into one
|
||||||
|
* snapshot; job id is the bare `page.id`.
|
||||||
|
*
|
||||||
|
* BullMQ forbids ':' in custom job ids (Redis key separator), so '-' is used;
|
||||||
|
* page.id is a UUID, so `${page.id}-agent` cannot collide with a human job.
|
||||||
*/
|
*/
|
||||||
export function computeHistoryJob(
|
export function computeHistoryJob(
|
||||||
page: Pick<Page, 'id'>,
|
page: Pick<Page, 'id' | 'createdAt'>,
|
||||||
source: string,
|
source: string,
|
||||||
// Epoch ms of the FIRST edit in the current burst (when the pending idle job
|
now: number,
|
||||||
// was first armed). Used to enforce the max-wait ceiling so a continuous
|
|
||||||
// editing session cannot re-arm the trailing timer forever. `now` is injectable
|
|
||||||
// for tests; both default to a live clock / no ceiling when omitted.
|
|
||||||
burstStart?: number,
|
|
||||||
now: number = Date.now(),
|
|
||||||
): { jobId: string; delay: number } {
|
): { jobId: string; delay: number } {
|
||||||
const isAgent = source === 'agent';
|
const isAgent = source === 'agent';
|
||||||
const interval = isAgent ? IDLE_INTERVAL_AGENT : IDLE_INTERVAL_USER;
|
const pageAge = now - new Date(page.createdAt).getTime();
|
||||||
const maxWait = isAgent ? IDLE_MAX_WAIT_AGENT : IDLE_MAX_WAIT_USER;
|
const delay = isAgent
|
||||||
|
? 0
|
||||||
let delay = interval;
|
: pageAge < HISTORY_FAST_THRESHOLD
|
||||||
if (burstStart !== undefined) {
|
? HISTORY_FAST_INTERVAL
|
||||||
// Time already elapsed since the burst's first edit; the snapshot must fire
|
: HISTORY_INTERVAL;
|
||||||
// no later than `maxWait` after that, so shrink the trailing delay to the
|
const jobId = isAgent ? `${page.id}-agent` : page.id;
|
||||||
// remaining budget (never negative, so BullMQ fires it promptly).
|
return { jobId, delay };
|
||||||
const remaining = burstStart + maxWait - now;
|
|
||||||
delay = Math.max(0, Math.min(interval, remaining));
|
|
||||||
}
|
|
||||||
return { jobId: page.id, delay };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -148,28 +132,6 @@ export class PersistenceExtension implements Extension {
|
|||||||
// coalescing window" per document and OR it across all edits in the window,
|
// coalescing window" per document and OR it across all edits in the window,
|
||||||
// so the snapshot is marked 'agent' regardless of who wrote last.
|
// so the snapshot is marked 'agent' regardless of who wrote last.
|
||||||
private agentTouched: Map<string, boolean> = new Map();
|
private agentTouched: Map<string, boolean> = new Map();
|
||||||
// #370 — epoch ms of the FIRST edit in the current idle-flush burst. Keyed by
|
|
||||||
// documentName (like its sibling per-document maps above), NOT by page.id, so
|
|
||||||
// it can be cleaned in afterUnloadDocument alongside `contributors` /
|
|
||||||
// `agentTouched` / `intentionalClear` when the doc unloads — otherwise any page
|
|
||||||
// that was edited but never manually saved (the common case) would keep its
|
|
||||||
// entry forever and the Map would grow unbounded in this long-lived process.
|
|
||||||
// Set when the pending idle job is first armed (empty entry), read to enforce
|
|
||||||
// the max-wait ceiling in computeHistoryJob, and cleared on doc unload or when
|
|
||||||
// a manual save cancels the idle job so the next burst starts a fresh window.
|
|
||||||
//
|
|
||||||
// Single-process assumption (like `contributors` / `agentTouched` above): this
|
|
||||||
// lives only in THIS collab process's memory. A restart, or a page's ownership
|
|
||||||
// moving to another node, loses the burst-start marker. Consequence: a burst
|
|
||||||
// that spans the restart looks like a fresh burst to the surviving process, so
|
|
||||||
// its max-wait ceiling is re-anchored to the first post-restart edit — a single
|
|
||||||
// continuous session straddling a restart can therefore wait up to ~2× the cap
|
|
||||||
// for its idle snapshot (once for the lost pre-restart window, once for the new
|
|
||||||
// one). Bounded and benign (it only DELAYS a safety-net autosnapshot; manual
|
|
||||||
// saves are unaffected and the next quiet period always flushes), but the
|
|
||||||
// assumption and its consequence are recorded here so no one mistakes the
|
|
||||||
// in-memory marker for a durable, cross-process guarantee.
|
|
||||||
private idleBurstStart: Map<string, number> = new Map();
|
|
||||||
// #251 — per-document "intentional clear pending" flags. Keyed by
|
// #251 — per-document "intentional clear pending" flags. Keyed by
|
||||||
// documentName, value = expiry timestamp (ms). Set by onStateless when the
|
// documentName, value = expiry timestamp (ms). Set by onStateless when the
|
||||||
// client reports a deliberate clear; consumed once by the next
|
// client reports a deliberate clear; consumed once by the next
|
||||||
@@ -401,19 +363,20 @@ export class PersistenceExtension implements Extension {
|
|||||||
//this.logger.debug('Contributors error:' + err?.['message']);
|
//this.logger.debug('Contributors error:' + err?.['message']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// #370 — boundary snapshot on ANY source transition. When the store
|
// Approach A — boundary snapshot before the agent's first edit.
|
||||||
// flips the page's provenance (user↔agent↔git), pin the OUTGOING
|
// When this store is the agent's and the page's currently persisted
|
||||||
// state as its own history version BEFORE the incoming source
|
// state was authored by a human, pin that human state as its own
|
||||||
// overwrites it. `page` still holds the OLD content/provenance here,
|
// history version BEFORE the agent overwrites it. `page` still holds
|
||||||
// so saveHistory(page) captures the pre-transition state tagged with
|
// the OLD content/provenance here, so saveHistory(page) captures the
|
||||||
// its own source, kind='boundary'. The incoming content is snapshotted
|
// pre-agent state tagged 'user'. The agent's new content is
|
||||||
// later by the debounced idle job. Skip if the page is effectively
|
// snapshotted later by the debounced PAGE_HISTORY job ('agent'). Skip
|
||||||
// empty or if the latest existing snapshot already equals this state
|
// if the prior state is already agent-authored (boundary already
|
||||||
// (the shared isDeepStrictEqual gate — avoids duplicates). Generalizing
|
// pinned on the user->agent transition), if the page is effectively
|
||||||
// beyond the old user→agent special-case also covers git-sync for free.
|
// empty, or if the latest existing snapshot already equals this human
|
||||||
|
// state (avoid duplicates).
|
||||||
if (
|
if (
|
||||||
page.lastUpdatedSource &&
|
lastUpdatedSource === 'agent' &&
|
||||||
page.lastUpdatedSource !== lastUpdatedSource
|
page.lastUpdatedSource !== 'agent'
|
||||||
) {
|
) {
|
||||||
// pageHistory.pageId is uuid-typed; use page.id (never the doc-name
|
// pageHistory.pageId is uuid-typed; use page.id (never the doc-name
|
||||||
// slugId) so a `page.<slugId>` doc cannot throw 22P02 here (#260).
|
// slugId) so a `page.<slugId>` doc cannot throw 22P02 here (#260).
|
||||||
@@ -421,13 +384,15 @@ export class PersistenceExtension implements Extension {
|
|||||||
page.id,
|
page.id,
|
||||||
{ includeContent: true, trx },
|
{ includeContent: true, trx },
|
||||||
);
|
);
|
||||||
const baselineMissing =
|
const humanBaselineMissing =
|
||||||
!lastHistory ||
|
!lastHistory ||
|
||||||
!isDeepStrictEqual(lastHistory.content, page.content);
|
!isDeepStrictEqual(lastHistory.content, page.content);
|
||||||
if (!isEmptyParagraphDoc(page.content as any) && baselineMissing) {
|
if (
|
||||||
|
!isEmptyParagraphDoc(page.content as any) &&
|
||||||
|
humanBaselineMissing
|
||||||
|
) {
|
||||||
await this.pageHistoryRepo.saveHistory(page, {
|
await this.pageHistoryRepo.saveHistory(page, {
|
||||||
contributorIds: page.contributorIds ?? undefined,
|
contributorIds: page.contributorIds ?? undefined,
|
||||||
kind: 'boundary',
|
|
||||||
trx,
|
trx,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -557,7 +522,7 @@ export class PersistenceExtension implements Extension {
|
|||||||
{ jobId: `embed-${page.id}`, delay: EMBED_DEBOUNCE_MS },
|
{ jobId: `embed-${page.id}`, delay: EMBED_DEBOUNCE_MS },
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.enqueuePageHistory(page, documentName, lastUpdatedSource);
|
await this.enqueuePageHistory(page, lastUpdatedSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
// #402 — report the serialized size for the store histogram's size_bucket.
|
// #402 — report the serialized size for the store histogram's size_bucket.
|
||||||
@@ -589,14 +554,6 @@ export class PersistenceExtension implements Extension {
|
|||||||
return; // unrelated / malformed stateless message
|
return; // unrelated / malformed stateless message
|
||||||
}
|
}
|
||||||
|
|
||||||
// #370 — explicit "save a version" (human Cmd+S / agent save tool). Edit
|
|
||||||
// rights are already enforced by the readOnly reject above (a reader can't
|
|
||||||
// create a version), exactly as intentional-clear requires.
|
|
||||||
if (message?.type === SAVE_VERSION_MESSAGE_TYPE) {
|
|
||||||
await this.handleSaveVersion(data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message?.type !== INTENTIONAL_CLEAR_MESSAGE_TYPE) return;
|
if (message?.type !== INTENTIONAL_CLEAR_MESSAGE_TYPE) return;
|
||||||
|
|
||||||
this.intentionalClear.set(
|
this.intentionalClear.set(
|
||||||
@@ -605,160 +562,6 @@ export class PersistenceExtension implements Extension {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* #370 — persist an intentional version from the live in-memory ydoc.
|
|
||||||
*
|
|
||||||
* One stateless path serves BOTH the human and the agent; the tier is derived
|
|
||||||
* SERVER-SIDE from the signed connection actor ('agent' → 'agent', anything
|
|
||||||
* else → 'manual'), so the version type cannot be spoofed by the client. We
|
|
||||||
* take the fresh ydoc from the collab process memory and run it through the
|
|
||||||
* EXISTING store path first (so pages.content/ydoc reflect the exact content
|
|
||||||
* being versioned — a REST endpoint would race the up-to-10s-stale page row),
|
|
||||||
* then snapshot it into page_history with the intentional kind.
|
|
||||||
*
|
|
||||||
* Promote-not-dup: if the latest history row already holds this exact content
|
|
||||||
* and it is an autosave (idle/boundary/legacy-null), upgrade its kind in place
|
|
||||||
* instead of duplicating a heavy content row; if it is already 'manual', it is
|
|
||||||
* a no-op (the client shows an "already saved" toast). Otherwise a fresh
|
|
||||||
* version row is written, popping the aggregated contributors from Redis.
|
|
||||||
*/
|
|
||||||
private async handleSaveVersion(data: onStatelessPayload): Promise<void> {
|
|
||||||
const { connection, document, documentName } = data;
|
|
||||||
const context = connection?.context;
|
|
||||||
const pageId = getPageId(documentName);
|
|
||||||
// Unforgeable: 'agent' only for a signed agent connection, else 'manual'.
|
|
||||||
const kind: PageHistoryKind =
|
|
||||||
context?.actor === 'agent' ? 'agent' : 'manual';
|
|
||||||
|
|
||||||
// Flush the live ydoc through the normal store path so the page row + ydoc
|
|
||||||
// hold exactly what we are about to version (also fires the idle enqueue we
|
|
||||||
// supersede below, plus any source-transition boundary). onStoreDocument
|
|
||||||
// only needs document/documentName/context.
|
|
||||||
await this.onStoreDocument({
|
|
||||||
document,
|
|
||||||
documentName,
|
|
||||||
context,
|
|
||||||
} as onStoreDocumentPayload);
|
|
||||||
|
|
||||||
let result:
|
|
||||||
| { historyId: string; kind: PageHistoryKind; alreadySaved: boolean }
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
// #370 F8-twin — the contributor set popped from Redis (destructive SPOP)
|
|
||||||
// must be restored if the version row does not durably land. The inner
|
|
||||||
// try/catch below only covers a throw INSIDE the callback; but executeTx
|
|
||||||
// COMMITS after the callback, so a commit-abort (serialization/deadlock/
|
|
||||||
// connection drop — the transient class the epic retries in the processor)
|
|
||||||
// rejects OUTSIDE the callback, after saveHistory already ran and the SPOP
|
|
||||||
// already happened, while the INSERT rolls back. onStateless does NOT retry,
|
|
||||||
// so an unrestored pop is a one-shot irrecoverable attribution loss (the
|
|
||||||
// processor got exactly this fix: poppedForRestore + an outer catch). We
|
|
||||||
// track the popped set here (keyed by the page UUID it was popped by — never
|
|
||||||
// the doc-name id, which may be a slugId, #260) and restore it in the outer
|
|
||||||
// catch. addContributors is an idempotent Redis SADD, so a double-restore is
|
|
||||||
// harmless. versionedPageId is also reused below to remove the superseded
|
|
||||||
// idle job by its real jobId (page.id).
|
|
||||||
let poppedForRestore: string[] = [];
|
|
||||||
let versionedPageId: string | undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await executeTx(this.db, async (trx) => {
|
|
||||||
const page = await this.pageRepo.findById(pageId, {
|
|
||||||
withLock: true,
|
|
||||||
includeContent: true,
|
|
||||||
trx,
|
|
||||||
});
|
|
||||||
if (!page) return;
|
|
||||||
versionedPageId = page.id;
|
|
||||||
// Never version an effectively-empty page (mirrors the processor's
|
|
||||||
// first-history guard); there is nothing intentional to pin.
|
|
||||||
if (isEmptyParagraphDoc(page.content as any)) return;
|
|
||||||
|
|
||||||
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
|
|
||||||
page.id,
|
|
||||||
{ includeContent: true, trx },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
lastHistory &&
|
|
||||||
isDeepStrictEqual(lastHistory.content, page.content)
|
|
||||||
) {
|
|
||||||
// Content is already snapshotted. Promote-not-dup.
|
|
||||||
if (lastHistory.kind === 'manual') {
|
|
||||||
result = {
|
|
||||||
historyId: lastHistory.id,
|
|
||||||
kind: 'manual',
|
|
||||||
alreadySaved: true,
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.pageHistoryRepo.updateHistoryKind(
|
|
||||||
lastHistory.id,
|
|
||||||
kind,
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
result = { historyId: lastHistory.id, kind, alreadySaved: false };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fresh version row. Pop the contributors aggregated since the last
|
|
||||||
// snapshot (SPOP); restore them if the write fails so they aren't lost.
|
|
||||||
const contributorIds = await this.collabHistory.popContributors(
|
|
||||||
page.id,
|
|
||||||
);
|
|
||||||
poppedForRestore = contributorIds;
|
|
||||||
try {
|
|
||||||
const saved = await this.pageHistoryRepo.saveHistory(page, {
|
|
||||||
contributorIds,
|
|
||||||
kind,
|
|
||||||
trx,
|
|
||||||
});
|
|
||||||
result = { historyId: saved.id, kind, alreadySaved: false };
|
|
||||||
} catch (err) {
|
|
||||||
await this.collabHistory.addContributors(page.id, contributorIds);
|
|
||||||
poppedForRestore = [];
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
// A throw here means the tx did NOT commit (callback threw, or the commit
|
|
||||||
// itself failed and rolled back). If we popped contributors and the inner
|
|
||||||
// catch did not already restore them, restore now so attribution is not
|
|
||||||
// lost — onStateless has no retry to recover it. Restore by the page UUID
|
|
||||||
// the pop was keyed under (versionedPageId is always set before the pop).
|
|
||||||
if (poppedForRestore.length && versionedPageId) {
|
|
||||||
await this.collabHistory.addContributors(
|
|
||||||
versionedPageId,
|
|
||||||
poppedForRestore,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Housekeeping: this explicit version supersedes the page's pending idle
|
|
||||||
// autosnapshot, so cancel it and end the current idle burst so the next edit
|
|
||||||
// starts a fresh max-wait window. Remove the idle job by its REAL jobId
|
|
||||||
// (page.id UUID — computeHistoryJob arms it under page.id), not the raw
|
|
||||||
// doc-name id which may be a slugId for a `page.<slugId>` doc (#260), or the
|
|
||||||
// remove silently misses. The burst marker is keyed by documentName (like its
|
|
||||||
// sibling per-document maps), and is also cleaned in afterUnloadDocument.
|
|
||||||
if (versionedPageId) {
|
|
||||||
await this.historyQueue.remove(versionedPageId).catch(() => undefined);
|
|
||||||
}
|
|
||||||
this.idleBurstStart.delete(documentName);
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
document.broadcastStateless(
|
|
||||||
JSON.stringify({
|
|
||||||
type: 'version.saved',
|
|
||||||
historyId: result.historyId,
|
|
||||||
kind: result.kind,
|
|
||||||
alreadySaved: result.alreadySaved,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async onChange(data: onChangePayload) {
|
async onChange(data: onChangePayload) {
|
||||||
const documentName = data.documentName;
|
const documentName = data.documentName;
|
||||||
const userId = data.context?.user?.id;
|
const userId = data.context?.user?.id;
|
||||||
@@ -783,10 +586,6 @@ export class PersistenceExtension implements Extension {
|
|||||||
this.contributors.delete(documentName);
|
this.contributors.delete(documentName);
|
||||||
this.agentTouched.delete(documentName);
|
this.agentTouched.delete(documentName);
|
||||||
this.intentionalClear.delete(documentName);
|
this.intentionalClear.delete(documentName);
|
||||||
// #370 — drop the idle-burst marker with the other per-document maps so it
|
|
||||||
// cannot accumulate across the process lifetime for never-manually-saved
|
|
||||||
// pages. The pending idle job (if any) is a self-expiring BullMQ delayed job.
|
|
||||||
this.idleBurstStart.delete(documentName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private consumeContributors(documentName: string): string[] {
|
private consumeContributors(documentName: string): string[] {
|
||||||
@@ -818,80 +617,19 @@ export class PersistenceExtension implements Extension {
|
|||||||
|
|
||||||
private async enqueuePageHistory(
|
private async enqueuePageHistory(
|
||||||
page: Page,
|
page: Page,
|
||||||
documentName: string,
|
|
||||||
lastUpdatedSource: string,
|
lastUpdatedSource: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// #370 — trailing idle debounce with a max-wait ceiling. One pending idle
|
// Job id + delay arithmetic lives in the pure `computeHistoryJob` (see its
|
||||||
// job per page (jobId = page.id); on every store we remove the pending
|
// doc comment for the agent-delay-0 / age-based-debounce invariants).
|
||||||
// delayed job and re-add it, so the snapshot lands `delay` after edits go
|
|
||||||
// quiet rather than once per store (precedent: workspace.service.ts).
|
|
||||||
// remove() on a delayed job simply deletes it (0 if absent, no throw); if the
|
|
||||||
// job is already ACTIVE and the remove is a no-op, the add still de-dups and
|
|
||||||
// the processor's isDeepStrictEqual gate collapses the duplicate content.
|
|
||||||
//
|
|
||||||
// The FIRST arm of a burst records `burstStart`; computeHistoryJob shrinks
|
|
||||||
// the delay to the remaining max-wait budget from that point, so a continuous
|
|
||||||
// session cannot re-arm the trailing timer forever and starve the snapshot.
|
|
||||||
// A burst marker older than THIS TIER's max-wait means the previous idle job
|
|
||||||
// has already fired — start a fresh window instead of firing immediately on
|
|
||||||
// the next edit. Must use the SAME source-specific max-wait computeHistoryJob
|
|
||||||
// uses (agent 5m / user 10m): a hardcoded USER ceiling would leave an agent
|
|
||||||
// burst's marker stale for 5..10m, forcing delay=0 on every store in that
|
|
||||||
// window and writing one idle row per store — exactly the per-store bloat the
|
|
||||||
// debounce exists to prevent, on the continuous-agent path.
|
|
||||||
const maxWait =
|
|
||||||
lastUpdatedSource === 'agent' ? IDLE_MAX_WAIT_AGENT : IDLE_MAX_WAIT_USER;
|
|
||||||
const now = Date.now();
|
|
||||||
// Keyed by documentName (see the map declaration) so afterUnloadDocument can
|
|
||||||
// clean it; the queue jobId stays page.id (computeHistoryJob) as required.
|
|
||||||
let burstStart = this.idleBurstStart.get(documentName);
|
|
||||||
if (burstStart === undefined || now - burstStart >= maxWait) {
|
|
||||||
burstStart = now;
|
|
||||||
this.idleBurstStart.set(documentName, burstStart);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { jobId, delay } = computeHistoryJob(
|
const { jobId, delay } = computeHistoryJob(
|
||||||
page,
|
page,
|
||||||
lastUpdatedSource,
|
lastUpdatedSource,
|
||||||
burstStart,
|
Date.now(),
|
||||||
now,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// remove-then-add trailing-debounce idiom, and its ONE race. We delete the
|
|
||||||
// pending delayed job and re-add it under the same jobId so the timer resets
|
|
||||||
// to the trailing edge of the burst. The race is the small window between
|
|
||||||
// these two awaits: if the delayed job's `delay` elapses in that gap it goes
|
|
||||||
// ACTIVE, and then:
|
|
||||||
// - remove() on an active/locked job is a no-op (BullMQ won't yank a job a
|
|
||||||
// worker holds), and our `.catch(() => undefined)` swallows that too; and
|
|
||||||
// - add() with a jobId that already exists (the now-active job's id) is
|
|
||||||
// DROPPED by BullMQ — a duplicate add is a no-op.
|
|
||||||
// So this store fails to re-arm the trailing job: the just-fired snapshot
|
|
||||||
// captured content up to the moment it went active, and THIS edit is left
|
|
||||||
// without a pending trailing job. It is bounded and self-healing — the NEXT
|
|
||||||
// store re-arms a fresh delayed job (the id is free again once the active job
|
|
||||||
// completes / removeOnComplete frees it), and the processor's
|
|
||||||
// isDeepStrictEqual gate collapses any content-identical duplicate. The only
|
|
||||||
// uncovered case is when the racing store was the LAST in the session: the
|
|
||||||
// tail edits made after the job went active get NO trailing snapshot until
|
|
||||||
// the next edit re-arms one. That is an acceptable safety-net gap (a manual
|
|
||||||
// Save, a source-transition boundary, or simply the next edit all still cover
|
|
||||||
// it), which is why the reviewer accepts documenting it here rather than
|
|
||||||
// adding a post-add "did the add actually arm a job?" re-check.
|
|
||||||
//
|
|
||||||
// NOTE — do NOT "unify" this with the neighbouring embed-debounce idiom
|
|
||||||
// (aiQueue.add of PAGE_CONTENT_UPDATED above): that one uses a STABLE jobId
|
|
||||||
// and NO remove(), relying purely on BullMQ coalescing a repeated add under
|
|
||||||
// the same id, because a re-embed only needs to eventually run once on the
|
|
||||||
// latest content and re-anchoring its delay on every keystroke is undesirable.
|
|
||||||
// THIS idiom deliberately removes-then-adds precisely to PUSH the delay back
|
|
||||||
// to the trailing edge on every store (a true debounce), which coalescing
|
|
||||||
// alone cannot do. Collapsing them would silently change the history cadence.
|
|
||||||
await this.historyQueue.remove(jobId).catch(() => undefined);
|
|
||||||
|
|
||||||
await this.historyQueue.add(
|
await this.historyQueue.add(
|
||||||
QueueJob.PAGE_HISTORY,
|
QueueJob.PAGE_HISTORY,
|
||||||
{ pageId: page.id, kind: 'idle' } as IPageHistoryJob,
|
{ pageId: page.id } as IPageHistoryJob,
|
||||||
{ jobId, delay },
|
{ jobId, delay },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,15 +66,6 @@ describe('HistoryProcessor.process', () => {
|
|||||||
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||||
generalQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
generalQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
|
||||||
// #370 F3 — the processor now serializes its find+save under a page-row lock
|
|
||||||
// via executeTx. A db whose transaction().execute(fn) runs fn with a trx stub
|
|
||||||
// drives the real executeTx() helper without a database.
|
|
||||||
const db = {
|
|
||||||
transaction: () => ({
|
|
||||||
execute: (fn: (trx: any) => Promise<any>) => fn({ __trx: true }),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// WorkerHost's constructor reads `this.worker`; passing repos positionally
|
// WorkerHost's constructor reads `this.worker`; passing repos positionally
|
||||||
// matches the constructor and avoids the Nest DI container.
|
// matches the constructor and avoids the Nest DI container.
|
||||||
proc = new HistoryProcessor(
|
proc = new HistoryProcessor(
|
||||||
@@ -82,7 +73,6 @@ describe('HistoryProcessor.process', () => {
|
|||||||
pageRepo as any,
|
pageRepo as any,
|
||||||
collabHistory as any,
|
collabHistory as any,
|
||||||
watcherService as any,
|
watcherService as any,
|
||||||
db as any,
|
|
||||||
notificationQueue as any,
|
notificationQueue as any,
|
||||||
generalQueue as any,
|
generalQueue as any,
|
||||||
);
|
);
|
||||||
@@ -136,26 +126,15 @@ describe('HistoryProcessor.process', () => {
|
|||||||
await proc.process(buildJob());
|
await proc.process(buildJob());
|
||||||
|
|
||||||
expect(collabHistory.popContributors).toHaveBeenCalledWith(PAGE_ID);
|
expect(collabHistory.popContributors).toHaveBeenCalledWith(PAGE_ID);
|
||||||
// #370 F3/F9 — the snapshot decision runs under a page-row lock. Pin the lock
|
|
||||||
// structurally so a refactor that drops withLock/trx (silently reintroducing
|
|
||||||
// the TOCTOU double-insert) turns this red. The tx stub is { __trx: true }.
|
|
||||||
expect(pageRepo.findById).toHaveBeenCalledWith(
|
|
||||||
PAGE_ID,
|
|
||||||
expect.objectContaining({ withLock: true, trx: { __trx: true } }),
|
|
||||||
);
|
|
||||||
// #370 F7 — addPageWatchers MUST receive the trx, or its FK-check runs on a
|
|
||||||
// separate connection and self-deadlocks against our FOR UPDATE. Asserting
|
|
||||||
// the trx arg here is exactly what would have caught that regression.
|
|
||||||
expect(watcherService.addPageWatchers).toHaveBeenCalledWith(
|
expect(watcherService.addPageWatchers).toHaveBeenCalledWith(
|
||||||
['u1', 'u2'],
|
['u1', 'u2'],
|
||||||
PAGE_ID,
|
PAGE_ID,
|
||||||
SPACE_ID,
|
SPACE_ID,
|
||||||
WORKSPACE_ID,
|
WORKSPACE_ID,
|
||||||
{ __trx: true },
|
|
||||||
);
|
);
|
||||||
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledWith(
|
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ id: PAGE_ID }),
|
expect.objectContaining({ id: PAGE_ID }),
|
||||||
{ contributorIds: ['u1', 'u2'], kind: 'idle', trx: { __trx: true } },
|
{ contributorIds: ['u1', 'u2'] },
|
||||||
);
|
);
|
||||||
expect(generalQueue.add).toHaveBeenCalledWith(
|
expect(generalQueue.add).toHaveBeenCalledWith(
|
||||||
QueueJob.PAGE_BACKLINKS,
|
QueueJob.PAGE_BACKLINKS,
|
||||||
@@ -207,48 +186,6 @@ describe('HistoryProcessor.process', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('COMMIT failure (throw outside the tx callback) → contributors RESTORED', async () => {
|
|
||||||
// #370 F8 — a commit-time failure throws OUTSIDE the callback, so the inner
|
|
||||||
// try/catch does not run; the outer catch must restore the popped set (else a
|
|
||||||
// BullMQ retry writes an unattributed version). Use a db whose execute() runs
|
|
||||||
// the callback THEN throws, simulating a commit abort.
|
|
||||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
|
|
||||||
content: { type: 'doc', content: [] },
|
|
||||||
});
|
|
||||||
const commitFail = {
|
|
||||||
transaction: () => ({
|
|
||||||
execute: async (fn: (trx: any) => Promise<any>) => {
|
|
||||||
await fn({ __trx: true }); // callback succeeds (saveHistory ok)
|
|
||||||
throw new Error('commit aborted'); // ...but the COMMIT fails
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const procCommitFail = new HistoryProcessor(
|
|
||||||
pageHistoryRepo as any,
|
|
||||||
pageRepo as any,
|
|
||||||
collabHistory as any,
|
|
||||||
watcherService as any,
|
|
||||||
commitFail as any,
|
|
||||||
notificationQueue as any,
|
|
||||||
generalQueue as any,
|
|
||||||
);
|
|
||||||
jest
|
|
||||||
.spyOn(procCommitFail['logger'], 'error')
|
|
||||||
.mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
await expect(procCommitFail.process(buildJob())).rejects.toThrow(
|
|
||||||
'commit aborted',
|
|
||||||
);
|
|
||||||
// The inner catch did NOT run (save succeeded), so only the outer catch can
|
|
||||||
// restore — assert it did.
|
|
||||||
expect(collabHistory.addContributors).toHaveBeenCalledWith(PAGE_ID, [
|
|
||||||
'u1',
|
|
||||||
'u2',
|
|
||||||
]);
|
|
||||||
// And the post-snapshot queue work must NOT have run (we rethrew).
|
|
||||||
expect(generalQueue.add).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('backlinks + notification queue failures are swallowed (history still committed)', async () => {
|
it('backlinks + notification queue failures are swallowed (history still committed)', async () => {
|
||||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
|
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
|
||||||
content: { type: 'doc', content: [] },
|
content: { type: 'doc', content: [] },
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ import { isDeepStrictEqual } from 'node:util';
|
|||||||
import { CollabHistoryService } from '../services/collab-history.service';
|
import { CollabHistoryService } from '../services/collab-history.service';
|
||||||
import { WatcherService } from '../../core/watcher/watcher.service';
|
import { WatcherService } from '../../core/watcher/watcher.service';
|
||||||
import { isEmptyParagraphDoc } from '../collaboration.util';
|
import { isEmptyParagraphDoc } from '../collaboration.util';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
|
||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
|
||||||
import { executeTx } from '@docmost/db/utils';
|
|
||||||
|
|
||||||
@Processor(QueueName.HISTORY_QUEUE)
|
@Processor(QueueName.HISTORY_QUEUE)
|
||||||
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
||||||
@@ -32,7 +29,6 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
private readonly collabHistory: CollabHistoryService,
|
private readonly collabHistory: CollabHistoryService,
|
||||||
private readonly watcherService: WatcherService,
|
private readonly watcherService: WatcherService,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
|
||||||
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
|
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
|
||||||
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
|
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
|
||||||
) {
|
) {
|
||||||
@@ -45,9 +41,6 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
try {
|
try {
|
||||||
const { pageId } = job.data;
|
const { pageId } = job.data;
|
||||||
|
|
||||||
// Read the page WITHOUT a lock first, only to bail early on the two cheap
|
|
||||||
// no-write cases (page gone / empty first snapshot) without opening a
|
|
||||||
// transaction. The authoritative check-then-write happens locked below.
|
|
||||||
const page = await this.pageRepo.findById(pageId, {
|
const page = await this.pageRepo.findById(pageId, {
|
||||||
includeContent: true,
|
includeContent: true,
|
||||||
});
|
});
|
||||||
@@ -58,109 +51,40 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// #370 F3 — the snapshot decision (findPageLastHistory → saveHistory) must
|
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
|
||||||
// be serialized against manual-save/boundary writers, which run under a
|
pageId,
|
||||||
// page-row lock in onStoreDocument. Without it, this processor and a
|
{ includeContent: true },
|
||||||
// concurrent manual-save each read the same lastHistory (MVCC), both see
|
);
|
||||||
// content != lastHistory, and both insert — producing two page_history rows
|
|
||||||
// with IDENTICAL content (one 'idle', one 'manual'), defeating
|
|
||||||
// promote-not-dup and the version-vs-autosave split. Taking the same
|
|
||||||
// page-row lock makes the second writer observe the first's committed row so
|
|
||||||
// the isDeepStrictEqual gate collapses the duplicate. Only the read+write
|
|
||||||
// is transacted; the post-snapshot queue work stays outside.
|
|
||||||
let contributorIds: string[] = [];
|
|
||||||
let snapshotWritten = false;
|
|
||||||
let lastHistoryContent: unknown;
|
|
||||||
// #370 F8 — the contributor set popped from Redis (destructive SPOP) must be
|
|
||||||
// restored if the snapshot does not durably land. The inner try/catch only
|
|
||||||
// covers a throw INSIDE the callback; a COMMIT failure (connection drop,
|
|
||||||
// serialization/deadlock abort on commit — the transient class the epic
|
|
||||||
// already retries) throws OUTSIDE it, rolling the snapshot back while the
|
|
||||||
// pop is already gone. We track the popped set here and restore it in the
|
|
||||||
// outer catch so a BullMQ retry re-attributes the version. addContributors
|
|
||||||
// is an idempotent Redis SADD, so a double-restore is harmless.
|
|
||||||
let poppedForRestore: string[] = [];
|
|
||||||
|
|
||||||
try {
|
if (!lastHistory && isEmptyParagraphDoc(page.content as any)) {
|
||||||
await executeTx(this.db, async (trx) => {
|
this.logger.debug(
|
||||||
const lockedPage = await this.pageRepo.findById(pageId, {
|
`Skipping first history for page ${pageId}: empty content`,
|
||||||
includeContent: true,
|
);
|
||||||
withLock: true,
|
await this.collabHistory.clearContributors(pageId);
|
||||||
trx,
|
|
||||||
});
|
|
||||||
if (!lockedPage) return;
|
|
||||||
|
|
||||||
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
|
|
||||||
pageId,
|
|
||||||
{ includeContent: true, trx },
|
|
||||||
);
|
|
||||||
lastHistoryContent = lastHistory?.content;
|
|
||||||
|
|
||||||
if (!lastHistory && isEmptyParagraphDoc(lockedPage.content as any)) {
|
|
||||||
this.logger.debug(
|
|
||||||
`Skipping first history for page ${pageId}: empty content`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
lastHistory &&
|
|
||||||
isDeepStrictEqual(lastHistory.content, lockedPage.content)
|
|
||||||
) {
|
|
||||||
return; // already snapshotted at this content — nothing to write
|
|
||||||
}
|
|
||||||
|
|
||||||
contributorIds = await this.collabHistory.popContributors(pageId);
|
|
||||||
poppedForRestore = contributorIds;
|
|
||||||
try {
|
|
||||||
// Pass `trx` so the watcher insert's FK check (FOR KEY SHARE on
|
|
||||||
// pages[pageId]) runs on the SAME connection that already holds the
|
|
||||||
// FOR UPDATE lock from findById — otherwise it takes the FK lock on a
|
|
||||||
// separate pool connection and self-deadlocks against our own tx.
|
|
||||||
await this.watcherService.addPageWatchers(
|
|
||||||
contributorIds,
|
|
||||||
pageId,
|
|
||||||
lockedPage.spaceId,
|
|
||||||
lockedPage.workspaceId,
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
|
|
||||||
// #370 — every job on this queue is a trailing idle-flush autosnapshot.
|
|
||||||
await this.pageHistoryRepo.saveHistory(lockedPage, {
|
|
||||||
contributorIds,
|
|
||||||
kind: job.data.kind ?? 'idle',
|
|
||||||
trx,
|
|
||||||
});
|
|
||||||
snapshotWritten = true;
|
|
||||||
this.logger.debug(`History created for page: ${pageId}`);
|
|
||||||
} catch (err) {
|
|
||||||
await this.collabHistory.addContributors(pageId, contributorIds);
|
|
||||||
poppedForRestore = [];
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
// A throw here means the tx did NOT commit (callback threw, or the commit
|
|
||||||
// itself failed and rolled back). If we popped contributors and the inner
|
|
||||||
// catch did not already restore them, restore now so the retry keeps
|
|
||||||
// attribution. snapshotWritten is irrelevant: it is set before commit, so
|
|
||||||
// it can be true even when the commit rolled the snapshot back.
|
|
||||||
if (poppedForRestore.length) {
|
|
||||||
await this.collabHistory.addContributors(pageId, poppedForRestore);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No snapshot written (page vanished / empty-first / unchanged content) →
|
|
||||||
// clear the contributor set for the skip cases and stop.
|
|
||||||
if (!snapshotWritten) {
|
|
||||||
if (!lastHistoryContent && isEmptyParagraphDoc(page.content as any)) {
|
|
||||||
await this.collabHistory.clearContributors(pageId);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
if (
|
||||||
|
!lastHistory ||
|
||||||
|
!isDeepStrictEqual(lastHistory.content, page.content)
|
||||||
|
) {
|
||||||
|
const contributorIds = await this.collabHistory.popContributors(pageId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.watcherService.addPageWatchers(
|
||||||
|
contributorIds,
|
||||||
|
pageId,
|
||||||
|
page.spaceId,
|
||||||
|
page.workspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.pageHistoryRepo.saveHistory(page, { contributorIds });
|
||||||
|
this.logger.debug(`History created for page: ${pageId}`);
|
||||||
|
} catch (err) {
|
||||||
|
await this.collabHistory.addContributors(pageId, contributorIds);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
const mentions = extractMentions(page.content);
|
const mentions = extractMentions(page.content);
|
||||||
const pageMentions = extractPageMentions(mentions);
|
const pageMentions = extractPageMentions(mentions);
|
||||||
const internalLinkSlugIds = extractInternalLinkSlugIds(page.content);
|
const internalLinkSlugIds = extractInternalLinkSlugIds(page.content);
|
||||||
@@ -178,7 +102,7 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (contributorIds.length > 0 && lastHistoryContent) {
|
if (contributorIds.length > 0 && lastHistory?.content) {
|
||||||
await this.notificationQueue
|
await this.notificationQueue
|
||||||
.add(QueueJob.PAGE_UPDATED, {
|
.add(QueueJob.PAGE_UPDATED, {
|
||||||
pageId,
|
pageId,
|
||||||
|
|||||||
@@ -529,107 +529,4 @@ describe('replaceYjsMarkedText', () => {
|
|||||||
expect(result).toEqual({ applied: false, currentText: 'abcdef' });
|
expect(result).toEqual({ applied: false, currentText: 'abcdef' });
|
||||||
expect(text.toDelta()).toEqual(before);
|
expect(text.toDelta()).toEqual(before);
|
||||||
});
|
});
|
||||||
|
|
||||||
// #496: apply must NOT silently strip the replaced run's inline formatting.
|
|
||||||
// Build a paragraph and format the marked range with extra marks, then assert
|
|
||||||
// the replacement carries them.
|
|
||||||
function buildFormatted(
|
|
||||||
runs: Array<{ text: string; attrs?: Record<string, any> }>,
|
|
||||||
): { fragment: Y.XmlFragment; text: Y.XmlText } {
|
|
||||||
const ydoc = new Y.Doc();
|
|
||||||
const fragment = ydoc.getXmlFragment('default');
|
|
||||||
const para = new Y.XmlElement('paragraph');
|
|
||||||
fragment.insert(0, [para]);
|
|
||||||
const text = new Y.XmlText();
|
|
||||||
para.insert(0, [text]);
|
|
||||||
text.insert(0, runs.map((r) => r.text).join(''));
|
|
||||||
let offset = 0;
|
|
||||||
for (const run of runs) {
|
|
||||||
if (run.attrs) text.format(offset, run.text.length, run.attrs);
|
|
||||||
offset += run.text.length;
|
|
||||||
}
|
|
||||||
return { fragment, text };
|
|
||||||
}
|
|
||||||
|
|
||||||
it('preserves the original run formatting (bold + link) on the replacement', () => {
|
|
||||||
const { fragment, text } = buildFormatted([
|
|
||||||
{ text: 'see ' },
|
|
||||||
{
|
|
||||||
text: 'old',
|
|
||||||
attrs: {
|
|
||||||
comment: { commentId: 'c1', resolved: false },
|
|
||||||
bold: true,
|
|
||||||
link: { href: 'https://x.test' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ text: ' end' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = replaceYjsMarkedText(fragment, 'c1', 'old', 'new');
|
|
||||||
|
|
||||||
expect(result).toEqual({ applied: true, currentText: 'new' });
|
|
||||||
// The comment anchor AND the bold/link marks survive the delete+insert.
|
|
||||||
expect(text.toDelta()).toEqual([
|
|
||||||
{ insert: 'see ' },
|
|
||||||
{
|
|
||||||
insert: 'new',
|
|
||||||
attributes: {
|
|
||||||
comment: { commentId: 'c1', resolved: false },
|
|
||||||
bold: true,
|
|
||||||
link: { href: 'https://x.test' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ insert: ' end' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('mixed formatting under the mark: replacement takes the DOMINANT (longest) run, NOT the leading one', () => {
|
|
||||||
// Leading run is SHORT + plain ("x", 1 char); the following run is LONGER +
|
|
||||||
// bold ("bolded", 6 chars), same commentId. The longest run is deliberately
|
|
||||||
// NOT first: a "first-wins" pick would carry plain (no bold), so asserting
|
|
||||||
// bold on the result only holds if the code genuinely selects the LONGEST run.
|
|
||||||
const { fragment, text } = buildFormatted([
|
|
||||||
{ text: 'x', attrs: { comment: { commentId: 'c1', resolved: false } } },
|
|
||||||
{
|
|
||||||
text: 'bolded',
|
|
||||||
attrs: { comment: { commentId: 'c1', resolved: false }, bold: true },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = replaceYjsMarkedText(fragment, 'c1', 'xbolded', 'Z');
|
|
||||||
|
|
||||||
expect(result).toEqual({ applied: true, currentText: 'Z' });
|
|
||||||
expect(text.toDelta()).toEqual([
|
|
||||||
{
|
|
||||||
insert: 'Z',
|
|
||||||
attributes: { comment: { commentId: 'c1', resolved: false }, bold: true },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('mixed formatting under the mark: on a length tie the FIRST run wins', () => {
|
|
||||||
// Two equal-length runs (2 chars each) with different formatting, same
|
|
||||||
// commentId. The reduce keeps the accumulator on a tie, so the FIRST run
|
|
||||||
// (italic) prevails over the later bold one.
|
|
||||||
const { fragment, text } = buildFormatted([
|
|
||||||
{
|
|
||||||
text: 'AA',
|
|
||||||
attrs: { comment: { commentId: 'c1', resolved: false }, italic: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'BB',
|
|
||||||
attrs: { comment: { commentId: 'c1', resolved: false }, bold: true },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = replaceYjsMarkedText(fragment, 'c1', 'AABB', 'Z');
|
|
||||||
|
|
||||||
expect(result).toEqual({ applied: true, currentText: 'Z' });
|
|
||||||
expect(text.toDelta()).toEqual([
|
|
||||||
{
|
|
||||||
insert: 'Z',
|
|
||||||
attributes: { comment: { commentId: 'c1', resolved: false }, italic: true },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -145,10 +145,6 @@ type MarkedSegment = {
|
|||||||
length: number;
|
length: number;
|
||||||
text: string;
|
text: string;
|
||||||
markAttrs: Record<string, any>;
|
markAttrs: Record<string, any>;
|
||||||
// The FULL attribute set of this delta run — the `comment` mark plus any
|
|
||||||
// inline formatting (bold/italic/code/link/…). Captured so apply can carry the
|
|
||||||
// original run's formatting onto the replacement instead of dropping it.
|
|
||||||
attributes: Record<string, any>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -206,7 +202,6 @@ export function replaceYjsMarkedText(
|
|||||||
length,
|
length,
|
||||||
text: insert,
|
text: insert,
|
||||||
markAttrs: markAttr,
|
markAttrs: markAttr,
|
||||||
attributes,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
offset += length;
|
offset += length;
|
||||||
@@ -256,25 +251,15 @@ export function replaceYjsMarkedText(
|
|||||||
return { applied: false, currentText: joinedText };
|
return { applied: false, currentText: joinedText };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. All guards passed: delete the marked run and re-insert newText at the
|
// 3. All guards passed: delete the marked run and re-insert newText with the
|
||||||
// same offset. Atomic within the caller's transaction.
|
// same comment attributes at the same offset. Atomic within the caller's
|
||||||
|
// transaction.
|
||||||
const start = segments[0].offset;
|
const start = segments[0].offset;
|
||||||
const len = segments.reduce((sum, s) => sum + s.length, 0);
|
const len = segments.reduce((sum, s) => sum + s.length, 0);
|
||||||
|
const markAttrs = segments[0].markAttrs;
|
||||||
// Carry the ORIGINAL run's formatting onto the replacement (#496): inserting
|
|
||||||
// with only the `comment` mark silently dropped bold/italic/code/link of the
|
|
||||||
// replaced text. Yjs applies one flat attribute set to the whole insert, so
|
|
||||||
// when the marked run mixes formatting we pick the DOMINANT segment (the one
|
|
||||||
// covering the most characters) and apply its attributes — a v1 that preserves
|
|
||||||
// the common single-format case exactly and, for a mixed run, keeps the
|
|
||||||
// prevailing style rather than losing all of it. `attributes` already carries
|
|
||||||
// the `comment` mark (every collected segment is filtered on it above), so the
|
|
||||||
// anchor is preserved by copying the run's attribute set verbatim.
|
|
||||||
const dominant = segments.reduce((a, b) => (b.length > a.length ? b : a));
|
|
||||||
const insertAttrs = { ...dominant.attributes };
|
|
||||||
|
|
||||||
node.delete(start, len);
|
node.delete(start, len);
|
||||||
node.insert(start, newText, insertAttrs);
|
node.insert(start, newText, { comment: markAttrs });
|
||||||
|
|
||||||
return { applied: true, currentText: newText };
|
return { applied: true, currentText: newText };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import { join } from 'path';
|
|
||||||
import * as fs from 'node:fs';
|
|
||||||
import * as os from 'node:os';
|
|
||||||
import { readClientBuildVersion } from './client-version';
|
|
||||||
|
|
||||||
describe('readClientBuildVersion', () => {
|
|
||||||
let dir: string;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
dir = fs.mkdtempSync(join(os.tmpdir(), 'client-version-'));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
const writeVersionJson = (content: string) =>
|
|
||||||
fs.writeFileSync(join(dir, 'version.json'), content);
|
|
||||||
|
|
||||||
it('returns the version from a valid version.json', () => {
|
|
||||||
writeVersionJson(JSON.stringify({ version: 'test-A' }));
|
|
||||||
expect(readClientBuildVersion(dir)).toBe('test-A');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('trims surrounding whitespace in the version', () => {
|
|
||||||
writeVersionJson(JSON.stringify({ version: ' v1.2.3 ' }));
|
|
||||||
expect(readClientBuildVersion(dir)).toBe('v1.2.3');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns "" when version.json is missing', () => {
|
|
||||||
expect(readClientBuildVersion(dir)).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns "" on malformed JSON', () => {
|
|
||||||
writeVersionJson('{ not json');
|
|
||||||
expect(readClientBuildVersion(dir)).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns "" when the version field is absent', () => {
|
|
||||||
writeVersionJson(JSON.stringify({ notVersion: 'x' }));
|
|
||||||
expect(readClientBuildVersion(dir)).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns "" when the version field is not a string', () => {
|
|
||||||
writeVersionJson(JSON.stringify({ version: 123 }));
|
|
||||||
expect(readClientBuildVersion(dir)).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns "" when the path does not exist at all', () => {
|
|
||||||
expect(readClientBuildVersion(join(dir, 'nope'))).toBe('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { join } from 'path';
|
|
||||||
import * as fs from 'node:fs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the absolute path to the built client bundle directory
|
|
||||||
* (`apps/client/dist`) shipped into the runtime image.
|
|
||||||
*
|
|
||||||
* The `../` depth is anchored on THIS module's compiled location
|
|
||||||
* (`dist/common/helpers`). `integrations/static` sits at the same depth under
|
|
||||||
* the compiled root, so both callers (StaticModule and readClientBuildVersion)
|
|
||||||
* MUST share this single helper rather than duplicating the depth — a copy in a
|
|
||||||
* module at a different depth would silently resolve to the wrong directory.
|
|
||||||
*/
|
|
||||||
export function resolveClientDistPath(): string {
|
|
||||||
return join(__dirname, '..', '..', '..', '..', 'client/dist');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the build version the client bundle was compiled with, from
|
|
||||||
* `<clientDistPath>/version.json` (written by the Vite build — the single
|
|
||||||
* source of truth shared by the baked-in `APP_VERSION` global and this file).
|
|
||||||
*
|
|
||||||
* Fail-safe: any error (missing file, unreadable, bad JSON, non-string
|
|
||||||
* version) yields `''`. The caller treats an empty version as "unknown" and
|
|
||||||
* the whole version-coherence feature stays silently inert — existing deploys
|
|
||||||
* without the file keep working unchanged.
|
|
||||||
*/
|
|
||||||
export function readClientBuildVersion(clientDistPath: string): string {
|
|
||||||
try {
|
|
||||||
const raw = fs.readFileSync(join(clientDistPath, 'version.json'), 'utf8');
|
|
||||||
const version = (JSON.parse(raw) as { version?: unknown }).version;
|
|
||||||
return typeof version === 'string' ? version.trim() : '';
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,4 +3,3 @@ export * from './nanoid.utils';
|
|||||||
export * from './file.helper';
|
export * from './file.helper';
|
||||||
export * from './constants';
|
export * from './constants';
|
||||||
export * from './security-headers';
|
export * from './security-headers';
|
||||||
export * from './client-version';
|
|
||||||
|
|||||||
@@ -43,9 +43,6 @@ function makeRepo(overrides: Record<string, jest.Mock> = {}) {
|
|||||||
workspaceId: v.workspaceId,
|
workspaceId: v.workspaceId,
|
||||||
})),
|
})),
|
||||||
update: jest.fn(async () => ({ id: 'run-1' })),
|
update: jest.fn(async () => ({ id: 'run-1' })),
|
||||||
// #487: terminal finalize now goes through the CONDITIONAL write. Default
|
|
||||||
// returns a truthy row (the run WAS active -> this call wrote it).
|
|
||||||
finalizeIfActive: jest.fn(async () => ({ id: 'run-1', status: 'succeeded' })),
|
|
||||||
markStopRequested: jest.fn(async () => ({ id: 'run-1' })),
|
markStopRequested: jest.fn(async () => ({ id: 'run-1' })),
|
||||||
findActiveByChat: jest.fn(async () => undefined),
|
findActiveByChat: jest.fn(async () => undefined),
|
||||||
findLatestByChat: jest.fn(async () => undefined),
|
findLatestByChat: jest.fn(async () => undefined),
|
||||||
@@ -339,12 +336,14 @@ describe('AiChatRunService run lifecycle', () => {
|
|||||||
await svc.finalizeRun('run-1', 'ws-1', 'error', 'provider blew up');
|
await svc.finalizeRun('run-1', 'ws-1', 'error', 'provider blew up');
|
||||||
|
|
||||||
expect(svc.isLocallyActive('run-1')).toBe(false);
|
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||||
// #487: the terminal write is CONDITIONAL (finalizeIfActive); finishedAt is
|
expect(repo.update).toHaveBeenCalledWith(
|
||||||
// stamped inside the repo method, so the service passes just status + error.
|
|
||||||
expect(repo.finalizeIfActive).toHaveBeenCalledWith(
|
|
||||||
'run-1',
|
'run-1',
|
||||||
'ws-1',
|
'ws-1',
|
||||||
expect.objectContaining({ status: 'failed', error: 'provider blew up' }),
|
expect.objectContaining({
|
||||||
|
status: 'failed',
|
||||||
|
error: 'provider blew up',
|
||||||
|
finishedAt: expect.any(Date),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -367,8 +366,8 @@ describe('AiChatRunService run lifecycle', () => {
|
|||||||
// A second settle (e.g. a streamText callback firing after the catch) no-ops.
|
// A second settle (e.g. a streamText callback firing after the catch) no-ops.
|
||||||
await svc.finalizeRun('run-1', 'ws-1', 'completed', undefined);
|
await svc.finalizeRun('run-1', 'ws-1', 'completed', undefined);
|
||||||
|
|
||||||
expect(repo.finalizeIfActive).toHaveBeenCalledTimes(1);
|
expect(repo.update).toHaveBeenCalledTimes(1);
|
||||||
expect(repo.finalizeIfActive).toHaveBeenCalledWith(
|
expect(repo.update).toHaveBeenCalledWith(
|
||||||
'run-1',
|
'run-1',
|
||||||
'ws-1',
|
'ws-1',
|
||||||
expect.objectContaining({ status: 'failed', error: 'first' }),
|
expect.objectContaining({ status: 'failed', error: 'first' }),
|
||||||
@@ -390,8 +389,8 @@ describe('AiChatRunService run lifecycle', () => {
|
|||||||
const updateGate = new Promise((res) => {
|
const updateGate = new Promise((res) => {
|
||||||
resolveUpdate = res;
|
resolveUpdate = res;
|
||||||
});
|
});
|
||||||
const finalizeIfActive = jest.fn(() => updateGate);
|
const update = jest.fn(() => updateGate);
|
||||||
const repo = makeRepo({ finalizeIfActive });
|
const repo = makeRepo({ update });
|
||||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
await svc.beginRun({
|
await svc.beginRun({
|
||||||
chatId: 'chat-1',
|
chatId: 'chat-1',
|
||||||
@@ -400,23 +399,23 @@ describe('AiChatRunService run lifecycle', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Fire both before the (pending) update resolves. The first synchronously
|
// Fire both before the (pending) update resolves. The first synchronously
|
||||||
// claims the entry (active.delete) and awaits the write; the second, started
|
// claims the entry (active.delete) and awaits update; the second, started in
|
||||||
// in the same macrotask, finds the entry already gone and returns at the claim
|
// the same macrotask, finds the entry already gone and returns at the claim
|
||||||
// WITHOUT ever writing.
|
// WITHOUT ever calling update.
|
||||||
const p1 = svc.finalizeRun('run-1', 'ws-1', 'completed');
|
const p1 = svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||||
const p2 = svc.finalizeRun('run-1', 'ws-1', 'error', 'safety-net');
|
const p2 = svc.finalizeRun('run-1', 'ws-1', 'error', 'safety-net');
|
||||||
|
|
||||||
// The decisive assertion: exactly one caller reached the terminal UPDATE.
|
// The decisive assertion: exactly one caller reached the terminal UPDATE.
|
||||||
expect(finalizeIfActive).toHaveBeenCalledTimes(1);
|
expect(update).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
// Let the single in-flight update land; both calls resolve cleanly.
|
// Let the single in-flight update land; both calls resolve cleanly.
|
||||||
resolveUpdate({ id: 'run-1', status: 'succeeded' });
|
resolveUpdate({ id: 'run-1' });
|
||||||
await Promise.all([p1, p2]);
|
await Promise.all([p1, p2]);
|
||||||
|
|
||||||
expect(finalizeIfActive).toHaveBeenCalledTimes(1);
|
expect(update).toHaveBeenCalledTimes(1);
|
||||||
// The winner is the FIRST caller ('completed' -> 'succeeded'); the late
|
// The winner is the FIRST caller ('completed' -> 'succeeded'); the late
|
||||||
// 'error' settle never wrote, so it could not clobber the real status.
|
// 'error' settle never wrote, so it could not clobber the real status.
|
||||||
expect(finalizeIfActive).toHaveBeenCalledWith(
|
expect(update).toHaveBeenCalledWith(
|
||||||
'run-1',
|
'run-1',
|
||||||
'ws-1',
|
'ws-1',
|
||||||
expect.objectContaining({ status: 'succeeded' }),
|
expect.objectContaining({ status: 'succeeded' }),
|
||||||
@@ -432,10 +431,10 @@ describe('AiChatRunService run lifecycle', () => {
|
|||||||
// 409s until a restart. The fix updates FIRST and retries.
|
// 409s until a restart. The fix updates FIRST and retries.
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
const repo = makeRepo({
|
const repo = makeRepo({
|
||||||
finalizeIfActive: jest.fn(async () => {
|
update: jest.fn(async () => {
|
||||||
calls += 1;
|
calls += 1;
|
||||||
if (calls === 1) throw new Error('deadlock detected');
|
if (calls === 1) throw new Error('deadlock detected');
|
||||||
return { id: 'run-1', status: 'succeeded' };
|
return { id: 'run-1' };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
@@ -448,29 +447,26 @@ describe('AiChatRunService run lifecycle', () => {
|
|||||||
|
|
||||||
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||||
|
|
||||||
// The retry landed the terminal write: the entry is dropped (slot freed), no
|
// The retry landed the terminal write: the entry is dropped (slot freed) and
|
||||||
// zombie left, and the row carries the real terminal status.
|
// the row carries the real terminal status — NOT stranded at 'running'.
|
||||||
expect(svc.isLocallyActive('run-1')).toBe(false);
|
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||||
expect(svc.hasZombie('run-1')).toBe(false);
|
expect(repo.update).toHaveBeenCalledTimes(2);
|
||||||
expect(repo.finalizeIfActive).toHaveBeenCalledTimes(2);
|
expect(repo.update).toHaveBeenLastCalledWith(
|
||||||
expect(repo.finalizeIfActive).toHaveBeenLastCalledWith(
|
|
||||||
'run-1',
|
'run-1',
|
||||||
'ws-1',
|
'ws-1',
|
||||||
expect.objectContaining({ status: 'succeeded' }),
|
expect.objectContaining({ status: 'succeeded' }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('#487 give-up: if the terminal write keeps failing, finalizeRun leaves a ZOMBIE (does NOT restore the entry) and settleZombie re-drives it', async () => {
|
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).
|
// Worst case: the DB is down for the whole first finalize (all attempts fail).
|
||||||
// #487 changes the give-up behaviour: the entry is NOT restored (a restored
|
// The run must NOT be silently lost — the entry stays so a subsequent settle
|
||||||
// entry is indistinguishable from a live run). Instead a ZOMBIE record holds
|
// (a streamText callback, requestStop -> onAbort, or a future sweep) can retry.
|
||||||
// the intended terminal status, and a re-drive (settleZombie — called by the
|
|
||||||
// reconcile / supersede / opportunistic paths) applies it later.
|
|
||||||
let healthy = false;
|
let healthy = false;
|
||||||
const repo = makeRepo({
|
const repo = makeRepo({
|
||||||
finalizeIfActive: jest.fn(async () => {
|
update: jest.fn(async () => {
|
||||||
if (!healthy) throw new Error('pool exhausted');
|
if (!healthy) throw new Error('pool exhausted');
|
||||||
return { id: 'run-1', status: 'succeeded' };
|
return { id: 'run-1' };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
@@ -484,83 +480,35 @@ describe('AiChatRunService run lifecycle', () => {
|
|||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
});
|
});
|
||||||
|
|
||||||
// First settle: every bounded attempt fails -> ZOMBIE, entry NOT restored.
|
// First settle: every bounded attempt fails -> entry retained, NOT settled.
|
||||||
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||||
expect(svc.isLocallyActive('run-1')).toBe(false); // NOT a live entry
|
expect(svc.isLocallyActive('run-1')).toBe(true);
|
||||||
expect(svc.hasZombie('run-1')).toBe(true);
|
// F12: the give-up emits ONE explicit, greppable ERROR (run + chat context)
|
||||||
expect(svc.zombieRunIds()).toContain('run-1');
|
// so an operator can tell "gave up, run held in memory" from a per-attempt
|
||||||
// The give-up emits ONE explicit, greppable ERROR mentioning the zombie.
|
// blip — distinct from the per-attempt warns.
|
||||||
const gaveUp = errorSpy.mock.calls.some(
|
const gaveUp = errorSpy.mock.calls.some(
|
||||||
(c) =>
|
(c) =>
|
||||||
/NON-TERMINAL/.test(String(c[0])) &&
|
/NON-TERMINAL/.test(String(c[0])) &&
|
||||||
/ZOMBIE/.test(String(c[0])) &&
|
|
||||||
/run-1/.test(String(c[0])) &&
|
/run-1/.test(String(c[0])) &&
|
||||||
/chat-1/.test(String(c[0])),
|
/chat-1/.test(String(c[0])),
|
||||||
);
|
);
|
||||||
expect(gaveUp).toBe(true);
|
expect(gaveUp).toBe(true);
|
||||||
// The settle notifier resolved as terminalWriteFailed (a subscriber learns the
|
|
||||||
// slot still needs the intended status applied).
|
|
||||||
const outcome = await svc.peekSettled('run-1');
|
|
||||||
expect(outcome).toEqual({
|
|
||||||
status: 'succeeded',
|
|
||||||
error: null,
|
|
||||||
terminalWriteFailed: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// The DB recovers; a re-drive settles the zombie via the conditional UPDATE.
|
// The DB recovers; a later settle now succeeds and frees the slot.
|
||||||
healthy = true;
|
healthy = true;
|
||||||
const redriven = await svc.settleZombie('run-1');
|
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||||
expect(redriven).toBe(true);
|
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||||
expect(svc.hasZombie('run-1')).toBe(false);
|
expect(repo.update).toHaveBeenLastCalledWith(
|
||||||
expect(repo.finalizeIfActive).toHaveBeenLastCalledWith(
|
|
||||||
'run-1',
|
'run-1',
|
||||||
'ws-1',
|
'ws-1',
|
||||||
expect.objectContaining({ status: 'succeeded' }),
|
expect.objectContaining({ status: 'succeeded' }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// A later finalizeRun is idempotent (row already terminal): it no-ops at the
|
// And it is now idempotent: a further settle no-ops (terminal row already
|
||||||
// once-gate, never re-writing.
|
// written), so a double-settle can never clobber the real status.
|
||||||
const callsBefore = repo.finalizeIfActive.mock.calls.length;
|
const callsBefore = repo.update.mock.calls.length;
|
||||||
await svc.finalizeRun('run-1', 'ws-1', 'error', 'late');
|
await svc.finalizeRun('run-1', 'ws-1', 'error', 'late');
|
||||||
expect(repo.finalizeIfActive).toHaveBeenCalledTimes(callsBefore);
|
expect(repo.update).toHaveBeenCalledTimes(callsBefore);
|
||||||
});
|
|
||||||
|
|
||||||
it('#487 double-settle collapses to a benign no-op (conditional write; notifier resolves once)', async () => {
|
|
||||||
// A second concurrent settle is stopped at the synchronous active.delete
|
|
||||||
// claim, so the terminal write runs exactly once and the notifier resolves
|
|
||||||
// exactly once with the FIRST settler's outcome.
|
|
||||||
const repo = makeRepo();
|
|
||||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
|
||||||
await svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'u1' });
|
|
||||||
|
|
||||||
await svc.finalizeRun('run-1', 'ws-1', 'aborted');
|
|
||||||
await svc.finalizeRun('run-1', 'ws-1', 'error', 'late'); // no-op
|
|
||||||
|
|
||||||
expect(repo.finalizeIfActive).toHaveBeenCalledTimes(1);
|
|
||||||
const outcome = await svc.peekSettled('run-1');
|
|
||||||
// peekSettled after resolve+delete falls through (notifier dropped, no zombie)
|
|
||||||
// -> undefined; the FIRST settler already resolved any earlier subscriber.
|
|
||||||
expect(outcome).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#487 late settledPromise subscriber gets the resolved outcome', async () => {
|
|
||||||
const repo = makeRepo();
|
|
||||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
|
||||||
await svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'u1' });
|
|
||||||
|
|
||||||
// Subscribe BEFORE settle: hold the promise reference (as supersede does).
|
|
||||||
const early = svc.peekSettled('run-1');
|
|
||||||
expect(early).toBeDefined();
|
|
||||||
|
|
||||||
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
|
||||||
|
|
||||||
// The reference grabbed before settle resolves with the written outcome, even
|
|
||||||
// though the notifier was dropped from the map on resolve (bounded).
|
|
||||||
await expect(early).resolves.toEqual({
|
|
||||||
status: 'succeeded',
|
|
||||||
error: null,
|
|
||||||
terminalWriteFailed: false,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('recordStep / linkAssistantMessage are best-effort: a repo failure is swallowed', async () => {
|
it('recordStep / linkAssistantMessage are best-effort: a repo failure is swallowed', async () => {
|
||||||
@@ -577,197 +525,3 @@ describe('AiChatRunService run lifecycle', () => {
|
|||||||
).resolves.toBeUndefined();
|
).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('#487 AiChatRunService.supersede (CAS)', () => {
|
|
||||||
const chat = 'chat-1';
|
|
||||||
const ws = 'ws-1';
|
|
||||||
|
|
||||||
it('degrade: no active run on the chat -> caller sends a normal turn', async () => {
|
|
||||||
const repo = makeRepo({
|
|
||||||
findById: jest.fn(async () => undefined),
|
|
||||||
findActiveByChat: jest.fn(async () => undefined),
|
|
||||||
});
|
|
||||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
|
||||||
expect(await svc.supersede(chat, 'run-x', ws)).toEqual({ kind: 'degrade' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('invalid: the target run belongs to a DIFFERENT chat -> 400', async () => {
|
|
||||||
const repo = makeRepo({
|
|
||||||
findById: jest.fn(async () => ({
|
|
||||||
id: 'run-x',
|
|
||||||
chatId: 'other-chat',
|
|
||||||
workspaceId: ws,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
|
||||||
expect(await svc.supersede(chat, 'run-x', ws)).toEqual({ kind: 'invalid' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('mismatch: a DIFFERENT run is active than the one targeted -> current runId', async () => {
|
|
||||||
const repo = makeRepo({
|
|
||||||
findById: jest.fn(async () => ({ id: 'run-x', chatId: chat, workspaceId: ws })),
|
|
||||||
findActiveByChat: jest.fn(async () => ({
|
|
||||||
id: 'run-live',
|
|
||||||
chatId: chat,
|
|
||||||
workspaceId: ws,
|
|
||||||
status: 'running',
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
|
||||||
expect(await svc.supersede(chat, 'run-x', ws)).toEqual({
|
|
||||||
kind: 'mismatch',
|
|
||||||
activeRunId: 'run-live',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ready: the target IS active -> stop it, await its (fast) settle, free the slot', async () => {
|
|
||||||
// Simulate a live long TOOL (NOT a slow UPDATE): the run stays active until an
|
|
||||||
// explicit Stop unwinds it; commit-1's race makes that settle land quickly.
|
|
||||||
// The abort listener stands in for streamText's onAbort -> finalizeRun.
|
|
||||||
const repo = makeRepo({
|
|
||||||
findById: jest.fn(async () => ({
|
|
||||||
id: 'run-1',
|
|
||||||
chatId: chat,
|
|
||||||
workspaceId: ws,
|
|
||||||
status: 'aborted',
|
|
||||||
error: null,
|
|
||||||
})),
|
|
||||||
findActiveByChat: jest.fn(async () => ({
|
|
||||||
id: 'run-1',
|
|
||||||
chatId: chat,
|
|
||||||
workspaceId: ws,
|
|
||||||
status: 'running',
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
|
||||||
const handle = await svc.beginRun({ chatId: chat, workspaceId: ws, userId: 'u1' });
|
|
||||||
handle.signal.addEventListener('abort', () => {
|
|
||||||
void svc.finalizeRun('run-1', ws, 'aborted');
|
|
||||||
});
|
|
||||||
|
|
||||||
// supersede: getRun -> getActiveByChat(==target) -> requestStop -> the abort
|
|
||||||
// listener settles the run -> awaitSettled resolves -> ready.
|
|
||||||
expect(await svc.supersede(chat, 'run-1', ws, 10_000)).toEqual({
|
|
||||||
kind: 'ready',
|
|
||||||
});
|
|
||||||
expect(handle.signal.aborted).toBe(true); // Stop reached the run
|
|
||||||
});
|
|
||||||
|
|
||||||
it('timeout: the target never settles within W -> 409 SUPERSEDE_TIMEOUT (nothing persisted)', async () => {
|
|
||||||
const repo = makeRepo({
|
|
||||||
findById: jest.fn(async () => ({ id: 'run-1', chatId: chat, workspaceId: ws })),
|
|
||||||
findActiveByChat: jest.fn(async () => ({
|
|
||||||
id: 'run-1',
|
|
||||||
chatId: chat,
|
|
||||||
workspaceId: ws,
|
|
||||||
status: 'running',
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
|
||||||
await svc.beginRun({ chatId: chat, workspaceId: ws, userId: 'u1' });
|
|
||||||
// Do NOT settle the run: a tiny W elapses -> timeout.
|
|
||||||
const result = await svc.supersede(chat, 'run-1', ws, 30);
|
|
||||||
expect(result).toEqual({ kind: 'timeout' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ready then a DUPLICATE supersede POST degrades (the run is already gone)', async () => {
|
|
||||||
let active: unknown = {
|
|
||||||
id: 'run-1',
|
|
||||||
chatId: chat,
|
|
||||||
workspaceId: ws,
|
|
||||||
status: 'running',
|
|
||||||
};
|
|
||||||
const repo = makeRepo({
|
|
||||||
findById: jest.fn(async () => ({
|
|
||||||
id: 'run-1',
|
|
||||||
chatId: chat,
|
|
||||||
workspaceId: ws,
|
|
||||||
status: 'aborted',
|
|
||||||
error: null,
|
|
||||||
})),
|
|
||||||
findActiveByChat: jest.fn(async () => active),
|
|
||||||
finalizeIfActive: jest.fn(async () => {
|
|
||||||
active = undefined; // settling frees the active slot
|
|
||||||
return { id: 'run-1', status: 'aborted' };
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
|
||||||
const handle = await svc.beginRun({ chatId: chat, workspaceId: ws, userId: 'u1' });
|
|
||||||
handle.signal.addEventListener('abort', () => {
|
|
||||||
void svc.finalizeRun('run-1', ws, 'aborted');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await svc.supersede(chat, 'run-1', ws, 10_000)).toEqual({
|
|
||||||
kind: 'ready',
|
|
||||||
});
|
|
||||||
// The duplicate POST for the same target now finds no active run -> degrade.
|
|
||||||
expect(await svc.supersede(chat, 'run-1', ws)).toEqual({ kind: 'degrade' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reconcileStaleRuns: aborts a stale run with NO entry/zombie; NEVER touches a live entry', async () => {
|
|
||||||
const finalizeIfActive = jest.fn(async () => ({ id: 'x', status: 'aborted' }));
|
|
||||||
const repo = makeRepo({
|
|
||||||
insert: jest.fn(async (v: any) => ({
|
|
||||||
id: 'live-1',
|
|
||||||
status: 'running',
|
|
||||||
chatId: v.chatId,
|
|
||||||
workspaceId: v.workspaceId,
|
|
||||||
})),
|
|
||||||
finalizeIfActive,
|
|
||||||
findStaleActive: jest.fn(async () => [
|
|
||||||
{ id: 'orphan-1', workspaceId: ws, chatId: 'c-orphan' },
|
|
||||||
{ id: 'live-1', workspaceId: ws, chatId: 'c-live' },
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
|
||||||
// A LIVE run this replica owns (in the `active` map).
|
|
||||||
await svc.beginRun({ chatId: 'c-live', workspaceId: ws, userId: 'u1' });
|
|
||||||
expect(svc.isLocallyActive('live-1')).toBe(true);
|
|
||||||
|
|
||||||
const aborted = await svc.reconcileStaleRuns(15 * 60 * 1000);
|
|
||||||
expect(aborted).toBe(1);
|
|
||||||
// The orphan (no entry) was aborted; the live entry was NEVER passed to the DB.
|
|
||||||
expect(finalizeIfActive).toHaveBeenCalledTimes(1);
|
|
||||||
expect(finalizeIfActive).toHaveBeenCalledWith(
|
|
||||||
'orphan-1',
|
|
||||||
ws,
|
|
||||||
expect.objectContaining({ status: 'aborted' }),
|
|
||||||
);
|
|
||||||
expect(svc.isLocallyActive('live-1')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gave-up zombie: supersede applies the intended status (settleZombie) then is ready', async () => {
|
|
||||||
let healthy = false;
|
|
||||||
let active: unknown = {
|
|
||||||
id: 'run-1',
|
|
||||||
chatId: chat,
|
|
||||||
workspaceId: ws,
|
|
||||||
status: 'running',
|
|
||||||
};
|
|
||||||
const repo = makeRepo({
|
|
||||||
findById: jest.fn(async () => ({ id: 'run-1', chatId: chat, workspaceId: ws })),
|
|
||||||
findActiveByChat: jest.fn(async () => active),
|
|
||||||
finalizeIfActive: jest.fn(async () => {
|
|
||||||
if (!healthy) throw new Error('db down');
|
|
||||||
active = undefined;
|
|
||||||
return { id: 'run-1', status: 'aborted' };
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
|
||||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
||||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
|
||||||
await svc.beginRun({ chatId: chat, workspaceId: ws, userId: 'u1' });
|
|
||||||
|
|
||||||
// The run's terminal write gives up -> zombie (row still 'running').
|
|
||||||
await svc.finalizeRun('run-1', ws, 'aborted');
|
|
||||||
expect(svc.hasZombie('run-1')).toBe(true);
|
|
||||||
|
|
||||||
// The DB recovers; supersede awaits the (already-resolved, terminalWriteFailed)
|
|
||||||
// settle, then settleZombie applies the intended status -> ready.
|
|
||||||
healthy = true;
|
|
||||||
expect(await svc.supersede(chat, 'run-1', ws, 10_000)).toEqual({
|
|
||||||
kind: 'ready',
|
|
||||||
});
|
|
||||||
expect(svc.hasZombie('run-1')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -34,88 +34,6 @@ export class RunAlreadyActiveError extends Error {
|
|||||||
export type TurnTerminalStatus = 'completed' | 'error' | 'aborted';
|
export type TurnTerminalStatus = 'completed' | 'error' | 'aborted';
|
||||||
export type RunTerminalStatus = 'succeeded' | 'failed' | 'aborted';
|
export type RunTerminalStatus = 'succeeded' | 'failed' | 'aborted';
|
||||||
|
|
||||||
/** The terminal run statuses — the row is done once it reads one of these. */
|
|
||||||
export const RUN_TERMINAL_STATUSES: readonly RunTerminalStatus[] = [
|
|
||||||
'succeeded',
|
|
||||||
'failed',
|
|
||||||
'aborted',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Whether a persisted run status is terminal (settled). */
|
|
||||||
export function isRunTerminal(status: string | null | undefined): boolean {
|
|
||||||
return (
|
|
||||||
status === 'succeeded' || status === 'failed' || status === 'aborted'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #487: the outcome a run's {@link AiChatRunService.finalizeRun} settled with.
|
|
||||||
* `terminalWriteFailed` = the terminal write GAVE UP after the bounded retry, so
|
|
||||||
* the row is still non-terminal ('running') and a ZOMBIE record holds the
|
|
||||||
* `intended` status for a later re-drive (reconcile / supersede / boot sweep). A
|
|
||||||
* subscriber (supersede, #487 commit 3) uses this to decide whether the slot is
|
|
||||||
* genuinely free or must first have the intended status applied.
|
|
||||||
*/
|
|
||||||
export interface RunSettleOutcome {
|
|
||||||
status: RunTerminalStatus;
|
|
||||||
error: string | null;
|
|
||||||
terminalWriteFailed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #487: how long a supersede waits for the target run to settle after Stop before
|
|
||||||
* it degrades to `SUPERSEDE_TIMEOUT`. W=10s is generous under a HEALTHY DB: commit
|
|
||||||
* 1's race-on-abort makes an in-app tool abort->settle in ms/hundreds of ms, so a
|
|
||||||
* live run releases its slot well within the window. Under a DB brownout the
|
|
||||||
* timeout is normal (the write cannot land); W must NOT be raised to paper
|
|
||||||
* over a slow DB — a SUPERSEDE_TIMEOUT is the honest signal (nothing persisted,
|
|
||||||
* the composer keeps the user's text). Env-tunable for ops, default 10s.
|
|
||||||
*/
|
|
||||||
export const SUPERSEDE_SETTLE_TIMEOUT_MS = (() => {
|
|
||||||
const raw = Number(process.env.AI_CHAT_SUPERSEDE_TIMEOUT_MS);
|
|
||||||
return Number.isFinite(raw) && raw > 0 ? raw : 10_000;
|
|
||||||
})();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #487: the result of the supersede CAS ({@link AiChatRunService.supersede}).
|
|
||||||
* - `degrade` : no active run on the chat (it ended between click and POST) —
|
|
||||||
* the caller sends a NORMAL turn (NOT a mismatch);
|
|
||||||
* - `invalid` : the target runId belongs to a DIFFERENT chat (malformed CAS 400);
|
|
||||||
* - `mismatch` : a DIFFERENT run is active than the one the client targeted —
|
|
||||||
* 409 SUPERSEDE_TARGET_MISMATCH carrying the current `activeRunId`
|
|
||||||
* (the client does NOT auto-retry);
|
|
||||||
* - `timeout` : the target did not settle within W — 409 SUPERSEDE_TIMEOUT,
|
|
||||||
* nothing persisted;
|
|
||||||
* - `ready` : the target was stopped AND settled (or its zombie's intended was
|
|
||||||
* applied) — the slot is free; the caller may beginRun the new run.
|
|
||||||
*/
|
|
||||||
export type SupersedeResult =
|
|
||||||
| { kind: 'degrade' }
|
|
||||||
| { kind: 'invalid' }
|
|
||||||
| { kind: 'mismatch'; activeRunId: string }
|
|
||||||
| { kind: 'timeout' }
|
|
||||||
| { kind: 'ready' };
|
|
||||||
|
|
||||||
/** A one-shot settle notifier (#487): `resolve` is called EXACTLY ONCE. */
|
|
||||||
interface Deferred<T> {
|
|
||||||
promise: Promise<T>;
|
|
||||||
resolve: (value: T) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #487: a run whose terminal write GAVE UP (every bounded attempt failed). The
|
|
||||||
* row is stranded non-terminal ('running'); this record is the ONLY thing that
|
|
||||||
* distinguishes it from a live run, and carries the `intended` terminal status so
|
|
||||||
* a re-drive can apply it via the conditional UPDATE. Process-local (phase-1
|
|
||||||
* single-process assumption): a restart drops it, and the boot sweep then writes
|
|
||||||
* 'aborted' over the intended — a documented loss (see finalizeRun).
|
|
||||||
*/
|
|
||||||
interface ZombieRun {
|
|
||||||
workspaceId: string;
|
|
||||||
chatId: string;
|
|
||||||
intended: { status: RunTerminalStatus; error: string | null };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapTurnStatusToRun(
|
export function mapTurnStatusToRun(
|
||||||
status: TurnTerminalStatus,
|
status: TurnTerminalStatus,
|
||||||
): RunTerminalStatus {
|
): RunTerminalStatus {
|
||||||
@@ -183,22 +101,6 @@ export class AiChatRunService implements OnModuleInit {
|
|||||||
// uptime — negligible in phase 1's single process.
|
// uptime — negligible in phase 1's single process.
|
||||||
private readonly settled = new Set<string>();
|
private readonly settled = new Set<string>();
|
||||||
|
|
||||||
// #487 runId -> one-shot settle notifier. Kept in a SEPARATE map from `active`
|
|
||||||
// ON PURPOSE: it must OUTLIVE the `active.delete` claim inside finalizeRun (the
|
|
||||||
// claim frees the slot the instant finalize starts), so a subscriber can still
|
|
||||||
// await the outcome after the entry is gone. Created in beginRun, resolved
|
|
||||||
// EXACTLY ONCE in finalizeRun, then removed (bounded). Absence => this replica
|
|
||||||
// has no live notifier: a subscriber falls back to the zombie map, then to the
|
|
||||||
// row (see peekSettled). Process-local (phase-1 single-process assumption).
|
|
||||||
private readonly settledPromises = new Map<string, Deferred<RunSettleOutcome>>();
|
|
||||||
|
|
||||||
// #487 runId -> ZOMBIE record: a run whose terminal write gave up (row stranded
|
|
||||||
// non-terminal). BOUNDED — an entry is added only on give-up and removed on a
|
|
||||||
// successful re-drive (settleZombie) or when the row is found already terminal;
|
|
||||||
// a process restart clears it (and the boot sweep settles the stranded row).
|
|
||||||
// Process-local (phase-1 single-process assumption).
|
|
||||||
private readonly zombies = new Map<string, ZombieRun>();
|
|
||||||
|
|
||||||
// Bounded retry for the terminal write (F6): a single PK UPDATE can fail
|
// Bounded retry for the terminal write (F6): a single PK UPDATE can fail
|
||||||
// transiently under many fire-and-forget writes (pool exhaustion, deadlock, a
|
// transiently under many fire-and-forget writes (pool exhaustion, deadlock, a
|
||||||
// brief connection blip). Riding out that blip in-place matters because the
|
// brief connection blip). Riding out that blip in-place matters because the
|
||||||
@@ -322,10 +224,6 @@ export class AiChatRunService implements OnModuleInit {
|
|||||||
chatId: args.chatId,
|
chatId: args.chatId,
|
||||||
workspaceId: args.workspaceId,
|
workspaceId: args.workspaceId,
|
||||||
});
|
});
|
||||||
// #487: arm the one-shot settle notifier BEFORE returning, so a subscriber
|
|
||||||
// that races in immediately after begin always finds a promise to await. It
|
|
||||||
// is resolved exactly once when the run settles (or gives up).
|
|
||||||
this.settledPromises.set(run.id, this.makeDeferred<RunSettleOutcome>());
|
|
||||||
return { runId: run.id, signal: controller.signal };
|
return { runId: run.id, signal: controller.signal };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,43 +263,47 @@ export class AiChatRunService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finalize a run to its terminal status (succeeded / failed / aborted) via a
|
* Finalize a run to its terminal status (succeeded / failed / aborted),
|
||||||
* CONDITIONAL UPDATE, stamping finishedAt + any error. Atomically safe against a
|
* stamping finishedAt + any error. Best-effort, but ROBUST against a transient
|
||||||
* concurrent settle AND robust against a transient terminal-write failure.
|
* terminal-write failure (F6) AND atomically safe against a concurrent settle.
|
||||||
*
|
*
|
||||||
* ATOMIC ONCE-CLAIM (the gate must close in ONE synchronous tick): two
|
* 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
|
* 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
|
* AiChatService.stream's safety-net catch settling the turn to 'error' while a
|
||||||
* streamText terminal callback (onFinish/onAbort/onError) ALSO settles it. The
|
* streamText terminal callback (onFinish/onAbort/onError) ALSO settles it. The
|
||||||
* claim happens via `active.delete`, a SYNCHRONOUS check-and-clear with NO await
|
* `settled.has` check alone is NOT a gate: it is read BEFORE the awaited UPDATE,
|
||||||
* between the gate and the entry removal: the second concurrent caller finds the
|
* so two callers can both see `false` and both write the row (last-write-wins
|
||||||
* entry already gone and returns in the same tick, before any UPDATE.
|
* 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.
|
||||||
*
|
*
|
||||||
* ALL TERMINAL WRITES ARE CONDITIONAL (#487): `finalizeIfActive` only flips a
|
* ORDER MATTERS (F6): once we own the claim, the terminal UPDATE happens FIRST;
|
||||||
* row still in pending|running (mirror of the assistant message's
|
* only once it SUCCEEDS do we record the run as settled. If the UPDATE fails on
|
||||||
* `onlyIfStreaming`). So even a settle that DID reach the UPDATE (e.g. a
|
* every bounded attempt we RESTORE the in-memory entry, leave the run UNsettled,
|
||||||
* reconcile stamp racing an owner finalize) can never clobber a terminal status
|
* and emit an ERROR signal that the row is left non-terminal 'running' (which
|
||||||
* — the loser matches nothing and is a benign no-op. `active.delete` is the
|
* would 409 every future turn in the chat until recovery). An in-process retry
|
||||||
* fast, in-process gate; the conditional WHERE is the authoritative one.
|
* 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.
|
||||||
*
|
*
|
||||||
* ZOMBIE ON GIVE-UP (#487): if every bounded attempt THROWS (the DB is down for
|
* IDEMPOTENT on SUCCESS (#184 review): the terminal write happens AT MOST ONCE
|
||||||
* the whole finalize), we do NOT restore the entry. The row is stranded
|
* per run. After a successful write the once-gate keys off {@link settled} (the
|
||||||
* non-terminal ('running'); we record a ZOMBIE `{ terminalWriteFailed, intended
|
* terminal row already written) so a settle arriving AFTER the entry was already
|
||||||
* }` (the ONLY thing distinguishing this dead run from a live one) and resolve
|
* dropped-and-settled returns early; a settle racing the in-flight write is
|
||||||
* the settle notifier with `terminalWriteFailed: true`. A restore would make the
|
* stopped earlier still, by the `active.delete` claim. Either way a genuine
|
||||||
* zombie indistinguishable from a live run to every reader; instead a re-drive
|
* double-settle collapses to a single write and a late settle can never clobber
|
||||||
* (settleZombie, called by the periodic reconcile / supersede / opportunistic
|
* the real terminal status or double-write the row.
|
||||||
* paths) applies the intended status later via the same conditional UPDATE.
|
|
||||||
*
|
|
||||||
* DOCUMENTED LOSS (#487, single-process phase 1): if the process RESTARTS before
|
|
||||||
* a zombie is re-driven, the in-memory zombie map is gone and the boot sweep
|
|
||||||
* (unconditional) writes 'aborted' over the ACTUAL intended status. This is
|
|
||||||
* unavoidable while the run lifecycle is single-process — there is no durable
|
|
||||||
* record of `intended`; a cross-process durable intent is deferred to phase 2.
|
|
||||||
*
|
|
||||||
* IDEMPOTENT: the settle notifier resolves EXACTLY ONCE; a second settle is
|
|
||||||
* stopped at `settled.has` or the `active.delete` claim, so a double-settle
|
|
||||||
* collapses to a single write and can never double-resolve or clobber the row.
|
|
||||||
*/
|
*/
|
||||||
async finalizeRun(
|
async finalizeRun(
|
||||||
runId: string,
|
runId: string,
|
||||||
@@ -412,17 +314,13 @@ export class AiChatRunService implements OnModuleInit {
|
|||||||
// ---- Atomic once-claim (synchronous; NO await before the gate closes) ----
|
// ---- Atomic once-claim (synchronous; NO await before the gate closes) ----
|
||||||
// Already terminally written -> idempotent no-op.
|
// Already terminally written -> idempotent no-op.
|
||||||
if (this.settled.has(runId)) return;
|
if (this.settled.has(runId)) return;
|
||||||
// Capture the entry BEFORE the delete for the give-up log context.
|
// Capture the entry BEFORE the delete so a total-failure path can restore it.
|
||||||
const entry = this.active.get(runId);
|
const entry = this.active.get(runId);
|
||||||
// SYNCHRONOUS check-and-clear: the FIRST caller deletes (claims) the entry;
|
// SYNCHRONOUS check-and-clear: the FIRST caller deletes (claims) the entry;
|
||||||
// any concurrent SECOND caller finds nothing to delete and returns HERE, in
|
// 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.
|
// the same tick, before any await — so it can never reach the UPDATE.
|
||||||
if (!this.active.delete(runId)) return;
|
if (!this.active.delete(runId)) return;
|
||||||
|
|
||||||
const status = mapTurnStatusToRun(turnStatus);
|
|
||||||
const err = error ?? null;
|
|
||||||
const chatId = entry?.chatId ?? 'unknown';
|
|
||||||
|
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
for (
|
for (
|
||||||
let attempt = 1;
|
let attempt = 1;
|
||||||
@@ -430,294 +328,47 @@ export class AiChatRunService implements OnModuleInit {
|
|||||||
attempt++
|
attempt++
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const row = await this.runRepo.finalizeIfActive(runId, workspaceId, {
|
await this.runRepo.update(runId, workspaceId, {
|
||||||
status,
|
status: mapTurnStatusToRun(turnStatus),
|
||||||
error: err,
|
finishedAt: new Date(),
|
||||||
|
error: error ?? null,
|
||||||
});
|
});
|
||||||
// No throw => the row is now terminal (we wrote it, or it was ALREADY
|
// Terminal write landed: arm the once-gate. The entry is already gone
|
||||||
// terminal — another writer won the conditional UPDATE, a benign no-op).
|
// (claimed above); we do NOT restore it. The slot is now free.
|
||||||
this.settled.add(runId);
|
this.settled.add(runId);
|
||||||
this.zombies.delete(runId);
|
|
||||||
// Resolve with the persisted outcome: our status when WE wrote it, else
|
|
||||||
// the row's real terminal status (re-read on the already-terminal path so
|
|
||||||
// a subscriber never sees a status we did not actually persist).
|
|
||||||
const outcome: RunSettleOutcome = row
|
|
||||||
? { status, error: err, terminalWriteFailed: false }
|
|
||||||
: await this.readTerminalOutcome(runId, workspaceId, status, err);
|
|
||||||
this.resolveSettled(runId, outcome);
|
|
||||||
return;
|
return;
|
||||||
} catch (err2) {
|
} catch (err) {
|
||||||
lastError = err2;
|
lastError = err;
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Failed to finalize run ${runId} (attempt ${attempt}/${
|
`Failed to finalize run ${runId} (attempt ${attempt}/${
|
||||||
AiChatRunService.FINALIZE_MAX_ATTEMPTS
|
AiChatRunService.FINALIZE_MAX_ATTEMPTS
|
||||||
}): ${err2 instanceof Error ? err2.message : 'unknown error'}`,
|
}): ${err instanceof Error ? err.message : 'unknown error'}`,
|
||||||
);
|
);
|
||||||
if (attempt < AiChatRunService.FINALIZE_MAX_ATTEMPTS) {
|
if (attempt < AiChatRunService.FINALIZE_MAX_ATTEMPTS) {
|
||||||
await this.delay(AiChatRunService.FINALIZE_RETRY_BASE_MS * attempt);
|
await this.delay(AiChatRunService.FINALIZE_RETRY_BASE_MS * attempt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Every attempt threw: GIVE UP. The row is stranded non-terminal ('running').
|
// Every attempt failed: this is a give-up, materially worse than a per-attempt
|
||||||
// Do NOT restore the entry (a restored entry is indistinguishable from a live
|
// blip — the row is left NON-TERMINAL ('running'), so emit ONE explicit,
|
||||||
// run); leave a ZOMBIE record instead, and resolve the notifier as
|
// greppable ERROR so an operator can tell "survived a blip" from "gave up, run
|
||||||
// terminalWriteFailed so a subscriber knows the slot still needs the intended
|
// held in memory until recovery" (the last warn alone says only "attempt 3/3").
|
||||||
// status applied. One explicit, greppable ERROR so an operator can tell a
|
|
||||||
// give-up from a per-attempt blip.
|
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Run ${runId} (chat ${chatId}) left NON-TERMINAL ('running'): terminal ` +
|
`Run ${runId} (chat ${entry?.chatId ?? 'unknown'}) left NON-TERMINAL ` +
|
||||||
`write failed after ${AiChatRunService.FINALIZE_MAX_ATTEMPTS} attempts; ` +
|
`('running'): terminal write failed after ${
|
||||||
`ZOMBIE recorded (intended '${status}'), recovery deferred to reconcile / ` +
|
AiChatRunService.FINALIZE_MAX_ATTEMPTS
|
||||||
`supersede / boot sweep`,
|
} attempts; entry retained in memory, recovery deferred to next settle / ` +
|
||||||
|
`boot sweep`,
|
||||||
lastError,
|
lastError,
|
||||||
);
|
);
|
||||||
this.zombies.set(runId, {
|
// RESTORE the claimed entry (and leave the run UNsettled) so a LATER settle
|
||||||
workspaceId,
|
// that arrives AFTER this restore MAY retry the terminal write — but that
|
||||||
chatId,
|
// in-process retry is NOT guaranteed (a concurrent settler caught in the retry
|
||||||
intended: { status, error: err },
|
// 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
|
||||||
this.resolveSettled(runId, { status, error: err, terminalWriteFailed: true });
|
// is the boot sweep on the next restart; the restored entry is bounded and
|
||||||
}
|
// cleared on restart.
|
||||||
|
if (entry) this.active.set(runId, entry);
|
||||||
/**
|
|
||||||
* #487: re-drive a zombie run's intended terminal write (the conditional
|
|
||||||
* UPDATE). Called by the periodic reconcile (commit 4), an opportunistic
|
|
||||||
* single-chat reconcile, and supersede (commit 3). On success — the row is now
|
|
||||||
* terminal (written OR found already terminal) — the zombie is cleared and the
|
|
||||||
* once-gate armed; on another failure the zombie is kept for a later retry.
|
|
||||||
* Returns true when the row is now terminal. Best-effort; never throws.
|
|
||||||
*/
|
|
||||||
async settleZombie(runId: string): Promise<boolean> {
|
|
||||||
const z = this.zombies.get(runId);
|
|
||||||
if (!z) return false;
|
|
||||||
try {
|
|
||||||
await this.runRepo.finalizeIfActive(runId, z.workspaceId, {
|
|
||||||
status: z.intended.status,
|
|
||||||
error: z.intended.error,
|
|
||||||
});
|
|
||||||
this.zombies.delete(runId);
|
|
||||||
this.settled.add(runId);
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Re-drive of zombie run ${runId} (chat ${z.chatId}) failed; will retry ` +
|
|
||||||
`later: ${err instanceof Error ? err.message : 'unknown error'}`,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #487 reconcile clause (c): abort runs the DB still shows active (pending|
|
|
||||||
* running) but that this replica does NOT own — NO live entry AND NO zombie —
|
|
||||||
* and that have been UNTOUCHED past `staleMs` (from last-progress `updated_at`,
|
|
||||||
* NOT startedAt, so a legit long marathon is never a candidate). "No entry" is
|
|
||||||
* the PRIMARY gate: a live entry (an actively-executing run on this replica) is
|
|
||||||
* NEVER aborted, whatever its age. Returns the number aborted. Best-effort —
|
|
||||||
* never throws (a periodic-job failure must not crash the process).
|
|
||||||
*/
|
|
||||||
async reconcileStaleRuns(staleMs: number): Promise<number> {
|
|
||||||
let candidates: Array<{ id: string; workspaceId: string; chatId: string }>;
|
|
||||||
try {
|
|
||||||
candidates = await this.runRepo.findStaleActive(staleMs);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Reconcile (stale runs) query failed: ${
|
|
||||||
err instanceof Error ? err.message : 'unknown error'
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let aborted = 0;
|
|
||||||
for (const c of candidates) {
|
|
||||||
// PRIMARY gate: never touch a live entry, and never race a zombie we are
|
|
||||||
// already re-driving (settleZombie owns those).
|
|
||||||
if (this.active.has(c.id) || this.zombies.has(c.id)) continue;
|
|
||||||
try {
|
|
||||||
const row = await this.runRepo.finalizeIfActive(c.id, c.workspaceId, {
|
|
||||||
status: 'aborted',
|
|
||||||
error: 'Run aborted by reconcile: no live runner (stale).',
|
|
||||||
});
|
|
||||||
if (row) {
|
|
||||||
aborted += 1;
|
|
||||||
this.settled.add(c.id);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Reconcile abort of stale run ${c.id} failed: ${
|
|
||||||
err instanceof Error ? err.message : 'unknown error'
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return aborted;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #487: the run's settle outcome as seen by THIS replica, or undefined when it
|
|
||||||
* has no record (the caller then reads the row — the DB is the source of truth).
|
|
||||||
* A LIVE deferred (still settling, or resolved-but-not-yet-consumed) wins; a
|
|
||||||
* ZOMBIE synthesizes the give-up outcome. A subscriber (supersede) races this
|
|
||||||
* against a timeout.
|
|
||||||
*/
|
|
||||||
peekSettled(runId: string): Promise<RunSettleOutcome> | undefined {
|
|
||||||
const d = this.settledPromises.get(runId);
|
|
||||||
if (d) return d.promise;
|
|
||||||
const z = this.zombies.get(runId);
|
|
||||||
if (z) {
|
|
||||||
return Promise.resolve({
|
|
||||||
status: z.intended.status,
|
|
||||||
error: z.intended.error,
|
|
||||||
terminalWriteFailed: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #487: await a run's settle outcome, bounded by `timeoutMs`. Returns the
|
|
||||||
* outcome on settle, or undefined on TIMEOUT (or when this replica has no record
|
|
||||||
* of the run and its row is not terminal). Uses the LIVE settle notifier / the
|
|
||||||
* zombie synth when present; else reads the row (the DB is the source of truth
|
|
||||||
* once the in-memory record is gone). The subscriber (supersede) grabs this
|
|
||||||
* right after Stop; commit 1's race makes the settle land in ms on a healthy DB.
|
|
||||||
*/
|
|
||||||
async awaitSettled(
|
|
||||||
runId: string,
|
|
||||||
workspaceId: string,
|
|
||||||
timeoutMs: number,
|
|
||||||
): Promise<RunSettleOutcome | undefined> {
|
|
||||||
const pending = this.peekSettled(runId);
|
|
||||||
if (pending) {
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
const timeout = new Promise<undefined>((resolve) => {
|
|
||||||
timer = setTimeout(() => resolve(undefined), timeoutMs);
|
|
||||||
timer.unref?.();
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
return await Promise.race([pending, timeout]);
|
|
||||||
} finally {
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// No live notifier and no zombie: read the row (already settled-and-written,
|
|
||||||
// or unknown here). A terminal row is an outcome; anything else -> undefined.
|
|
||||||
const row = await this.runRepo.findById(runId, workspaceId);
|
|
||||||
if (row && isRunTerminal(row.status)) {
|
|
||||||
return {
|
|
||||||
status: row.status as RunTerminalStatus,
|
|
||||||
error: row.error ?? null,
|
|
||||||
terminalWriteFailed: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #487: the SERVER supersede CAS for `POST /stream { supersede: { runId: X } }`.
|
|
||||||
* Atomically transitions "X is the chat's active run" -> "X is stopped, settled,
|
|
||||||
* slot free" so the caller can start a replacement run. See {@link
|
|
||||||
* SupersedeResult} for the branch semantics.
|
|
||||||
*
|
|
||||||
* On a `ready` result the caller MUST still go through the normal beginRun gate
|
|
||||||
* (the partial unique index) — between the slot freeing here and beginRun a
|
|
||||||
* neighbouring tab's ordinary POST can win the slot (documented SLOT-THEFT: the
|
|
||||||
* loser then gets a MISMATCH carrying the NEW runId). There is also NO side-
|
|
||||||
* effect quiescence: an in-flight write of the stopped run may still land AFTER
|
|
||||||
* the new run starts (commit 1 stops the NEXT call, not one already committing),
|
|
||||||
* so the caller adds a prompt note to the new run.
|
|
||||||
*/
|
|
||||||
async supersede(
|
|
||||||
chatId: string,
|
|
||||||
targetRunId: string,
|
|
||||||
workspaceId: string,
|
|
||||||
timeoutMs: number = SUPERSEDE_SETTLE_TIMEOUT_MS,
|
|
||||||
): Promise<SupersedeResult> {
|
|
||||||
// Validate the target belongs to THIS chat (a CAS targeting another chat's run
|
|
||||||
// is malformed -> 400). A missing row is NOT invalid: the run may have ended
|
|
||||||
// and been pruned; the active-run check below decides degrade vs mismatch.
|
|
||||||
const target = await this.getRun(targetRunId, workspaceId);
|
|
||||||
if (target && target.chatId !== chatId) return { kind: 'invalid' };
|
|
||||||
|
|
||||||
const active = await this.getActiveForChat(chatId, workspaceId);
|
|
||||||
// No active run: it ended between the client's click and this POST — this is a
|
|
||||||
// DEGRADE to a normal send, NOT a mismatch (the user's intent still holds).
|
|
||||||
if (!active) return { kind: 'degrade' };
|
|
||||||
// A DIFFERENT run is active than the one the client saw -> mismatch. The
|
|
||||||
// client does not auto-retry; it surfaces the new runId.
|
|
||||||
if (active.id !== targetRunId) {
|
|
||||||
return { kind: 'mismatch', activeRunId: active.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
// The target IS active: stop it, then await its settle within W.
|
|
||||||
await this.requestStop(targetRunId, workspaceId);
|
|
||||||
const outcome = await this.awaitSettled(targetRunId, workspaceId, timeoutMs);
|
|
||||||
if (!outcome) return { kind: 'timeout' };
|
|
||||||
// Gave up (terminal write failed): apply the intended status via the
|
|
||||||
// conditional UPDATE so the slot actually frees. If that ALSO fails, the row
|
|
||||||
// is still stranded -> treat as a timeout (nothing persisted for the new run).
|
|
||||||
if (outcome.terminalWriteFailed) {
|
|
||||||
const settled = await this.settleZombie(targetRunId);
|
|
||||||
if (!settled) return { kind: 'timeout' };
|
|
||||||
}
|
|
||||||
return { kind: 'ready' };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** #487 test/diagnostic seam: whether a give-up zombie is held for this run. */
|
|
||||||
hasZombie(runId: string): boolean {
|
|
||||||
return this.zombies.has(runId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** #487: every zombie runId held on this replica (reconcile clause a, commit 4). */
|
|
||||||
zombieRunIds(): string[] {
|
|
||||||
return [...this.zombies.keys()];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** #487: create a one-shot deferred (resolve captured for a later single call). */
|
|
||||||
private makeDeferred<T>(): Deferred<T> {
|
|
||||||
let resolve!: (value: T) => void;
|
|
||||||
const promise = new Promise<T>((r) => {
|
|
||||||
resolve = r;
|
|
||||||
});
|
|
||||||
return { promise, resolve };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** #487: resolve a run's settle notifier EXACTLY ONCE, then drop it (bounded).
|
|
||||||
* A subscriber that already grabbed the promise still resolves; a later one
|
|
||||||
* falls back to the zombie map / the row (see peekSettled). */
|
|
||||||
private resolveSettled(runId: string, outcome: RunSettleOutcome): void {
|
|
||||||
const d = this.settledPromises.get(runId);
|
|
||||||
if (!d) return;
|
|
||||||
this.settledPromises.delete(runId);
|
|
||||||
d.resolve(outcome);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** #487: read the persisted terminal outcome when the conditional finalize was a
|
|
||||||
* no-op (the row was already terminal). Falls back to the intended status when
|
|
||||||
* the read fails or the row is unexpectedly missing/non-terminal. */
|
|
||||||
private async readTerminalOutcome(
|
|
||||||
runId: string,
|
|
||||||
workspaceId: string,
|
|
||||||
fallbackStatus: RunTerminalStatus,
|
|
||||||
fallbackError: string | null,
|
|
||||||
): Promise<RunSettleOutcome> {
|
|
||||||
try {
|
|
||||||
const row = await this.runRepo.findById(runId, workspaceId);
|
|
||||||
if (row && isRunTerminal(row.status)) {
|
|
||||||
return {
|
|
||||||
status: row.status as RunTerminalStatus,
|
|
||||||
error: row.error ?? null,
|
|
||||||
terminalWriteFailed: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Fall through to the intended status — best-effort only.
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
status: fallbackStatus,
|
|
||||||
error: fallbackError,
|
|
||||||
terminalWriteFailed: false,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Small async backoff between terminal-write retries (F6). Isolated so it is
|
/** Small async backoff between terminal-write retries (F6). Isolated so it is
|
||||||
|
|||||||
@@ -1,122 +1,42 @@
|
|||||||
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory run-stream registry (#184 phase 1.5, step-aligned retention #491). A
|
* In-memory run-stream registry (#184 phase 1.5). A durable agent run tees its
|
||||||
* durable agent run tees its SSE frames here (via
|
* SSE frames here (via `pipeUIMessageStreamToResponse({ consumeSseStream })`)
|
||||||
* `pipeUIMessageStreamToResponse({ consumeSseStream })`) so a LATE tab — one that
|
* so a LATE tab — one that reloaded, or opened after the starter dropped — can
|
||||||
* reloaded, or opened after the starter dropped — can attach through
|
* attach through `GET /ai-chat/runs/:chatId/stream`, replay the frames buffered
|
||||||
* `GET /ai-chat/runs/:chatId/stream`, be handed the TAIL past the step it already
|
* so far, and then follow the live tail as a normal streamer.
|
||||||
* has persisted, and then follow the live tail as a normal streamer.
|
|
||||||
*
|
*
|
||||||
* This is deliberately single-process and best-effort: it holds nothing the DB
|
* This is deliberately single-process and best-effort: it holds nothing the DB
|
||||||
* does not (the run + assistant row are the source of truth), so a process
|
* does not (the run + assistant row are the source of truth), so a process
|
||||||
* restart simply drops in-flight entries and the client falls back to its
|
* restart simply drops in-flight entries and the client falls back to its
|
||||||
* restore + degraded-poll path. The async `attach` return type is the seam for a
|
* restore + degraded-poll path. The async `attach` return type is the seam for a
|
||||||
* future phase-2 cross-process backend (Redis) — the interface does not change.
|
* future phase-2 cross-process backend (Redis) — the interface does not change.
|
||||||
*
|
|
||||||
* ── #491 step-aligned retention (the OOM fix) ────────────────────────────────
|
|
||||||
* The old registry buffered up to 32MB of raw SSE frames PER active run (V8 ~2×
|
|
||||||
* in memory) and, on attach, blasted the WHOLE buffer to the socket synchronously
|
|
||||||
* with no drain — a handful of marathon runs on a 1GB container OOM'd. #491 caps
|
|
||||||
* the ring at a few MB (env-tunable, default 4MB) and keeps it there by ROTATING:
|
|
||||||
*
|
|
||||||
* - Every buffered frame is STAMPED with a step number at tee (see ingestFrame).
|
|
||||||
* Convention: the stamp of a frame is the number of `finish-step` parts seen
|
|
||||||
* BEFORE it (starting at 0). The finish-step frame itself carries the current
|
|
||||||
* value, THEN the counter increments. So a frame stamped `s` is the content of
|
|
||||||
* the (s+1)-th step — 0-based step index `s` — and the stamp aligns EXACTLY
|
|
||||||
* with `metadata.stepsPersisted`: a client whose persisted `stepsPersisted` is
|
|
||||||
* N has steps 0..N-1 on disk (and in its seed) and needs the tail `stamp >= N`.
|
|
||||||
*
|
|
||||||
* - The ring rotates ONLY on a CONFIRMED persist of step N
|
|
||||||
* (`confirmPersistedStep`), dropping frames with `stamp < N` (those steps are
|
|
||||||
* now on disk and a fresh client seed carries them). A NON-confirmed step is
|
|
||||||
* never rotated away, so a persist FAILURE just makes the ring cover MORE
|
|
||||||
* (auto-safe). This is the anti-inversion rule: a naive "rotate in .then()"
|
|
||||||
* that rotated after an UNwritten step would drop a step nobody has → silent
|
|
||||||
* hole. Rotation is gated on a real, successful persist.
|
|
||||||
*
|
|
||||||
* - If the ring still exceeds its byte cap after rotation (a single fat step, or
|
|
||||||
* a lagging persist), the OLDEST frames are evicted to stay bounded. Evicting a
|
|
||||||
* not-yet-persisted frame opens a GAP: an attach whose N falls at or below an
|
|
||||||
* evicted step answers 204 and the client degrades to restore+poll. The gap is
|
|
||||||
* NOT sticky — the coverage floor is recomputed from the ring, so a later
|
|
||||||
* persist that rotates past the holey steps clears it.
|
|
||||||
*
|
|
||||||
* ── attach numbering / coverage (the wire convention) ────────────────────────
|
|
||||||
* The step marker N comes ONLY FROM THE CLIENT (a query param). The server never
|
|
||||||
* reads the row to derive N — a server-side N from a stale seed would open a
|
|
||||||
* silent one-step hole. N is the client's persisted `stepsPersisted` (a COUNT):
|
|
||||||
* - the tail it needs = frames with `stamp >= N`;
|
|
||||||
* - coverage is OK ⟺ `coverageFloor(entry) <= N`, where coverageFloor is the
|
|
||||||
* smallest step FULLY present in the ring (its smallest retained stamp, bumped
|
|
||||||
* by one when that leading step was only partially evicted by overflow). If
|
|
||||||
* `coverageFloor > N` the ring starts AFTER the client's frontier (a hole, or
|
|
||||||
* the client's seed simply lagged behind a rotation) → 204 → the client
|
|
||||||
* refetches (a larger N) and re-attaches.
|
|
||||||
* The N cutoff is applied in ALL branches, INCLUDING the finished-retained replay.
|
|
||||||
*
|
|
||||||
* ── same-tick invariants (unchanged, still load-bearing) ─────────────────────
|
|
||||||
* invariant 1: only the matching run may mutate/observe an entry (runId check).
|
|
||||||
* invariant 2: retention deletes ONLY its own entry (a replacement may own the key).
|
|
||||||
* invariant 3: open() over a live entry mirrors the done-path (subscribers released).
|
|
||||||
* invariant 4: the tail SLICE + subscriber registration happen in ONE synchronous
|
|
||||||
* tick inside attach() — no await between them — so a concurrently
|
|
||||||
* ingested frame is EITHER in the snapshot (buffered before the sync
|
|
||||||
* block, and the just-added subscriber never sees it) OR fanned out to
|
|
||||||
* the paused subscriber's `pending` (ingested after) — never both and
|
|
||||||
* never neither: no loss, no duplication. NOTE (#491): the controller
|
|
||||||
* now AWAITS the drain-respecting tail write BEFORE calling start(), so
|
|
||||||
* frames ingested during that await accumulate in `pending`; this is
|
|
||||||
* bounded by the subscriber cap (an overflow degrades start() to an
|
|
||||||
* end(), a 204-equivalent). It is the SYNCHRONOUS snapshot+registration
|
|
||||||
* — not a same-tick start() — that makes this correct.
|
|
||||||
* invariant 5: the controller wires close-cleanup BEFORE any write.
|
|
||||||
* invariant 6: no cross-run replay — the `anchor` (the client's assistant row id)
|
|
||||||
* must match this run's assistant id, or a foreign run's transcript
|
|
||||||
* would be appended to the client's message.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** How long a finished entry is retained for late attach (replay + immediate end). */
|
/** How long a finished entry is retained for late attach (replay + immediate end). */
|
||||||
export const RUN_STREAM_RETAIN_FINISHED_MS = 30_000;
|
export const RUN_STREAM_RETAIN_FINISHED_MS = 30_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DEFAULT per-run replay ring cap (#491, down from 32MB). SSE frames carry
|
* Per-run replay buffer cap. Past this the buffer is dropped (attach -> 204, and
|
||||||
* UNcompacted tool outputs + framing overhead (×1.5–2 vs the persisted parts), so
|
* the client falls back to its restore + degraded-poll path, #430).
|
||||||
* a "2–3 large reads + reasoning" step routinely blows past 2MB; 4MB comfortably
|
*
|
||||||
* holds a step or two of TAIL, which is all a resuming client needs (steps below
|
* Raised from 4MB to 32MB (#430): marathon autonomous runs (11-25 min observed)
|
||||||
* its persisted frontier come from the seed, not the ring). The ring stays bounded
|
* stream far more than 4MB of SSE frames, so a live disconnect mid-run would find
|
||||||
* because it rotates on every confirmed persist; this cap is only the ceiling for
|
* an already-overflowed buffer and could only degrade-poll instead of re-attaching
|
||||||
* the un-persisted tail between rotations. Env-tunable via
|
* to the live tail. 32MB comfortably covers those runs while staying bounded.
|
||||||
* AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES (bytes); a 0/invalid value falls back to this.
|
*
|
||||||
|
* Memory cost: this is the WORST-CASE retained size PER ACTIVE run (the buffer is
|
||||||
|
* freed on finish + retention, or dropped immediately on overflow). With the small
|
||||||
|
* number of concurrent autonomous runs a single workspace realistically has, 32MB
|
||||||
|
* each is an acceptable ceiling; the overflow->204->degraded-poll fallback remains
|
||||||
|
* the backstop for anything larger, so correctness never depends on this bound.
|
||||||
*/
|
*/
|
||||||
export const AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES = 4 * 1024 * 1024;
|
export const RUN_STREAM_MAX_BUFFER_BYTES = 32 * 1024 * 1024;
|
||||||
|
|
||||||
// 2× the ring cap: a just-written full-tail burst alone can never trip the
|
// 2x the replay cap: a just-written full-replay burst alone can never trip the
|
||||||
// per-subscriber cap (see controller); only a genuinely stalled socket can. This
|
// per-subscriber cap (see controller); only a genuinely stalled socket can.
|
||||||
// derivative relationship is preserved even when the ring cap is env-overridden.
|
export const SUBSCRIBER_MAX_BUFFERED_BYTES = 2 * RUN_STREAM_MAX_BUFFER_BYTES;
|
||||||
export const SUBSCRIBER_MAX_BUFFERED_BYTES = 2 * AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A finish-step boundary frame is exactly `data: {"type":"finish-step"...}\n\n`
|
|
||||||
* (verified empirically against ai@6.0.207 — each UI-message-stream part is a
|
|
||||||
* single `data: {json}\n\n` event, never split across `data:` lines, and `type`
|
|
||||||
* is always the first key). A prefix match is cheaper than JSON.parse-per-frame
|
|
||||||
* and has no false positives: a literal `"type":"finish-step"` inside a text
|
|
||||||
* delta is JSON-escaped (`\"type\":...`), and the frame would start with
|
|
||||||
* `data: {"type":"text-delta"` anyway.
|
|
||||||
*/
|
|
||||||
const FINISH_STEP_FRAME_PREFIX = 'data: {"type":"finish-step"';
|
|
||||||
|
|
||||||
/** Resolve the ring cap from the environment, falling back to the default. */
|
|
||||||
function resolveMaxBufferBytes(): number {
|
|
||||||
const raw = process.env.AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES;
|
|
||||||
if (!raw) return AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES;
|
|
||||||
const parsed = Number(raw);
|
|
||||||
return Number.isFinite(parsed) && parsed > 0
|
|
||||||
? Math.floor(parsed)
|
|
||||||
: AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RunStreamCallbacks {
|
export interface RunStreamCallbacks {
|
||||||
onFrame: (frame: string) => void;
|
onFrame: (frame: string) => void;
|
||||||
@@ -124,9 +44,6 @@ export interface RunStreamCallbacks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RunStreamAttachment {
|
export interface RunStreamAttachment {
|
||||||
// The synthetic `start` frame (carrying { runId, chatId }) followed by the
|
|
||||||
// buffered TAIL filtered to `stamp >= N`. The controller writes these to the
|
|
||||||
// socket in chunks respecting drain, then calls start().
|
|
||||||
replay: string[];
|
replay: string[];
|
||||||
finished: boolean;
|
finished: boolean;
|
||||||
start(): void; // drain pending frames (order preserved) and go live
|
start(): void; // drain pending frames (order preserved) and go live
|
||||||
@@ -136,19 +53,14 @@ export interface RunStreamAttachment {
|
|||||||
interface Subscriber extends RunStreamCallbacks {
|
interface Subscriber extends RunStreamCallbacks {
|
||||||
started: boolean;
|
started: boolean;
|
||||||
pending: string[];
|
pending: string[];
|
||||||
// Byte size of `pending`, capped at the subscriber cap. `start()` is called in
|
// Byte size of `pending`, capped at SUBSCRIBER_MAX_BUFFERED_BYTES. `start()` is
|
||||||
// the SAME tick as `attach()` today, so `pending` never holds more than one
|
// called in the SAME tick as `attach()` today (see attach), so `pending` never
|
||||||
// microtask of frames — but the controller writes the (potentially large) tail
|
// holds more than one microtask of frames — but the async `attach` signature is
|
||||||
// respecting drain BEFORE start(), so a stalled socket can accumulate here; the
|
// a phase-2 seam: an await between attach and start would let a stalled paused
|
||||||
// cap is the structural backstop (an overflow degrades start() to an end()).
|
// subscriber buffer the WHOLE run here. The cap is the structural backstop.
|
||||||
pendingBytes: number;
|
pendingBytes: number;
|
||||||
overflowed: boolean;
|
overflowed: boolean;
|
||||||
pendingEnd: boolean;
|
pendingEnd: boolean;
|
||||||
// The client's step frontier N: this subscriber only receives frames with
|
|
||||||
// `stamp >= minStamp` (the tail past what it already persisted). Live frames
|
|
||||||
// always satisfy this (their stamp is the current, highest step), so it only
|
|
||||||
// filters the rare out-of-order below-frontier frame.
|
|
||||||
minStamp: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Entry {
|
interface Entry {
|
||||||
@@ -156,20 +68,8 @@ interface Entry {
|
|||||||
// The persisted assistant row id of this run (set at bind; undefined if the
|
// The persisted assistant row id of this run (set at bind; undefined if the
|
||||||
// seed failed). Used by the attach anchor check (invariant 6).
|
// seed failed). Used by the attach anchor check (invariant 6).
|
||||||
assistantMessageId?: string;
|
assistantMessageId?: string;
|
||||||
// Parallel arrays: frames[i] is the SSE string, stamps[i] its step number.
|
|
||||||
frames: string[];
|
frames: string[];
|
||||||
stamps: number[];
|
|
||||||
bytes: number;
|
bytes: number;
|
||||||
// The running step counter used to stamp the NEXT frame (number of finish-step
|
|
||||||
// frames seen so far).
|
|
||||||
currentStamp: number;
|
|
||||||
// The highest confirmed `stepsPersisted`: frames with stamp < persistedFloor are
|
|
||||||
// on disk (safe to drop, never re-buffered). Monotonic (confirmPersistedStep).
|
|
||||||
persistedFloor: number;
|
|
||||||
// The highest stamp EVICTED by an overflow (unsafe) drop, -1 if none. Used to
|
|
||||||
// detect a partially-evicted leading step when computing the coverage floor.
|
|
||||||
overflowThroughStamp: number;
|
|
||||||
// Sticky-for-logging only: at least one unsafe (overflow) eviction happened.
|
|
||||||
overflowed: boolean;
|
overflowed: boolean;
|
||||||
finished: boolean;
|
finished: boolean;
|
||||||
subscribers: Set<Subscriber>;
|
subscribers: Set<Subscriber>;
|
||||||
@@ -180,10 +80,6 @@ interface Entry {
|
|||||||
export class AiChatStreamRegistryService implements OnModuleDestroy {
|
export class AiChatStreamRegistryService implements OnModuleDestroy {
|
||||||
private readonly logger = new Logger(AiChatStreamRegistryService.name);
|
private readonly logger = new Logger(AiChatStreamRegistryService.name);
|
||||||
private readonly entries = new Map<string, Entry>(); // key: chatId
|
private readonly entries = new Map<string, Entry>(); // key: chatId
|
||||||
// Env-resolved caps (per instance) so a deployment can tune the ceiling without
|
|
||||||
// a code change. The subscriber cap keeps the documented 2× relationship.
|
|
||||||
readonly maxBufferBytes = resolveMaxBufferBytes();
|
|
||||||
readonly subscriberMaxBufferedBytes = 2 * this.maxBufferBytes;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a fresh entry at the START of a run (before any frame), so a tab
|
* Register a fresh entry at the START of a run (before any frame), so a tab
|
||||||
@@ -209,11 +105,7 @@ export class AiChatStreamRegistryService implements OnModuleDestroy {
|
|||||||
this.entries.set(chatId, {
|
this.entries.set(chatId, {
|
||||||
runId,
|
runId,
|
||||||
frames: [],
|
frames: [],
|
||||||
stamps: [],
|
|
||||||
bytes: 0,
|
bytes: 0,
|
||||||
currentStamp: 0,
|
|
||||||
persistedFloor: 0,
|
|
||||||
overflowThroughStamp: -1,
|
|
||||||
overflowed: false,
|
overflowed: false,
|
||||||
finished: false,
|
finished: false,
|
||||||
subscribers: new Set<Subscriber>(),
|
subscribers: new Set<Subscriber>(),
|
||||||
@@ -258,34 +150,6 @@ export class AiChatStreamRegistryService implements OnModuleDestroy {
|
|||||||
void pump();
|
void pump();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Confirm that step `stepsPersisted` (a COUNT: steps 0..stepsPersisted-1) is on
|
|
||||||
* disk for this run, and ROTATE the ring: drop the buffered frames of those
|
|
||||||
* now-persisted steps (stamp < stepsPersisted). This is the ONLY thing that
|
|
||||||
* rotates the ring, and it is called ONLY after a genuinely SUCCESSFUL per-step
|
|
||||||
* persist (see ai-chat.service updateStreaming). A failed persist never calls
|
|
||||||
* it, so the ring covers more (auto-safe). Identity-checked (invariant 1) and
|
|
||||||
* monotonic (a stale lower count is ignored).
|
|
||||||
*/
|
|
||||||
confirmPersistedStep(
|
|
||||||
chatId: string,
|
|
||||||
runId: string,
|
|
||||||
stepsPersisted: number,
|
|
||||||
): void {
|
|
||||||
const entry = this.entries.get(chatId);
|
|
||||||
if (!entry || entry.runId !== runId) return;
|
|
||||||
if (!Number.isFinite(stepsPersisted) || stepsPersisted <= entry.persistedFloor)
|
|
||||||
return;
|
|
||||||
entry.persistedFloor = stepsPersisted;
|
|
||||||
// Clean rotation: drop the persisted steps from the head. These frames are on
|
|
||||||
// disk + carried by a fresh client seed, so this NEVER opens a gap.
|
|
||||||
while (entry.frames.length > 0 && entry.stamps[0] < stepsPersisted) {
|
|
||||||
entry.bytes -= Buffer.byteLength(entry.frames[0]);
|
|
||||||
entry.frames.shift();
|
|
||||||
entry.stamps.shift();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Terminate a run's entry from the OUTER catch of the stream method (a failure
|
* Terminate a run's entry from the OUTER catch of the stream method (a failure
|
||||||
* before/while wiring the pipe, so `done` will never arrive). Identity-checked
|
* before/while wiring the pipe, so `done` will never arrive). Identity-checked
|
||||||
@@ -298,77 +162,36 @@ export class AiChatStreamRegistryService implements OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attach to a run's stream from the client's step frontier `n` (its persisted
|
* Attach to a run's stream. Async only for the phase-2 Redis seam — the body
|
||||||
* `stepsPersisted`). Async only for the phase-2 Redis seam — the body runs
|
* runs synchronously so the replay snapshot and the subscriber registration
|
||||||
* synchronously so the tail SLICE and the subscriber registration happen in ONE
|
* happen in ONE tick with no await between them (invariant 4): a frame ingested
|
||||||
* tick with no await between them (invariant 4).
|
* concurrently cannot slip into the gap and be lost or duplicated.
|
||||||
*
|
*
|
||||||
* Returns null (-> the caller answers 204) when:
|
* Returns null (-> the caller answers 204) when:
|
||||||
* - there is no entry;
|
* - there is no entry, or it overflowed (replay is gone);
|
||||||
* - the `anchor` does not match this run's assistant id (invariant 6);
|
* - expect=live with an anchor that does not match this run's assistant id
|
||||||
* - the ring does not cover the client's frontier (coverageFloor > n): a hole
|
* (invariant 6: a stripped tab must never replay a FOREIGN run's transcript);
|
||||||
* from overflow, or the client's seed simply lagged behind a rotation. The
|
* - the run finished and the caller did not expect a live tail.
|
||||||
* client then refetches (a larger n) and re-attaches.
|
* A finished run with expect=live yields a replay-only attachment (no
|
||||||
*
|
* subscriber registered). Otherwise a paused subscriber is registered and the
|
||||||
* Otherwise the attachment's `replay` is a synthetic `start` frame (the run-fact
|
* caller replays `replay`, then calls start() to drain and go live.
|
||||||
* on re-attach) followed by the buffered tail filtered to `stamp >= n`. For a
|
|
||||||
* FINISHED run this is replay-only (no subscriber) and ends after the replay —
|
|
||||||
* with n = N_final that tail is just the run's `finish` frame, so the client
|
|
||||||
* closes the stream. For a LIVE run a paused subscriber is registered; the
|
|
||||||
* caller writes the replay (respecting drain) then calls start() to drain the
|
|
||||||
* pending frames and go live.
|
|
||||||
*/
|
*/
|
||||||
async attach(
|
async attach(
|
||||||
chatId: string,
|
chatId: string,
|
||||||
|
expectLive: boolean,
|
||||||
anchor: string | undefined,
|
anchor: string | undefined,
|
||||||
// The client's persisted step frontier. `null` = a NOT-tail-aware client (no
|
|
||||||
// `n` query param) — a legacy/parameterless tab that expects the old
|
|
||||||
// "finished -> 204 -> poll" contract; distinct from `0` (a tail-aware client
|
|
||||||
// with nothing persisted yet).
|
|
||||||
n: number | null,
|
|
||||||
cb: RunStreamCallbacks,
|
cb: RunStreamCallbacks,
|
||||||
): Promise<RunStreamAttachment | null> {
|
): Promise<RunStreamAttachment | null> {
|
||||||
const entry = this.entries.get(chatId);
|
const entry = this.entries.get(chatId);
|
||||||
if (!entry) return null;
|
if (!entry || entry.overflowed) return null;
|
||||||
// Invariant 6: cross-run replay is forbidden. Before bind, assistantMessageId
|
// Invariant 6: cross-run replay is forbidden. Before bind, assistantMessageId
|
||||||
// is undefined and mismatches any anchor -> 204 -> client restore+poll path.
|
// is undefined and mismatches any anchor -> 204 -> client restore+poll path.
|
||||||
if (anchor && entry.assistantMessageId !== anchor) return null;
|
if (expectLive && anchor && entry.assistantMessageId !== anchor) return null;
|
||||||
// #491 regression guard (#137/#161 dup): a NOT-tail-aware client (no `n`)
|
if (entry.finished && !expectLive) return null;
|
||||||
// resuming a FINISHED run must 204 and poll — the old `finished && !expectLive`
|
if (entry.finished && expectLive) {
|
||||||
// gate. Without this, a missing `n` collapsing to frontier 0 would serve the
|
|
||||||
// WHOLE tail of a finished, NON-rotated run (coverageFloor 0), and a
|
|
||||||
// parameterless client that never stripped its transcript would APPEND that
|
|
||||||
// full replay onto the steps it already shows -> duplicated text. A tail-aware
|
|
||||||
// client (n present, incl. n=0) still gets the tail past its frontier.
|
|
||||||
if (entry.finished && n === null) return null;
|
|
||||||
// A finished entry with NOTHING in the ring (aborted before the first frame,
|
|
||||||
// or fully overflowed) has no tail to deliver -> 204 -> the client polls.
|
|
||||||
if (entry.finished && entry.frames.length === 0) return null;
|
|
||||||
// A LIVE run with no `n` (legacy parameterless) replays from step 0 (the old
|
|
||||||
// behavior); a tail-aware client resumes from its frontier.
|
|
||||||
const frontier = n ?? 0;
|
|
||||||
const floor = this.coverageFloor(entry);
|
|
||||||
if (floor > frontier) {
|
|
||||||
this.logger.warn(
|
|
||||||
`run-stream attach gap for run=${entry.runId}: coverageFloor=${floor} ` +
|
|
||||||
`> client frontier=${frontier} -> 204 (client refetches + re-attaches)`,
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startFrame = this.buildStartFrame(chatId, entry.runId);
|
|
||||||
const sliceTail = (): string[] => {
|
|
||||||
const out: string[] = [startFrame];
|
|
||||||
for (let i = 0; i < entry.frames.length; i++) {
|
|
||||||
if (entry.stamps[i] >= frontier) out.push(entry.frames[i]);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (entry.finished) {
|
|
||||||
// Replay-only: the run is done, no subscriber is registered.
|
// Replay-only: the run is done, no subscriber is registered.
|
||||||
return {
|
return {
|
||||||
replay: sliceTail(),
|
replay: entry.frames.slice(),
|
||||||
finished: true,
|
finished: true,
|
||||||
start: () => undefined,
|
start: () => undefined,
|
||||||
unsubscribe: () => undefined,
|
unsubscribe: () => undefined,
|
||||||
@@ -383,12 +206,15 @@ export class AiChatStreamRegistryService implements OnModuleDestroy {
|
|||||||
pendingBytes: 0,
|
pendingBytes: 0,
|
||||||
overflowed: false,
|
overflowed: false,
|
||||||
pendingEnd: false,
|
pendingEnd: false,
|
||||||
minStamp: frontier,
|
|
||||||
};
|
};
|
||||||
// Register + snapshot in the SAME synchronous block (invariant 4). No await
|
|
||||||
// separates them, so a concurrently ingested frame cannot be lost/duplicated.
|
|
||||||
entry.subscribers.add(sub);
|
entry.subscribers.add(sub);
|
||||||
const replay = sliceTail();
|
// Snapshot in the SAME synchronous block as the registration (invariant 4).
|
||||||
|
const replay = entry.frames.slice();
|
||||||
|
// CONTRACT: the caller MUST call start() in the SAME tick as this attach()
|
||||||
|
// returns — no await between them. While a subscriber is paused, every frame
|
||||||
|
// is buffered in sub.pending; a delayed start() lets a whole run accumulate
|
||||||
|
// there. The pendingBytes cap (see ingestFrame) is the structural backstop if
|
||||||
|
// that contract is ever broken (e.g. the phase-2 Redis await seam).
|
||||||
return {
|
return {
|
||||||
replay,
|
replay,
|
||||||
finished: false,
|
finished: false,
|
||||||
@@ -437,83 +263,24 @@ export class AiChatStreamRegistryService implements OnModuleDestroy {
|
|||||||
this.entries.clear();
|
this.entries.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The synthetic `start` frame the tail is prefixed with — the source of the
|
/** Buffer + fan-out a single frame. See invariant/overflow semantics inline. */
|
||||||
* run-fact (runId/chatId) on re-attach. A `start` frame does NOT reset the
|
|
||||||
* client's message parts (ai@6.0.207 createStreamingUIMessageState), so it is
|
|
||||||
* safe to prepend even when the sliced tail begins mid-message. */
|
|
||||||
private buildStartFrame(chatId: string, runId: string): string {
|
|
||||||
return `data: ${JSON.stringify({
|
|
||||||
type: 'start',
|
|
||||||
messageMetadata: { runId, chatId },
|
|
||||||
})}\n\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The smallest step FULLY present in the ring: its smallest retained stamp, or
|
|
||||||
* (when the leading step was only partially evicted by an overflow) one past it.
|
|
||||||
* When the ring is empty it is the current step (only the live tail is coming).
|
|
||||||
* An attach at frontier `n` is covered ⟺ coverageFloor <= n.
|
|
||||||
*/
|
|
||||||
private coverageFloor(entry: Entry): number {
|
|
||||||
// Empty ring: only the live tail is coming. The floor is the current step,
|
|
||||||
// but never below persistedFloor — a confirmed persist can rotate the ring
|
|
||||||
// empty while currentStamp still lags a beat behind on another connection, so
|
|
||||||
// max() keeps the invariant STRUCTURAL (a client with n = persistedFloor is
|
|
||||||
// always covered) rather than timing-dependent.
|
|
||||||
if (entry.frames.length === 0)
|
|
||||||
return Math.max(entry.currentStamp, entry.persistedFloor);
|
|
||||||
const min = entry.stamps[0];
|
|
||||||
return entry.overflowThroughStamp >= min ? min + 1 : min;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Buffer (step-stamped) + fan-out a single frame. The stamp is the number of
|
|
||||||
* finish-step frames seen BEFORE this one; a finish-step frame carries the
|
|
||||||
* current value and THEN increments the counter (so its stamp equals the 0-based
|
|
||||||
* index of the step it closes). Only frames at/above persistedFloor are buffered
|
|
||||||
* (already-persisted steps are on disk); the ring is then trimmed to the byte
|
|
||||||
* cap, an unsafe eviction opening a gap. Fan-out is always live (filtered per
|
|
||||||
* subscriber by its frontier).
|
|
||||||
*/
|
|
||||||
private ingestFrame(entry: Entry, frame: string): void {
|
private ingestFrame(entry: Entry, frame: string): void {
|
||||||
const size = Buffer.byteLength(frame);
|
entry.bytes += Buffer.byteLength(frame);
|
||||||
const stamp = entry.currentStamp;
|
if (!entry.overflowed) {
|
||||||
if (frame.startsWith(FINISH_STEP_FRAME_PREFIX)) {
|
|
||||||
entry.currentStamp = stamp + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Buffer for replay only if this step is not already persisted+rotated away.
|
|
||||||
if (stamp >= entry.persistedFloor) {
|
|
||||||
entry.frames.push(frame);
|
entry.frames.push(frame);
|
||||||
entry.stamps.push(stamp);
|
if (entry.bytes > RUN_STREAM_MAX_BUFFER_BYTES) {
|
||||||
entry.bytes += size;
|
// The crossing frame was already counted AND (below) fanned out; only the
|
||||||
// Enforce the ring cap. Evicting a not-yet-persisted frame (stamp >=
|
// replay buffer is dropped. After overflow no more frames are buffered,
|
||||||
// persistedFloor) opens a GAP; a leftover persisted frame (< floor) is a
|
// but live fan-out continues.
|
||||||
// safe drop. Keep evicting until the ring is back under the cap.
|
entry.overflowed = true;
|
||||||
while (entry.bytes > this.maxBufferBytes && entry.frames.length > 0) {
|
entry.frames = [];
|
||||||
const evStamp = entry.stamps[0];
|
this.logger.warn(
|
||||||
entry.bytes -= Buffer.byteLength(entry.frames[0]);
|
`run-stream buffer overflow for run=${entry.runId}; ` +
|
||||||
entry.frames.shift();
|
`late attach will 204 until the run ends`,
|
||||||
entry.stamps.shift();
|
);
|
||||||
if (evStamp >= entry.persistedFloor) {
|
|
||||||
if (evStamp > entry.overflowThroughStamp)
|
|
||||||
entry.overflowThroughStamp = evStamp;
|
|
||||||
if (!entry.overflowed) {
|
|
||||||
entry.overflowed = true;
|
|
||||||
this.logger.warn(
|
|
||||||
`run-stream ring overflow for run=${entry.runId}: an un-persisted ` +
|
|
||||||
`step was evicted to stay under ${this.maxBufferBytes}B; a late ` +
|
|
||||||
`attach at an evicted step will 204 until a later persist confirms`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fan out live, filtered to each subscriber's frontier (a subscriber only
|
|
||||||
// wants the tail past the step it already persisted).
|
|
||||||
for (const sub of entry.subscribers) {
|
for (const sub of entry.subscribers) {
|
||||||
if (stamp < sub.minStamp) continue;
|
|
||||||
if (sub.started) {
|
if (sub.started) {
|
||||||
try {
|
try {
|
||||||
sub.onFrame(frame);
|
sub.onFrame(frame);
|
||||||
@@ -522,12 +289,12 @@ export class AiChatStreamRegistryService implements OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
sub.pending.push(frame);
|
sub.pending.push(frame);
|
||||||
sub.pendingBytes += size;
|
sub.pendingBytes += Buffer.byteLength(frame);
|
||||||
if (sub.pendingBytes > this.subscriberMaxBufferedBytes) {
|
if (sub.pendingBytes > SUBSCRIBER_MAX_BUFFERED_BYTES) {
|
||||||
// The paused subscriber's buffer overflowed — only possible if start()
|
// The paused subscriber's buffer overflowed — only possible if start()
|
||||||
// was delayed (the controller's drain-respecting tail write, or the
|
// was delayed past the same-tick contract (the phase-2 await seam).
|
||||||
// phase-2 await seam). Drop it rather than buffer the whole run; on
|
// Drop it rather than buffer the whole run; on start() it degrades to an
|
||||||
// start() it degrades to an immediate end (a 204-equivalent).
|
// immediate end (a 204-equivalent) instead of replaying a partial.
|
||||||
sub.overflowed = true;
|
sub.overflowed = true;
|
||||||
sub.pending = [];
|
sub.pending = [];
|
||||||
entry.subscribers.delete(sub);
|
entry.subscribers.delete(sub);
|
||||||
|
|||||||
@@ -1,27 +1,19 @@
|
|||||||
import {
|
import {
|
||||||
AiChatStreamRegistryService,
|
AiChatStreamRegistryService,
|
||||||
AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES,
|
RUN_STREAM_MAX_BUFFER_BYTES,
|
||||||
RUN_STREAM_RETAIN_FINISHED_MS,
|
RUN_STREAM_RETAIN_FINISHED_MS,
|
||||||
|
SUBSCRIBER_MAX_BUFFERED_BYTES,
|
||||||
RunStreamCallbacks,
|
RunStreamCallbacks,
|
||||||
} from './ai-chat-stream-registry.service';
|
} from './ai-chat-stream-registry.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit tests for the in-memory run-stream registry (#184 phase 1.5, step-aligned
|
* Unit tests for the in-memory run-stream registry (#184 phase 1.5). The registry
|
||||||
* retention #491). The registry is the whole of the resumable-transport contract:
|
* is the whole of the resumable-transport contract: replay ordering, paused ->
|
||||||
* step-stamped retention, tail-only attach at the client's frontier N, the
|
* live hand-off, overflow, retention, the anchor check (invariant 6), and the
|
||||||
* confirmed-persist ring rotation (and the anti-inversion rule), the memory bound,
|
* mirror-the-done-path replace semantics (invariant 3). Every enumerated case in
|
||||||
* the overflow gap, paused -> live hand-off, retention, the anchor check
|
* the issue's task 1.5 has a test here.
|
||||||
* (invariant 6), and the mirror-the-done-path replace semantics (invariant 3).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Real ai@6 UI-message-stream SSE frames are `data: {json}\n\n`, one part each.
|
|
||||||
const sse = (part: Record<string, unknown>): string =>
|
|
||||||
`data: ${JSON.stringify(part)}\n\n`;
|
|
||||||
const finishStep = (): string => sse({ type: 'finish-step' });
|
|
||||||
const textDelta = (id: string, delta: string): string =>
|
|
||||||
sse({ type: 'text-delta', id, delta });
|
|
||||||
const finish = (): string => sse({ type: 'finish' });
|
|
||||||
|
|
||||||
// A ReadableStream whose frames the test pushes explicitly, plus close/error.
|
// A ReadableStream whose frames the test pushes explicitly, plus close/error.
|
||||||
function makePushStream(): {
|
function makePushStream(): {
|
||||||
stream: ReadableStream<string>;
|
stream: ReadableStream<string>;
|
||||||
@@ -66,9 +58,6 @@ function collector(): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// The tail past the synthetic start frame (replay[0] is always the start frame).
|
|
||||||
const tail = (replay: string[]): string[] => replay.slice(1);
|
|
||||||
|
|
||||||
describe('AiChatStreamRegistryService', () => {
|
describe('AiChatStreamRegistryService', () => {
|
||||||
const CHAT = 'chat-1';
|
const CHAT = 'chat-1';
|
||||||
let registry: AiChatStreamRegistryService;
|
let registry: AiChatStreamRegistryService;
|
||||||
@@ -82,21 +71,7 @@ describe('AiChatStreamRegistryService', () => {
|
|||||||
registry.onModuleDestroy();
|
registry.onModuleDestroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prepends a synthetic start frame carrying { runId, chatId }', async () => {
|
it('replays frames in arrival order (live attach)', async () => {
|
||||||
registry.open(CHAT, 'run-1');
|
|
||||||
const src = makePushStream();
|
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
|
||||||
src.push('a');
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const c = collector();
|
|
||||||
const att = (await registry.attach(CHAT, 'assist-1', 0, c.cb))!;
|
|
||||||
const start = JSON.parse(att.replay[0].replace(/^data: /, '').trim());
|
|
||||||
expect(start.type).toBe('start');
|
|
||||||
expect(start.messageMetadata).toEqual({ runId: 'run-1', chatId: CHAT });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('replays the buffered tail (from frontier 0) in arrival order (live attach)', async () => {
|
|
||||||
registry.open(CHAT, 'run-1');
|
registry.open(CHAT, 'run-1');
|
||||||
const src = makePushStream();
|
const src = makePushStream();
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||||
@@ -106,13 +81,13 @@ describe('AiChatStreamRegistryService', () => {
|
|||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
const c = collector();
|
const c = collector();
|
||||||
const att = await registry.attach(CHAT, 'assist-1', 0, c.cb);
|
const att = await registry.attach(CHAT, false, undefined, c.cb);
|
||||||
expect(att).not.toBeNull();
|
expect(att).not.toBeNull();
|
||||||
expect(tail(att!.replay)).toEqual(['a', 'b', 'c']);
|
expect(att!.replay).toEqual(['a', 'b', 'c']);
|
||||||
expect(att!.finished).toBe(false);
|
expect(att!.finished).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('late attach gets the buffered prefix as tail plus the live tail', async () => {
|
it('late attach gets the full prefix as replay plus the live tail', async () => {
|
||||||
registry.open(CHAT, 'run-1');
|
registry.open(CHAT, 'run-1');
|
||||||
const src = makePushStream();
|
const src = makePushStream();
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||||
@@ -121,16 +96,17 @@ describe('AiChatStreamRegistryService', () => {
|
|||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
const c = collector();
|
const c = collector();
|
||||||
const att = (await registry.attach(CHAT, 'assist-1', 0, c.cb))!;
|
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
|
||||||
expect(tail(att.replay)).toEqual(['a', 'b']);
|
expect(att.replay).toEqual(['a', 'b']);
|
||||||
att.start();
|
att.start();
|
||||||
|
// Live tail arrives after start().
|
||||||
src.push('c');
|
src.push('c');
|
||||||
src.push('d');
|
src.push('d');
|
||||||
await flush();
|
await flush();
|
||||||
expect(c.frames).toEqual(['c', 'd']);
|
expect(c.frames).toEqual(['c', 'd']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a paused subscriber receives frames buffered during pause in order, then live', async () => {
|
it('a paused subscriber receives frames buffered during pause in order, then live (no loss/reorder)', async () => {
|
||||||
registry.open(CHAT, 'run-1');
|
registry.open(CHAT, 'run-1');
|
||||||
const src = makePushStream();
|
const src = makePushStream();
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||||
@@ -138,45 +114,81 @@ describe('AiChatStreamRegistryService', () => {
|
|||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
const c = collector();
|
const c = collector();
|
||||||
const att = (await registry.attach(CHAT, 'assist-1', 0, c.cb))!;
|
// Attach (paused). Frames that arrive BEFORE start() must queue, not drop.
|
||||||
expect(tail(att.replay)).toEqual(['a']);
|
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
|
||||||
|
expect(att.replay).toEqual(['a']);
|
||||||
src.push('b'); // arrives while paused -> pending
|
src.push('b'); // arrives while paused -> pending
|
||||||
src.push('c');
|
src.push('c');
|
||||||
await flush();
|
await flush();
|
||||||
expect(c.frames).toEqual([]); // nothing delivered yet (paused)
|
expect(c.frames).toEqual([]); // nothing delivered yet (paused)
|
||||||
att.start();
|
att.start(); // drains pending in order
|
||||||
expect(c.frames).toEqual(['b', 'c']);
|
expect(c.frames).toEqual(['b', 'c']);
|
||||||
src.push('d');
|
src.push('d'); // now live
|
||||||
await flush();
|
await flush();
|
||||||
expect(c.frames).toEqual(['b', 'c', 'd']);
|
expect(c.frames).toEqual(['b', 'c', 'd']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a run that finishes while a subscriber is paused ends it on start()', async () => {
|
it('a run that finishes while a subscriber is paused ends it on start()', async () => {
|
||||||
registry.open(CHAT, 'run-1');
|
registry.open(CHAT, 'run-1');
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', makePushStream().stream);
|
|
||||||
const c = collector();
|
const c = collector();
|
||||||
const att = (await registry.attach(CHAT, 'assist-1', 0, c.cb))!;
|
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
|
||||||
|
// Terminate the run while the subscriber is still paused.
|
||||||
registry.abortEntry(CHAT, 'run-1');
|
registry.abortEntry(CHAT, 'run-1');
|
||||||
expect(c.ended()).toBe(0); // paused: not ended yet
|
expect(c.ended()).toBe(0); // paused: not ended yet
|
||||||
att.start();
|
att.start();
|
||||||
expect(c.ended()).toBe(1); // start() drains + ends
|
expect(c.ended()).toBe(1); // start() drains + ends
|
||||||
});
|
});
|
||||||
|
|
||||||
it('anchor mismatch returns null (and null before bind sets assistantMessageId)', async () => {
|
it('finished + expect=live returns a replay WITHOUT registering a subscriber', async () => {
|
||||||
|
registry.open(CHAT, 'run-1');
|
||||||
|
const src = makePushStream();
|
||||||
|
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||||
|
src.push('a');
|
||||||
|
src.push('b');
|
||||||
|
src.close();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const c = collector();
|
||||||
|
const att = (await registry.attach(CHAT, true, undefined, c.cb))!;
|
||||||
|
expect(att.finished).toBe(true);
|
||||||
|
expect(att.replay).toEqual(['a', 'b']);
|
||||||
|
// No subscriber registered: start()/unsubscribe are no-ops and the entry has
|
||||||
|
// zero subscribers.
|
||||||
|
const entry = (registry as any).entries.get(CHAT);
|
||||||
|
expect(entry.subscribers.size).toBe(0);
|
||||||
|
att.start();
|
||||||
|
expect(c.frames).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finished WITHOUT expect=live returns null', async () => {
|
||||||
|
registry.open(CHAT, 'run-1');
|
||||||
|
const src = makePushStream();
|
||||||
|
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||||
|
src.push('a');
|
||||||
|
src.close();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const c = collector();
|
||||||
|
expect(await registry.attach(CHAT, false, undefined, c.cb)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('anchor mismatch with expect=live returns null (and null before bind sets assistantMessageId)', async () => {
|
||||||
registry.open(CHAT, 'run-1');
|
registry.open(CHAT, 'run-1');
|
||||||
const c = collector();
|
const c = collector();
|
||||||
// Before bind: assistantMessageId is undefined -> mismatches any anchor.
|
// Before bind: assistantMessageId is undefined -> mismatches any anchor.
|
||||||
expect(await registry.attach(CHAT, 'assist-1', 0, c.cb)).toBeNull();
|
expect(
|
||||||
|
await registry.attach(CHAT, true, 'assist-1', c.cb),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
const src = makePushStream();
|
const src = makePushStream();
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||||
src.push('a');
|
src.push('a');
|
||||||
await flush();
|
await flush();
|
||||||
// Wrong anchor -> null (cross-run replay forbidden, invariant 6).
|
// Wrong anchor -> null (cross-run replay forbidden, invariant 6).
|
||||||
expect(await registry.attach(CHAT, 'other-id', 0, c.cb)).toBeNull();
|
expect(await registry.attach(CHAT, true, 'other-id', c.cb)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('matching anchor attaches', async () => {
|
it('matching anchor with expect=live attaches', async () => {
|
||||||
registry.open(CHAT, 'run-1');
|
registry.open(CHAT, 'run-1');
|
||||||
const src = makePushStream();
|
const src = makePushStream();
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||||
@@ -184,39 +196,73 @@ describe('AiChatStreamRegistryService', () => {
|
|||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
const c = collector();
|
const c = collector();
|
||||||
const att = await registry.attach(CHAT, 'assist-1', 0, c.cb);
|
const att = await registry.attach(CHAT, true, 'assist-1', c.cb);
|
||||||
expect(att).not.toBeNull();
|
expect(att).not.toBeNull();
|
||||||
expect(tail(att!.replay)).toEqual(['a']);
|
expect(att!.replay).toEqual(['a']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a throwing onFrame ejects only that subscriber; the ingest loop stays alive', async () => {
|
it('overflow: attach returns null, but the LIVE subscriber keeps receiving (incl. the crossing frame)', async () => {
|
||||||
registry.open(CHAT, 'run-1');
|
registry.open(CHAT, 'run-1');
|
||||||
const src = makePushStream();
|
const src = makePushStream();
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||||
|
|
||||||
const bad = collector();
|
// A live (started) subscriber attached before the flood.
|
||||||
const badAtt = (await registry.attach(CHAT, 'assist-1', 0, {
|
const c = collector();
|
||||||
onFrame: () => {
|
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
|
||||||
throw new Error('boom');
|
att.start();
|
||||||
},
|
|
||||||
onEnd: bad.cb.onEnd,
|
|
||||||
}))!;
|
|
||||||
badAtt.start();
|
|
||||||
|
|
||||||
const good = collector();
|
// Cap-relative so it survives a buffer-cap change (#430): a quarter-cap frame
|
||||||
const goodAtt = (await registry.attach(CHAT, 'assist-1', 0, good.cb))!;
|
// means 5 frames comfortably exceed the replay cap; the last one crosses.
|
||||||
goodAtt.start();
|
const chunk = 'x'.repeat(Math.floor(RUN_STREAM_MAX_BUFFER_BYTES / 4));
|
||||||
|
for (let i = 0; i < 5; i++) src.push(chunk + i);
|
||||||
src.push('a'); // bad throws on this frame -> ejected
|
|
||||||
src.push('b'); // good still receives both
|
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
const entry = (registry as any).entries.get(CHAT);
|
const entry = (registry as any).entries.get(CHAT);
|
||||||
expect(entry.subscribers.size).toBe(1);
|
expect(entry.overflowed).toBe(true);
|
||||||
expect(good.frames).toEqual(['a', 'b']);
|
expect(entry.bytes).toBeGreaterThan(RUN_STREAM_MAX_BUFFER_BYTES);
|
||||||
|
// The live subscriber received ALL 5 frames, including the crossing one.
|
||||||
|
expect(c.frames).toHaveLength(5);
|
||||||
|
expect(c.frames[4]).toBe(chunk + 4);
|
||||||
|
|
||||||
|
// A NEW attach after overflow gets null (replay buffer is gone).
|
||||||
|
const c2 = collector();
|
||||||
|
expect(await registry.attach(CHAT, false, undefined, c2.cb)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('open() over a LIVE entry ends started subscribers once; a late done never touches the new entry (invariant 3)', async () => {
|
it('a paused subscriber whose pending buffer overflows is dropped and ends on start(); other subscribers keep receiving', async () => {
|
||||||
|
registry.open(CHAT, 'run-1');
|
||||||
|
const src = makePushStream();
|
||||||
|
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||||
|
|
||||||
|
// A: paused (start() deliberately delayed to simulate the phase-2 await seam).
|
||||||
|
const a = collector();
|
||||||
|
const attA = (await registry.attach(CHAT, false, undefined, a.cb))!;
|
||||||
|
// B: live (started) — its delivery must be unaffected by A's overflow.
|
||||||
|
const b = collector();
|
||||||
|
const attB = (await registry.attach(CHAT, false, undefined, b.cb))!;
|
||||||
|
attB.start();
|
||||||
|
|
||||||
|
// Cap-relative so it survives a buffer-cap change (#430): a quarter-of-the-
|
||||||
|
// per-subscriber-cap frame means 5 frames exceed A's paused-pending cap while
|
||||||
|
// B streams every frame live.
|
||||||
|
const chunk = 'x'.repeat(Math.floor(SUBSCRIBER_MAX_BUFFERED_BYTES / 4));
|
||||||
|
for (let i = 0; i < 5; i++) src.push(chunk + i);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const entry = (registry as any).entries.get(CHAT);
|
||||||
|
// A was dropped from the subscriber set on overflow; B (started) remains.
|
||||||
|
expect(entry.subscribers.size).toBe(1);
|
||||||
|
expect(a.frames).toEqual([]); // paused + overflowed: nothing was delivered
|
||||||
|
// B received every frame live (delivery unaffected by A's overflow).
|
||||||
|
expect(b.frames).toHaveLength(5);
|
||||||
|
|
||||||
|
// A's start() (arriving late) degrades to an immediate end, not a partial replay.
|
||||||
|
attA.start();
|
||||||
|
expect(a.frames).toEqual([]);
|
||||||
|
expect(a.ended()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('open() over a LIVE entry ends started subscribers exactly once and a late done does not touch the new entry (invariant 3)', async () => {
|
||||||
registry.open(CHAT, 'run-1');
|
registry.open(CHAT, 'run-1');
|
||||||
const src = makePushStream();
|
const src = makePushStream();
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||||
@@ -224,20 +270,23 @@ describe('AiChatStreamRegistryService', () => {
|
|||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
const c = collector();
|
const c = collector();
|
||||||
const att = (await registry.attach(CHAT, 'assist-1', 0, c.cb))!;
|
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
|
||||||
att.start();
|
att.start(); // started subscriber on run-1
|
||||||
|
|
||||||
|
// run-2 starts on the same chat while run-1's tee is still reading.
|
||||||
registry.open(CHAT, 'run-2');
|
registry.open(CHAT, 'run-2');
|
||||||
expect(c.ended()).toBe(1);
|
expect(c.ended()).toBe(1); // exactly one onEnd from the replace
|
||||||
|
|
||||||
const newEntry = (registry as any).entries.get(CHAT);
|
const newEntry = (registry as any).entries.get(CHAT);
|
||||||
expect(newEntry.runId).toBe('run-2');
|
expect(newEntry.runId).toBe('run-2');
|
||||||
expect(newEntry.finished).toBe(false);
|
expect(newEntry.finished).toBe(false);
|
||||||
|
|
||||||
|
// The old tee now completes: its late done must NOT double-end nor delete the
|
||||||
|
// new entry.
|
||||||
src.push('b');
|
src.push('b');
|
||||||
src.close();
|
src.close();
|
||||||
await flush();
|
await flush();
|
||||||
expect(c.ended()).toBe(1);
|
expect(c.ended()).toBe(1); // still exactly one
|
||||||
const still = (registry as any).entries.get(CHAT);
|
const still = (registry as any).entries.get(CHAT);
|
||||||
expect(still).toBe(newEntry);
|
expect(still).toBe(newEntry);
|
||||||
expect(still.runId).toBe('run-2');
|
expect(still.runId).toBe('run-2');
|
||||||
@@ -250,6 +299,7 @@ describe('AiChatStreamRegistryService', () => {
|
|||||||
src.push('a');
|
src.push('a');
|
||||||
await flush();
|
await flush();
|
||||||
const entry = (registry as any).entries.get(CHAT);
|
const entry = (registry as any).entries.get(CHAT);
|
||||||
|
// Frames were NOT ingested (bind bailed), assistantMessageId untouched.
|
||||||
expect(entry.frames).toEqual([]);
|
expect(entry.frames).toEqual([]);
|
||||||
expect(entry.assistantMessageId).toBeUndefined();
|
expect(entry.assistantMessageId).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -260,276 +310,32 @@ describe('AiChatStreamRegistryService', () => {
|
|||||||
const entry = (registry as any).entries.get(CHAT);
|
const entry = (registry as any).entries.get(CHAT);
|
||||||
expect(entry.finished).toBe(false);
|
expect(entry.finished).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
it('a throwing onFrame ejects only that subscriber; the ingest loop stays alive', async () => {
|
||||||
* #491 step-stamped retention: the boundary detector, tail-only slicing at the
|
|
||||||
* client's frontier N, the confirmed-persist rotation (+ anti-inversion), the
|
|
||||||
* overflow gap, the memory bound, and the finished-retained tail. All observable
|
|
||||||
* against the REAL registry driven through open/bind/ingest.
|
|
||||||
*/
|
|
||||||
describe('AiChatStreamRegistryService step-aligned retention (#491)', () => {
|
|
||||||
const CHAT = 'chat-s';
|
|
||||||
let registry: AiChatStreamRegistryService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
registry = new AiChatStreamRegistryService();
|
|
||||||
jest.spyOn((registry as any).logger, 'warn').mockImplementation(() => {});
|
|
||||||
});
|
|
||||||
afterEach(() => registry.onModuleDestroy());
|
|
||||||
|
|
||||||
const entryOf = () => (registry as any).entries.get(CHAT);
|
|
||||||
|
|
||||||
it('stamps frames by finish-step count, aligned with stepsPersisted', async () => {
|
|
||||||
registry.open(CHAT, 'run-1');
|
registry.open(CHAT, 'run-1');
|
||||||
const src = makePushStream();
|
const src = makePushStream();
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||||
// step 0 content, its finish-step, step 1 content, its finish-step, finish.
|
|
||||||
src.push(textDelta('t0', 'a')); // stamp 0
|
|
||||||
src.push(finishStep()); // stamp 0 (the finish-step frame carries the pre value)
|
|
||||||
src.push(textDelta('t1', 'b')); // stamp 1
|
|
||||||
src.push(finishStep()); // stamp 1
|
|
||||||
src.push(finish()); // stamp 2
|
|
||||||
await flush();
|
|
||||||
const e = entryOf();
|
|
||||||
expect(e.stamps).toEqual([0, 0, 1, 1, 2]);
|
|
||||||
expect(e.currentStamp).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does NOT treat a text delta that merely quotes "finish-step" as a boundary', async () => {
|
const bad = collector();
|
||||||
registry.open(CHAT, 'run-1');
|
const badAtt = (await registry.attach(CHAT, false, undefined, {
|
||||||
const src = makePushStream();
|
onFrame: () => {
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
throw new Error('boom');
|
||||||
// A model that literally types "type":"finish-step" — JSON-escaped in the frame.
|
},
|
||||||
src.push(textDelta('t0', '"type":"finish-step"'));
|
onEnd: bad.cb.onEnd,
|
||||||
await flush();
|
}))!;
|
||||||
expect(entryOf().currentStamp).toBe(0); // no false boundary
|
badAtt.start();
|
||||||
});
|
|
||||||
|
|
||||||
it('tail-only: attach at N slices frames with stamp >= N', async () => {
|
const good = collector();
|
||||||
registry.open(CHAT, 'run-1');
|
const goodAtt = (await registry.attach(CHAT, false, undefined, good.cb))!;
|
||||||
const src = makePushStream();
|
goodAtt.start();
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
|
||||||
src.push(textDelta('t0', 'a')); // 0
|
src.push('a'); // bad throws on this frame -> ejected
|
||||||
src.push(finishStep()); // 0
|
src.push('b'); // good still receives both
|
||||||
src.push(textDelta('t1', 'b')); // 1
|
|
||||||
src.push(finishStep()); // 1
|
|
||||||
src.push(textDelta('t2', 'c')); // 2 (in-progress)
|
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
const c = collector();
|
const entry = (registry as any).entries.get(CHAT);
|
||||||
// Client persisted 2 steps -> wants the tail from step 2.
|
expect(entry.subscribers.size).toBe(1); // bad ejected, good remains
|
||||||
const att = (await registry.attach(CHAT, 'assist-1', 2, c.cb))!;
|
expect(good.frames).toEqual(['a', 'b']);
|
||||||
expect(tail(att.replay)).toEqual([textDelta('t2', 'c')]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('attach in the MIDDLE of a step (N between finish-steps) slices from that step', async () => {
|
|
||||||
registry.open(CHAT, 'run-1');
|
|
||||||
const src = makePushStream();
|
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
|
||||||
src.push(textDelta('t0', 'a')); // 0
|
|
||||||
src.push(finishStep()); // 0
|
|
||||||
src.push(textDelta('t1', 'b1')); // 1
|
|
||||||
src.push(textDelta('t1', 'b2')); // 1 (still step 1, no finish-step yet)
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const c = collector();
|
|
||||||
const att = (await registry.attach(CHAT, 'assist-1', 1, c.cb))!;
|
|
||||||
// Step 0's frames are dropped from the tail; the whole in-progress step 1 is kept.
|
|
||||||
expect(tail(att.replay)).toEqual([textDelta('t1', 'b1'), textDelta('t1', 'b2')]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rotates the ring ONLY on a confirmed persist (drops stamp < N)', async () => {
|
|
||||||
registry.open(CHAT, 'run-1');
|
|
||||||
const src = makePushStream();
|
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
|
||||||
src.push(textDelta('t0', 'a')); // 0
|
|
||||||
src.push(finishStep()); // 0
|
|
||||||
src.push(textDelta('t1', 'b')); // 1
|
|
||||||
await flush();
|
|
||||||
expect(entryOf().stamps).toEqual([0, 0, 1]);
|
|
||||||
|
|
||||||
// Confirm step 0 persisted (stepsPersisted = 1) -> drop stamp < 1.
|
|
||||||
registry.confirmPersistedStep(CHAT, 'run-1', 1);
|
|
||||||
expect(entryOf().stamps).toEqual([1]);
|
|
||||||
expect(entryOf().persistedFloor).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('persist FAILED but the ring still fits -> attach SUCCEEDS and the tail includes step N', async () => {
|
|
||||||
registry.open(CHAT, 'run-1');
|
|
||||||
const src = makePushStream();
|
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
|
||||||
src.push(textDelta('t0', 'a')); // 0
|
|
||||||
src.push(finishStep()); // 0
|
|
||||||
src.push(textDelta('t1', 'b')); // 1 (step 1's persist FAILED -> no confirm)
|
|
||||||
await flush();
|
|
||||||
// No confirmPersistedStep for step 1: the ring still holds step 1.
|
|
||||||
|
|
||||||
const c = collector();
|
|
||||||
// Client's last successful persist was step 0 -> stepsPersisted = 1.
|
|
||||||
const att = await registry.attach(CHAT, 'assist-1', 1, c.cb);
|
|
||||||
expect(att).not.toBeNull();
|
|
||||||
expect(tail(att!.replay)).toEqual([textDelta('t1', 'b')]); // includes step 1
|
|
||||||
});
|
|
||||||
|
|
||||||
it('persist failed AND the ring overflowed past N -> 204 (coverage gap)', async () => {
|
|
||||||
registry.open(CHAT, 'run-1');
|
|
||||||
const src = makePushStream();
|
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
|
||||||
// Step 0: a fat step that blows past the cap with NO persist confirmation.
|
|
||||||
const big = 'x'.repeat(Math.floor(AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES / 2));
|
|
||||||
src.push(textDelta('t0', big)); // 0
|
|
||||||
src.push(textDelta('t0', big)); // 0
|
|
||||||
src.push(textDelta('t0', big)); // 0 -> overflow evicts stamp-0 frames
|
|
||||||
await flush();
|
|
||||||
const e = entryOf();
|
|
||||||
expect(e.overflowed).toBe(true);
|
|
||||||
expect(e.bytes).toBeLessThanOrEqual(registry.maxBufferBytes);
|
|
||||||
|
|
||||||
// A client at frontier 0 falls at/below an evicted step -> gap -> null.
|
|
||||||
const c = collector();
|
|
||||||
expect(await registry.attach(CHAT, 'assist-1', 0, c.cb)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stale N (client seed lagged behind a rotation) -> 204; after a refetch (larger N) -> success', async () => {
|
|
||||||
registry.open(CHAT, 'run-1');
|
|
||||||
const src = makePushStream();
|
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
|
||||||
src.push(textDelta('t0', 'a')); // 0
|
|
||||||
src.push(finishStep()); // 0
|
|
||||||
src.push(textDelta('t1', 'b')); // 1
|
|
||||||
src.push(finishStep()); // 1
|
|
||||||
src.push(textDelta('t2', 'c')); // 2
|
|
||||||
await flush();
|
|
||||||
// Server confirmed steps 0 and 1 -> rotate away stamp < 2.
|
|
||||||
registry.confirmPersistedStep(CHAT, 'run-1', 2);
|
|
||||||
expect(entryOf().stamps).toEqual([2]);
|
|
||||||
|
|
||||||
// A client whose seed still says stepsPersisted = 1 -> below minStamp -> 204.
|
|
||||||
const stale = collector();
|
|
||||||
expect(await registry.attach(CHAT, 'assist-1', 1, stale.cb)).toBeNull();
|
|
||||||
|
|
||||||
// It refetches (now stepsPersisted = 2) and re-attaches -> success.
|
|
||||||
const fresh = collector();
|
|
||||||
const att = await registry.attach(CHAT, 'assist-1', 2, fresh.cb);
|
|
||||||
expect(att).not.toBeNull();
|
|
||||||
expect(tail(att!.replay)).toEqual([textDelta('t2', 'c')]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('overflow gap CLEARS once a later persist rotates out the holey steps', async () => {
|
|
||||||
registry.open(CHAT, 'run-1');
|
|
||||||
const src = makePushStream();
|
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
|
||||||
const big = 'x'.repeat(Math.floor(AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES / 2));
|
|
||||||
src.push(textDelta('t0', big)); // 0
|
|
||||||
src.push(textDelta('t0', big)); // 0
|
|
||||||
src.push(finishStep()); // 0 (still stamp 0)
|
|
||||||
src.push(textDelta('t1', 'small')); // 1
|
|
||||||
src.push(finishStep()); // 1
|
|
||||||
src.push(textDelta('t2', 'c')); // 2
|
|
||||||
await flush();
|
|
||||||
expect(entryOf().overflowed).toBe(true);
|
|
||||||
|
|
||||||
// Late persist confirms steps 0..1 -> rotates out the holey step-0 frames.
|
|
||||||
registry.confirmPersistedStep(CHAT, 'run-1', 2);
|
|
||||||
// A client at frontier 2 is now cleanly covered (the hole was below it).
|
|
||||||
const c = collector();
|
|
||||||
const att = await registry.attach(CHAT, 'assist-1', 2, c.cb);
|
|
||||||
expect(att).not.toBeNull();
|
|
||||||
expect(tail(att!.replay)).toEqual([textDelta('t2', 'c')]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('finished-retained + N = N_final -> empty tail plus the finish frame', async () => {
|
|
||||||
registry.open(CHAT, 'run-1');
|
|
||||||
const src = makePushStream();
|
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
|
||||||
src.push(textDelta('t0', 'a')); // 0
|
|
||||||
src.push(finishStep()); // 0
|
|
||||||
src.push(finish()); // 1 (N_final = 1)
|
|
||||||
src.close();
|
|
||||||
await flush();
|
|
||||||
// The last step's per-step persist confirmed stepsPersisted = 1.
|
|
||||||
registry.confirmPersistedStep(CHAT, 'run-1', 1);
|
|
||||||
|
|
||||||
const c = collector();
|
|
||||||
const att = (await registry.attach(CHAT, 'assist-1', 1, c.cb))!;
|
|
||||||
expect(att.finished).toBe(true);
|
|
||||||
// Empty step tail; just the finish frame so the client's SDK closes the stream.
|
|
||||||
expect(tail(att.replay)).toEqual([finish()]);
|
|
||||||
// No subscriber registered for a finished run.
|
|
||||||
expect(entryOf().subscribers.size).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#491 regression (#137/#161 dup): a PARAMETERLESS attach (n=null) to a finished NON-rotated run -> 204, but n=0 still gets the tail', async () => {
|
|
||||||
// A finished, non-rotated run: frames present, coverageFloor 0. A missing `n`
|
|
||||||
// (null — a legacy/parameterless tab that never stripped its transcript) must
|
|
||||||
// 204 -> poll, NOT receive the whole tail it would append (duplicate). A
|
|
||||||
// tail-aware client (n=0 present) still resumes.
|
|
||||||
registry.open(CHAT, 'run-1');
|
|
||||||
const src = makePushStream();
|
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
|
||||||
src.push(textDelta('t0', 'a')); // 0
|
|
||||||
src.push(finishStep()); // 0
|
|
||||||
src.push(finish()); // 1
|
|
||||||
src.close();
|
|
||||||
await flush();
|
|
||||||
// NOT rotated (no confirmPersistedStep) -> stamps[0]=0, coverageFloor=0.
|
|
||||||
// MUTATION-VERIFY: revert the `finished && n === null -> null` gate (default n
|
|
||||||
// to 0) and the parameterless attach below serves the full tail instead of 204.
|
|
||||||
expect(await registry.attach(CHAT, 'assist-1', null, collector().cb)).toBeNull();
|
|
||||||
// A tail-aware client at frontier 0 IS served (the distinction: null != 0).
|
|
||||||
const tailAware = await registry.attach(CHAT, 'assist-1', 0, collector().cb);
|
|
||||||
expect(tailAware).not.toBeNull();
|
|
||||||
expect(tailAware!.finished).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('confirmPersistedStep is monotonic and identity-checked', async () => {
|
|
||||||
registry.open(CHAT, 'run-1');
|
|
||||||
const src = makePushStream();
|
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
|
||||||
src.push(textDelta('t0', 'a'));
|
|
||||||
src.push(finishStep());
|
|
||||||
src.push(textDelta('t1', 'b'));
|
|
||||||
await flush();
|
|
||||||
registry.confirmPersistedStep(CHAT, 'run-1', 1);
|
|
||||||
expect(entryOf().persistedFloor).toBe(1);
|
|
||||||
// A stale lower count is ignored.
|
|
||||||
registry.confirmPersistedStep(CHAT, 'run-1', 0);
|
|
||||||
expect(entryOf().persistedFloor).toBe(1);
|
|
||||||
// A foreign runId is ignored.
|
|
||||||
registry.confirmPersistedStep(CHAT, 'WRONG', 5);
|
|
||||||
expect(entryOf().persistedFloor).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('MEMORY BOUND: 5 parallel marathon runs each stream well past 32MB; each ring stays <= the cap', async () => {
|
|
||||||
const cap = registry.maxBufferBytes;
|
|
||||||
const chats = ['m0', 'm1', 'm2', 'm3', 'm4'];
|
|
||||||
const srcs = chats.map((chat) => {
|
|
||||||
registry.open(chat, `run-${chat}`);
|
|
||||||
const s = makePushStream();
|
|
||||||
registry.bind(chat, `run-${chat}`, `assist-${chat}`, s.stream);
|
|
||||||
return s;
|
|
||||||
});
|
|
||||||
// ~256KB frames; 160 per chat = 40MB streamed each, well past the old 32MB.
|
|
||||||
// Interleave a finish-step every 8 frames so steps advance realistically. No
|
|
||||||
// persist confirmation -> the ONLY thing keeping memory bounded is the cap.
|
|
||||||
const frame = 'y'.repeat(256 * 1024);
|
|
||||||
for (let batch = 0; batch < 20; batch++) {
|
|
||||||
for (let i = 0; i < 8; i++) {
|
|
||||||
for (const s of srcs) s.push(textDelta('t', frame));
|
|
||||||
}
|
|
||||||
for (const s of srcs) s.push(finishStep());
|
|
||||||
await flush(); // drain the pump so queues never hold a whole run
|
|
||||||
}
|
|
||||||
let total = 0;
|
|
||||||
for (const chat of chats) {
|
|
||||||
const e = (registry as any).entries.get(chat);
|
|
||||||
expect(e.bytes).toBeLessThanOrEqual(cap);
|
|
||||||
total += e.bytes;
|
|
||||||
}
|
|
||||||
// Total retained across all 5 runs is bounded by 5x the per-run cap — the old
|
|
||||||
// registry would have retained ~5x40MB = 200MB here.
|
|
||||||
expect(total).toBeLessThanOrEqual(cap * chats.length);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -555,7 +361,7 @@ describe('AiChatStreamRegistryService retention timers', () => {
|
|||||||
|
|
||||||
it('a finished entry is removed after the retention window', () => {
|
it('a finished entry is removed after the retention window', () => {
|
||||||
registry.open(CHAT, 'run-1');
|
registry.open(CHAT, 'run-1');
|
||||||
registry.abortEntry(CHAT, 'run-1');
|
registry.abortEntry(CHAT, 'run-1'); // finalize -> retention armed
|
||||||
expect((registry as any).entries.get(CHAT)).toBeDefined();
|
expect((registry as any).entries.get(CHAT)).toBeDefined();
|
||||||
jest.advanceTimersByTime(RUN_STREAM_RETAIN_FINISHED_MS + 1);
|
jest.advanceTimersByTime(RUN_STREAM_RETAIN_FINISHED_MS + 1);
|
||||||
expect((registry as any).entries.get(CHAT)).toBeUndefined();
|
expect((registry as any).entries.get(CHAT)).toBeUndefined();
|
||||||
@@ -563,18 +369,20 @@ describe('AiChatStreamRegistryService retention timers', () => {
|
|||||||
|
|
||||||
it('retention deletes ONLY its own entry (invariant 2)', () => {
|
it('retention deletes ONLY its own entry (invariant 2)', () => {
|
||||||
registry.open(CHAT, 'run-1');
|
registry.open(CHAT, 'run-1');
|
||||||
registry.abortEntry(CHAT, 'run-1');
|
registry.abortEntry(CHAT, 'run-1'); // arm retention for entry A
|
||||||
|
// Simulate the race where the key was replaced without clearing A's timer.
|
||||||
const sentinel = { marker: true };
|
const sentinel = { marker: true };
|
||||||
(registry as any).entries.set(CHAT, sentinel);
|
(registry as any).entries.set(CHAT, sentinel);
|
||||||
jest.advanceTimersByTime(RUN_STREAM_RETAIN_FINISHED_MS + 1);
|
jest.advanceTimersByTime(RUN_STREAM_RETAIN_FINISHED_MS + 1);
|
||||||
|
// A's timer saw entries.get(CHAT) !== A, so it did NOT delete the successor.
|
||||||
expect((registry as any).entries.get(CHAT)).toBe(sentinel);
|
expect((registry as any).entries.get(CHAT)).toBe(sentinel);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('open() over a retained entry clears its timer and the successor survives', () => {
|
it('open() over a retained entry clears its timer and the successor survives', () => {
|
||||||
registry.open(CHAT, 'run-1');
|
registry.open(CHAT, 'run-1');
|
||||||
registry.abortEntry(CHAT, 'run-1');
|
registry.abortEntry(CHAT, 'run-1'); // retained, timer armed
|
||||||
const clearSpy = jest.spyOn(global, 'clearTimeout');
|
const clearSpy = jest.spyOn(global, 'clearTimeout');
|
||||||
registry.open(CHAT, 'run-2');
|
registry.open(CHAT, 'run-2'); // must clear run-1's retain timer
|
||||||
expect(clearSpy).toHaveBeenCalled();
|
expect(clearSpy).toHaveBeenCalled();
|
||||||
jest.advanceTimersByTime(RUN_STREAM_RETAIN_FINISHED_MS + 1);
|
jest.advanceTimersByTime(RUN_STREAM_RETAIN_FINISHED_MS + 1);
|
||||||
const entry = (registry as any).entries.get(CHAT);
|
const entry = (registry as any).entries.get(CHAT);
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
import {
|
|
||||||
ConflictException,
|
|
||||||
Logger,
|
|
||||||
ServiceUnavailableException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { AiChatService } from './ai-chat.service';
|
|
||||||
import { RunAlreadyActiveError } from './ai-chat-run.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fail-fast guard for beginRun failures (#486, commit 4).
|
|
||||||
*
|
|
||||||
* When runHooks.begin() rejects for a reason OTHER than RunAlreadyActiveError
|
|
||||||
* (e.g. a DB-pool blip), the turn must NOT continue untracked. The old code
|
|
||||||
* logged and streamed anyway, leaving a run with NO run-row: in autonomous mode
|
|
||||||
* nobody could abort it (/stop can't see it, disconnect doesn't abort it, and the
|
|
||||||
* one-run gate would admit a SECOND run) — an unstoppable invisible run until
|
|
||||||
* restart. The fix throws A_RUN_BEGIN_FAILED (503) BEFORE the first byte and
|
|
||||||
* before the user row is persisted.
|
|
||||||
*
|
|
||||||
* We drive `stream()` directly on a prototype instance wired with only the
|
|
||||||
* collaborators it touches before the throw, so the assertion is on the REAL
|
|
||||||
* control flow, not a mock of it.
|
|
||||||
*/
|
|
||||||
describe('AiChatService beginRun failure (#486)', () => {
|
|
||||||
function makeService(insertSpy: jest.Mock): AiChatService {
|
|
||||||
// Bypass the (heavy) DI constructor: exercise the real stream() method on a
|
|
||||||
// bare prototype instance with just the fields reached before the throw.
|
|
||||||
// `any` because the private `logger` field makes a typed intersection collapse.
|
|
||||||
const svc = Object.create(AiChatService.prototype);
|
|
||||||
svc.aiChatRepo = {
|
|
||||||
// Existing chat -> no insert path; chatId is kept as-is.
|
|
||||||
findById: jest.fn().mockResolvedValue({ id: 'chat1' }),
|
|
||||||
};
|
|
||||||
svc.aiChatMessageRepo = { insert: insertSpy };
|
|
||||||
svc.logger = new Logger('test');
|
|
||||||
return svc as AiChatService;
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseArgs = () => {
|
|
||||||
const write = jest.fn();
|
|
||||||
const res = {
|
|
||||||
raw: { write, writableEnded: false, headersSent: false },
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
user: { id: 'u1' } as never,
|
|
||||||
workspace: { id: 'w1' } as never,
|
|
||||||
sessionId: 's1',
|
|
||||||
// openPage undefined -> resolveOpenPageContext returns null without any DB
|
|
||||||
// call; chatId present -> the existing-chat path.
|
|
||||||
body: { chatId: 'chat1', messages: [] } as never,
|
|
||||||
res: res as never,
|
|
||||||
signal: new AbortController().signal,
|
|
||||||
model: {} as never,
|
|
||||||
role: null,
|
|
||||||
write,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
it('throws A_RUN_BEGIN_FAILED (503) before the first byte and before persisting the user turn', async () => {
|
|
||||||
const insertSpy = jest.fn();
|
|
||||||
const svc = makeService(insertSpy);
|
|
||||||
const { write, ...args } = baseArgs();
|
|
||||||
|
|
||||||
const runHooks = {
|
|
||||||
begin: jest.fn().mockRejectedValue(new Error('DB pool exhausted')),
|
|
||||||
} as never;
|
|
||||||
|
|
||||||
let caught: unknown;
|
|
||||||
try {
|
|
||||||
await svc.stream({ ...args, runHooks });
|
|
||||||
} catch (e) {
|
|
||||||
caught = e;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(caught).toBeInstanceOf(ServiceUnavailableException);
|
|
||||||
const http = caught as ServiceUnavailableException;
|
|
||||||
expect(http.getStatus()).toBe(503);
|
|
||||||
expect(http.getResponse()).toMatchObject({ code: 'A_RUN_BEGIN_FAILED' });
|
|
||||||
|
|
||||||
// Fail-fast: nothing was written to the socket and NO user message row was
|
|
||||||
// persisted, so the turn left no orphan state to clean up.
|
|
||||||
expect(write).not.toHaveBeenCalled();
|
|
||||||
expect(insertSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('still maps a lost-the-race RunAlreadyActiveError to a 409, not A_RUN_BEGIN_FAILED', async () => {
|
|
||||||
const insertSpy = jest.fn();
|
|
||||||
const svc = makeService(insertSpy);
|
|
||||||
const { write, ...args } = baseArgs();
|
|
||||||
|
|
||||||
const runHooks = {
|
|
||||||
begin: jest.fn().mockRejectedValue(new RunAlreadyActiveError('chat1')),
|
|
||||||
} as never;
|
|
||||||
|
|
||||||
let caught: unknown;
|
|
||||||
try {
|
|
||||||
await svc.stream({ ...args, runHooks });
|
|
||||||
} catch (e) {
|
|
||||||
caught = e;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(caught).toBeInstanceOf(ConflictException);
|
|
||||||
expect((caught as ConflictException).getResponse()).toMatchObject({
|
|
||||||
code: 'A_RUN_ALREADY_ACTIVE',
|
|
||||||
});
|
|
||||||
expect(write).not.toHaveBeenCalled();
|
|
||||||
expect(insertSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -8,12 +8,10 @@ import { SUBSCRIBER_MAX_BUFFERED_BYTES } from './ai-chat-stream-registry.service
|
|||||||
import type { User, Workspace } from '@docmost/db/types/entity.types';
|
import type { User, Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wiring spec for the #184 phase 1.5 attach endpoint (tail-only #491)
|
* Wiring spec for the #184 phase 1.5 attach endpoint
|
||||||
* (`GET /ai-chat/runs/:chatId/stream`). Owner-gated via assertOwnedChat; the
|
* (`GET /ai-chat/runs/:chatId/stream`). Owner-gated via assertOwnedChat; the
|
||||||
* registry is mocked so this exercises ONLY the controller's tail-write/live/204/
|
* registry is mocked so this exercises ONLY the controller's replay/live/204/
|
||||||
* cleanup wiring against a fake raw socket. The attach signature is now
|
* cleanup wiring against a fake raw socket. Constructor order is (aiChatService,
|
||||||
* `(chatId, anchor, n, cb)` — the client hands its persisted step frontier `n`
|
|
||||||
* and its assistant row id `anchor`. Constructor order is (aiChatService,
|
|
||||||
* aiChatRunService, aiChatRepo, aiChatMessageRepo, aiTranscription, pageRepo,
|
* aiChatRunService, aiChatRepo, aiChatMessageRepo, aiTranscription, pageRepo,
|
||||||
* streamRegistry, environment).
|
* streamRegistry, environment).
|
||||||
*/
|
*/
|
||||||
@@ -88,8 +86,8 @@ describe('AiChatController attach endpoint (#184 phase 1.5)', () => {
|
|||||||
attach: jest.fn(
|
attach: jest.fn(
|
||||||
(
|
(
|
||||||
_chatId: string,
|
_chatId: string,
|
||||||
|
_live: boolean,
|
||||||
_anchor: string | undefined,
|
_anchor: string | undefined,
|
||||||
_n: number,
|
|
||||||
cb: RunStreamCallbacks,
|
cb: RunStreamCallbacks,
|
||||||
) => {
|
) => {
|
||||||
capturedCb = cb;
|
capturedCb = cb;
|
||||||
@@ -158,7 +156,7 @@ describe('AiChatController attach endpoint (#184 phase 1.5)', () => {
|
|||||||
expect(res.hijack).not.toHaveBeenCalled();
|
expect(res.hijack).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('threads anchor and the numeric frontier n through to the registry', async () => {
|
it('threads expect=live and anchor through to the registry', async () => {
|
||||||
const { controller, streamRegistry } = makeController({
|
const { controller, streamRegistry } = makeController({
|
||||||
chat: owned,
|
chat: owned,
|
||||||
attachment: null,
|
attachment: null,
|
||||||
@@ -167,8 +165,8 @@ describe('AiChatController attach endpoint (#184 phase 1.5)', () => {
|
|||||||
const { req } = makeReq();
|
const { req } = makeReq();
|
||||||
await controller.attachRunStream(
|
await controller.attachRunStream(
|
||||||
'c1',
|
'c1',
|
||||||
|
'live',
|
||||||
'anchor-1',
|
'anchor-1',
|
||||||
'2',
|
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
user,
|
user,
|
||||||
@@ -176,44 +174,13 @@ describe('AiChatController attach endpoint (#184 phase 1.5)', () => {
|
|||||||
);
|
);
|
||||||
expect(streamRegistry.attach).toHaveBeenCalledWith(
|
expect(streamRegistry.attach).toHaveBeenCalledWith(
|
||||||
'c1',
|
'c1',
|
||||||
|
true,
|
||||||
'anchor-1',
|
'anchor-1',
|
||||||
2, // parsed to a number
|
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('#491: an ABSENT/invalid n passes null (not 0) so a finished run 204s (not-tail-aware)', async () => {
|
it('passes expect=false when the query is absent', async () => {
|
||||||
// Distinguishing a MISSING `n` from `n=0` is the #137/#161 dup guard: a
|
|
||||||
// parameterless/legacy tab must be handed null (-> the registry 204s a finished
|
|
||||||
// run) rather than frontier 0 (which would serve a finished non-rotated run's
|
|
||||||
// whole tail). MUTATION-VERIFY: revert to `Number(n) || 0` and this asserts 0.
|
|
||||||
const { controller, streamRegistry } = makeController({
|
|
||||||
chat: owned,
|
|
||||||
attachment: null,
|
|
||||||
});
|
|
||||||
for (const bad of [undefined, '', 'abc']) {
|
|
||||||
streamRegistry.attach.mockClear();
|
|
||||||
const { res } = makeRawRes();
|
|
||||||
const { req } = makeReq();
|
|
||||||
await controller.attachRunStream(
|
|
||||||
'c1',
|
|
||||||
undefined,
|
|
||||||
bad,
|
|
||||||
req,
|
|
||||||
res,
|
|
||||||
user,
|
|
||||||
workspace,
|
|
||||||
);
|
|
||||||
expect(streamRegistry.attach).toHaveBeenCalledWith(
|
|
||||||
'c1',
|
|
||||||
undefined,
|
|
||||||
null,
|
|
||||||
expect.anything(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#491: a PRESENT n=0 passes 0 (tail-aware, distinct from absent)', async () => {
|
|
||||||
const { controller, streamRegistry } = makeController({
|
const { controller, streamRegistry } = makeController({
|
||||||
chat: owned,
|
chat: owned,
|
||||||
attachment: null,
|
attachment: null,
|
||||||
@@ -223,7 +190,7 @@ describe('AiChatController attach endpoint (#184 phase 1.5)', () => {
|
|||||||
await controller.attachRunStream(
|
await controller.attachRunStream(
|
||||||
'c1',
|
'c1',
|
||||||
undefined,
|
undefined,
|
||||||
'0',
|
undefined,
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
user,
|
user,
|
||||||
@@ -231,8 +198,8 @@ describe('AiChatController attach endpoint (#184 phase 1.5)', () => {
|
|||||||
);
|
);
|
||||||
expect(streamRegistry.attach).toHaveBeenCalledWith(
|
expect(streamRegistry.attach).toHaveBeenCalledWith(
|
||||||
'c1',
|
'c1',
|
||||||
|
false,
|
||||||
undefined,
|
undefined,
|
||||||
0,
|
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -278,8 +245,8 @@ describe('AiChatController attach endpoint (#184 phase 1.5)', () => {
|
|||||||
const { req } = makeReq();
|
const { req } = makeReq();
|
||||||
await controller.attachRunStream(
|
await controller.attachRunStream(
|
||||||
'c1',
|
'c1',
|
||||||
|
'live',
|
||||||
'a1',
|
'a1',
|
||||||
'1',
|
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
user,
|
user,
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
import { ForbiddenException } from '@nestjs/common';
|
|
||||||
import { AiChatController } from './ai-chat.controller';
|
|
||||||
import type { User, Workspace } from '@docmost/db/types/entity.types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wiring spec for the #491 delta-poll endpoint (`POST /ai-chat/messages/delta`).
|
|
||||||
* Owner-gated via assertOwnedChat (same gate as the other reads), NOT flag-gated.
|
|
||||||
* The run fact rides IN the delta response (no separate /run poll). Hand-rolled
|
|
||||||
* mocks — no Nest graph, no DB. Constructor order: (aiChatService,
|
|
||||||
* aiChatRunService, aiChatRepo, aiChatMessageRepo, aiTranscription, pageRepo).
|
|
||||||
*/
|
|
||||||
describe('AiChatController POST /ai-chat/messages/delta (#491)', () => {
|
|
||||||
const user = { id: 'u1' } as User;
|
|
||||||
const workspace = { id: 'ws1' } as Workspace;
|
|
||||||
|
|
||||||
function makeController(opts: {
|
|
||||||
chat?: unknown;
|
|
||||||
delta?: { rows: unknown[]; cursor: string };
|
|
||||||
run?: unknown;
|
|
||||||
}) {
|
|
||||||
const aiChatRunService = {
|
|
||||||
getLatestForChat: jest.fn().mockResolvedValue(opts.run),
|
|
||||||
};
|
|
||||||
const aiChatRepo = {
|
|
||||||
findById: jest.fn().mockResolvedValue(opts.chat),
|
|
||||||
};
|
|
||||||
const aiChatMessageRepo = {
|
|
||||||
findByChatUpdatedAfter: jest
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue(opts.delta ?? { rows: [], cursor: 'C1' }),
|
|
||||||
};
|
|
||||||
const controller = new AiChatController(
|
|
||||||
{} as never,
|
|
||||||
aiChatRunService as never,
|
|
||||||
aiChatRepo as never,
|
|
||||||
aiChatMessageRepo as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
);
|
|
||||||
return { controller, aiChatRunService, aiChatRepo, aiChatMessageRepo };
|
|
||||||
}
|
|
||||||
|
|
||||||
it('owner-gates: a chat the user does not own throws, never reaching the repo', async () => {
|
|
||||||
const { controller, aiChatMessageRepo, aiChatRunService } = makeController({
|
|
||||||
chat: { id: 'c1', creatorId: 'someone-else' },
|
|
||||||
});
|
|
||||||
await expect(
|
|
||||||
controller.getMessagesDelta({ chatId: 'c1' }, user, workspace),
|
|
||||||
).rejects.toBeInstanceOf(ForbiddenException);
|
|
||||||
expect(aiChatMessageRepo.findByChatUpdatedAfter).not.toHaveBeenCalled();
|
|
||||||
expect(aiChatRunService.getLatestForChat).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns { rows, cursor, run:{id,status} } with the run fact inlined', async () => {
|
|
||||||
const rows = [{ id: 'm1' }];
|
|
||||||
const { controller } = makeController({
|
|
||||||
chat: { id: 'c1', creatorId: 'u1' },
|
|
||||||
delta: { rows, cursor: 'C2' },
|
|
||||||
run: { id: 'r1', status: 'running', error: 'ignored', stepCount: 3 },
|
|
||||||
});
|
|
||||||
const res = await controller.getMessagesDelta(
|
|
||||||
{ chatId: 'c1', cursor: 'C1' },
|
|
||||||
user,
|
|
||||||
workspace,
|
|
||||||
);
|
|
||||||
expect(res).toEqual({
|
|
||||||
rows,
|
|
||||||
cursor: 'C2',
|
|
||||||
// ONLY id + status — never the whole run row.
|
|
||||||
run: { id: 'r1', status: 'running' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('run is null when the chat has never had a run', async () => {
|
|
||||||
const { controller } = makeController({
|
|
||||||
chat: { id: 'c1', creatorId: 'u1' },
|
|
||||||
run: undefined,
|
|
||||||
});
|
|
||||||
const res = await controller.getMessagesDelta(
|
|
||||||
{ chatId: 'c1' },
|
|
||||||
user,
|
|
||||||
workspace,
|
|
||||||
);
|
|
||||||
expect(res.run).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes cursor through, defaulting a missing cursor to null (first poll)', async () => {
|
|
||||||
const { controller, aiChatMessageRepo } = makeController({
|
|
||||||
chat: { id: 'c1', creatorId: 'u1' },
|
|
||||||
});
|
|
||||||
await controller.getMessagesDelta({ chatId: 'c1' }, user, workspace);
|
|
||||||
expect(aiChatMessageRepo.findByChatUpdatedAfter).toHaveBeenCalledWith(
|
|
||||||
'c1',
|
|
||||||
'ws1',
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
await controller.getMessagesDelta(
|
|
||||||
{ chatId: 'c1', cursor: 'CX' },
|
|
||||||
user,
|
|
||||||
workspace,
|
|
||||||
);
|
|
||||||
expect(aiChatMessageRepo.findByChatUpdatedAfter).toHaveBeenLastCalledWith(
|
|
||||||
'c1',
|
|
||||||
'ws1',
|
|
||||||
'CX',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -115,7 +115,7 @@ describe('finalizeAssistant dispatch (planFinalizeAssistant + applyFinalize)', (
|
|||||||
|
|
||||||
// Drive the SAME applyFinalize the service calls (no duplicated logic).
|
// Drive the SAME applyFinalize the service calls (no duplicated logic).
|
||||||
async function dispatchFinalize(
|
async function dispatchFinalize(
|
||||||
repo: { insert: jest.Mock; finalizeOwner: jest.Mock },
|
repo: { insert: jest.Mock; update: jest.Mock },
|
||||||
assistantId: string | undefined,
|
assistantId: string | undefined,
|
||||||
flushed: AssistantFlush,
|
flushed: AssistantFlush,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -135,22 +135,21 @@ describe('finalizeAssistant dispatch (planFinalizeAssistant + applyFinalize)', (
|
|||||||
expect(planFinalizeAssistant(undefined)).toEqual({ kind: 'insert' });
|
expect(planFinalizeAssistant(undefined)).toEqual({ kind: 'insert' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('(a) upfront insert succeeded -> finalize CONDITIONALLY updates the row by id (#487 owner-write)', async () => {
|
it('(a) upfront insert succeeded -> finalize UPDATEs the row by id', async () => {
|
||||||
const repo = { insert: jest.fn(), finalizeOwner: jest.fn() };
|
const repo = { insert: jest.fn(), update: jest.fn() };
|
||||||
const flushed = flushAssistant([], 'final answer', 'completed', {
|
const flushed = flushAssistant([], 'final answer', 'completed', {
|
||||||
finishReason: 'stop',
|
finishReason: 'stop',
|
||||||
});
|
});
|
||||||
await dispatchFinalize(repo, 'a1', flushed);
|
await dispatchFinalize(repo, 'a1', flushed);
|
||||||
// #487: the owner write is the CONDITIONAL finalizeOwner, not a raw update.
|
expect(repo.update).toHaveBeenCalledWith('a1', workspaceId, flushed);
|
||||||
expect(repo.finalizeOwner).toHaveBeenCalledWith('a1', workspaceId, flushed);
|
|
||||||
expect(repo.insert).not.toHaveBeenCalled();
|
expect(repo.insert).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('(b) upfront insert failed -> finalize INSERTs the terminal payload', async () => {
|
it('(b) upfront insert failed -> finalize INSERTs the terminal payload', async () => {
|
||||||
const repo = { insert: jest.fn(), finalizeOwner: jest.fn() };
|
const repo = { insert: jest.fn(), update: jest.fn() };
|
||||||
const flushed = flushAssistant([], 'partial', 'error', { error: 'boom' });
|
const flushed = flushAssistant([], 'partial', 'error', { error: 'boom' });
|
||||||
await dispatchFinalize(repo, undefined, flushed);
|
await dispatchFinalize(repo, undefined, flushed);
|
||||||
expect(repo.finalizeOwner).not.toHaveBeenCalled();
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
expect(repo.insert).toHaveBeenCalledTimes(1);
|
expect(repo.insert).toHaveBeenCalledTimes(1);
|
||||||
const arg = repo.insert.mock.calls[0][0];
|
const arg = repo.insert.mock.calls[0][0];
|
||||||
// The fallback insert carries the terminal content/status/metadata.
|
// The fallback insert carries the terminal content/status/metadata.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user