From 72bb03918d9416ff0b9b7fec8fb49330cd1ef59a Mon Sep 17 00:00:00 2001
From: a
Date: Sun, 28 Jun 2026 01:44:09 +0300
Subject: [PATCH 01/34] fix(ai): show live reindex progress in semantic-search
settings
The "Indexed X of Y pages" counter stayed stuck at "478 of 478" during a
manual "Reindex now" run instead of resetting to 0 and climbing. The status
reports indexedPages = countIndexedPages (DISTINCT pages with >=1 embedding
row), but reindex hard-replaces each page in its OWN small transaction, so
nearly all pages always have rows -> the count never drops.
Add a per-workspace live reindex-progress record in Redis (reusing the
existing global ioredis client via RedisService, no new Redis config):
- EmbeddingReindexProgressService: start/increment/clear/get over a Redis hash
with a 1h TTL self-clean; all best-effort/cosmetic so a Redis failure degrades
to the existing DB-count behavior.
- AiSettingsService.reindex seeds {total, done:0, startedAt} at enqueue time so
the very first poll already reports done=0.
- EmbeddingIndexerService.reindexWorkspace overwrites total with the real page
count at start, increments done per processed page (success or handled
failure), and clears the record in a finally (covers success, fatal abort,
and the unconfigured early-return) so a failed run never sticks.
- AiSettingsService.getMasked returns the live run numbers when a progress
record is active (plus an optional reindexing flag), else falls back to
countIndexedPages/countEmbeddablePages.
Per-page edits (reindexPage) never touch the workspace progress record, and no
mass up-front delete is introduced (search availability preserved).
Tests: indexer sets/increments/clears progress (incl. fatal abort and
unconfigured early-return); status reports run progress when active and falls
back when not.
Co-Authored-By: Claude Opus 4.8 (1M context)
boundary consumes pos 4 and 5, so
+ // "bbb" starts at pos 6 (chars at 6,7,8). Select pos 2 (inside "aaa") to pos
+ // 8 (after the second "b").
+ const sel = selectionFor(fragment, 2, 8);
+
+ setYjsMark(ydoc as never, fragment, sel, 'comment', {
+ commentId: 'c3',
+ resolved: false,
+ });
+
+ // First paragraph: "a" clean, "aa" marked.
+ expect(paragraphText(fragment, 0).toDelta()).toEqual([
+ { insert: 'a' },
+ {
+ insert: 'aa',
+ attributes: { comment: { commentId: 'c3', resolved: false } },
+ },
+ ]);
+ // Second paragraph: "bb" marked, "b" clean.
+ expect(paragraphText(fragment, 1).toDelta()).toEqual([
+ {
+ insert: 'bb',
+ attributes: { comment: { commentId: 'c3', resolved: false } },
+ },
+ { insert: 'b' },
+ ]);
+ });
+});
+
+describe('removeYjsMarkByAttribute', () => {
+ it('removes only the run whose attribute value matches, leaving others', () => {
+ const { fragment, text } = buildWithComments([
+ { text: 'AAA', comment: { commentId: 'c1', resolved: false } },
+ { text: 'BBB', comment: { commentId: 'c2', resolved: false } },
+ ]);
+
+ removeYjsMarkByAttribute(fragment, 'comment', 'commentId', 'c1');
+
+ // c1's run loses the mark; c2's run is untouched.
+ expect(text.toDelta()).toEqual([
+ { insert: 'AAA' },
+ {
+ insert: 'BBB',
+ attributes: { comment: { commentId: 'c2', resolved: false } },
+ },
+ ]);
+ });
+
+ it('does nothing when no run carries the requested value (no-match branch)', () => {
+ const { fragment, text } = buildWithComments([
+ { text: 'AAA', comment: { commentId: 'c1', resolved: false } },
+ ]);
+ const before = text.toDelta();
+
+ removeYjsMarkByAttribute(fragment, 'comment', 'commentId', 'does-not-exist');
+
+ expect(text.toDelta()).toEqual(before);
+ });
+
+ it('leaves a different mark type alone', () => {
+ // A run carrying only `bold` must survive a comment removal pass.
+ 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, 'XYZ');
+ text.format(0, 3, { bold: true });
+
+ removeYjsMarkByAttribute(fragment, 'comment', 'commentId', 'c1');
+
+ expect(text.toDelta()).toEqual([
+ { insert: 'XYZ', attributes: { bold: true } },
+ ]);
+ });
+});
+
+describe('updateYjsMarkAttribute', () => {
+ it('merges new attributes into the matching run, preserving the rest', () => {
+ const { fragment, text } = buildWithComments([
+ { text: 'AAA', comment: { commentId: 'c1', resolved: false } },
+ { text: 'BBB', comment: { commentId: 'c2', resolved: false } },
+ ]);
+
+ updateYjsMarkAttribute(
+ fragment,
+ 'comment',
+ { name: 'commentId', value: 'c1' },
+ { resolved: true },
+ );
+
+ // c1's run flips resolved=true (commentId preserved via merge); c2 untouched.
+ expect(text.toDelta()).toEqual([
+ {
+ insert: 'AAA',
+ attributes: { comment: { commentId: 'c1', resolved: true } },
+ },
+ {
+ insert: 'BBB',
+ attributes: { comment: { commentId: 'c2', resolved: false } },
+ },
+ ]);
+ });
+
+ it('does nothing when no run matches (no-match branch)', () => {
+ const { fragment, text } = buildWithComments([
+ { text: 'AAA', comment: { commentId: 'c1', resolved: false } },
+ ]);
+ const before = text.toDelta();
+
+ updateYjsMarkAttribute(
+ fragment,
+ 'comment',
+ { name: 'commentId', value: 'nope' },
+ { resolved: true },
+ );
+
+ expect(text.toDelta()).toEqual(before);
+ });
+});
diff --git a/apps/server/src/core/ai-chat/external-mcp/mcp-clients.service.spec.ts b/apps/server/src/core/ai-chat/external-mcp/mcp-clients.service.spec.ts
new file mode 100644
index 00000000..53ad6191
--- /dev/null
+++ b/apps/server/src/core/ai-chat/external-mcp/mcp-clients.service.spec.ts
@@ -0,0 +1,166 @@
+import { McpClientsService } from './mcp-clients.service';
+
+/**
+ * Unit tests for the two security-critical surfaces of McpClientsService that the
+ * sibling specs (ssrf-guard / validate-resolved-addresses / lease) do NOT cover:
+ *
+ * 1. `decryptHeaders` (private) — FAIL-OPEN behavior. A decrypt/parse failure
+ * (e.g. APP_SECRET rotated, tampered blob) must NEVER throw and must NEVER
+ * log the blob: it returns `undefined` so the connect proceeds WITHOUT the
+ * now-unreadable auth headers (which then 401s and the server is skipped),
+ * rather than crashing the whole turn.
+ *
+ * 2. `this.guardedFetch` (private, bound to the SSRF-pinned dispatcher) — the
+ * per-request DNS-rebinding guard. A blocked host (private/loopback/metadata
+ * IP literal, or an unparseable URL) must REJECT before any socket is opened;
+ * a public host is allowed through to the real `fetch` with the pinned
+ * dispatcher attached.
+ *
+ * No network and no DB: the repo + secretBox deps are stubbed, and global `fetch`
+ * is mocked for the single allow-path assertion.
+ */
+
+// Build the service with a SecretBoxService stub whose decryptSecret is supplied
+// per-test. The repo dep is unused by the methods under test.
+function buildService(decryptSecret: (blob: string) => string) {
+ const secretBox = { decryptSecret: jest.fn(decryptSecret) };
+ const service = new McpClientsService({} as never, secretBox as never);
+ return { service, secretBox };
+}
+
+describe('McpClientsService.decryptHeaders', () => {
+ // Reach the private method via the as-any pattern common in these NestJS specs.
+ const callDecrypt = (
+ service: McpClientsService,
+ blob: string | null,
+ ): Record