ruflo 3.6.27 → 3.6.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/package.json +1 -1
  2. package/src/ruvocal/.claude-flow/daemon-state.json +135 -0
  3. package/src/ruvocal/.claude-flow/data/pending-insights.jsonl +0 -25
  4. package/src/ruvocal/.claude-flow/data/ranked-context.json +5 -0
  5. package/src/ruvocal/.claude-flow/logs/daemon.log +31 -0
  6. package/src/ruvocal/.claude-flow/logs/headless/audit_1777949411822_juxau0_prompt.log +989 -0
  7. package/src/ruvocal/.claude-flow/logs/headless/audit_1777949411822_juxau0_result.log +67 -0
  8. package/src/ruvocal/.claude-flow/logs/headless/audit_1777950042278_jvj5xq_prompt.log +989 -0
  9. package/src/ruvocal/.claude-flow/logs/headless/audit_1777950042278_jvj5xq_result.log +93 -0
  10. package/src/ruvocal/.claude-flow/logs/headless/optimize_1777949531823_yt5yc2_prompt.log +1498 -0
  11. package/src/ruvocal/.claude-flow/logs/headless/optimize_1777949531823_yt5yc2_result.log +93 -0
  12. package/src/ruvocal/.claude-flow/logs/headless/testgaps_1777949771821_elw1j4_prompt.log +1498 -0
  13. package/src/ruvocal/.claude-flow/logs/headless/testgaps_1777949771821_elw1j4_result.log +100 -0
  14. package/src/ruvocal/.claude-flow/metrics/codebase-map.json +11 -0
  15. package/src/ruvocal/.claude-flow/metrics/consolidation.json +6 -0
  16. package/src/ruvocal/.claude-flow/sessions/current.json +13 -0
  17. package/src/ruvocal/.swarm/attestation.db +0 -0
  18. package/src/ruvocal/.swarm/hnsw.index +0 -0
  19. package/src/ruvocal/.swarm/hnsw.metadata.json +1 -0
  20. package/src/ruvocal/.swarm/memory.db +0 -0
  21. package/src/ruvocal/.swarm/schema.sql +305 -0
  22. package/src/ruvocal/src/lib/components/chat/ChatWindow.svelte +8 -8
  23. package/src/ruvocal/src/lib/server/mcp/clientPool.spec.ts +175 -0
  24. package/src/ruvocal/src/lib/server/mcp/clientPool.ts +0 -0
  25. package/src/ruvocal/src/lib/server/textGeneration/index.ts +1 -0
  26. package/src/ruvocal/src/lib/server/textGeneration/mcp/runMcpFlow.ts +10 -1
  27. package/src/ruvocal/src/lib/server/textGeneration/types.ts +3 -1
  28. package/src/ruvocal/src/routes/api/v2/user/settings/+server.ts +7 -0
  29. package/src/ruvocal/src/routes/conversation/[id]/+page.svelte +4 -0
  30. package/src/ruvocal/src/routes/conversation/[id]/+server.ts +4 -0
  31. package/src/ruvocal/src/routes/settings/(nav)/+server.ts +6 -0
@@ -0,0 +1,1498 @@
1
+ [2026-05-05T02:52:11.834Z] PROMPT
2
+ ============================================================
3
+ Analyze this codebase for performance optimizations:
4
+ - Identify N+1 query patterns
5
+ - Find unnecessary re-renders in React
6
+ - Suggest caching opportunities
7
+ - Identify memory leaks
8
+ - Find redundant computations
9
+
10
+ Provide actionable suggestions with code examples.
11
+
12
+ ## Codebase Context
13
+
14
+ --- src/ambient.d.ts ---
15
+ declare module "*.ttf" {
16
+ const value: ArrayBuffer;
17
+ export default value;
18
+ }
19
+
20
+ // Legacy helpers removed: web search support is deprecated, so we intentionally
21
+ // avoid leaking those shapes into the global ambient types.
22
+
23
+
24
+ --- src/app.d.ts ---
25
+ /// <reference types="@sveltejs/kit" />
26
+ /// <reference types="unplugin-icons/types/svelte" />
27
+
28
+ import type { User } from "$lib/types/User";
29
+
30
+ // See https://kit.svelte.dev/docs/types#app
31
+ // for information about these interfaces
32
+ declare global {
33
+ namespace App {
34
+ // interface Error {}
35
+ interface Locals {
36
+ sessionId: string;
37
+ user?: User;
38
+ isAdmin: boolean;
39
+ token?: string;
40
+ /** Organization to bill inference requests to (from settings) */
41
+ billingOrganization?: string;
42
+ }
43
+
44
+ interface Error {
45
+ message: string;
46
+ errorId?: ReturnType<typeof crypto.randomUUID>;
47
+ }
48
+ // interface PageData {}
49
+ // interface Platform {}
50
+ }
51
+ }
52
+
53
+ export {};
54
+
55
+
56
+ --- src/hooks.server.ts ---
57
+ import { building } from "$app/environment";
58
+ import type { Handle, HandleServerError, ServerInit, HandleFetch } from "@sveltejs/kit";
59
+ import { initServer } from "$lib/server/hooks/init";
60
+ import { handleRequest } from "$lib/server/hooks/handle";
61
+ import { handleServerError } from "$lib/server/hooks/error";
62
+ import { handleFetchRequest } from "$lib/server/hooks/fetch";
63
+
64
+ export const init: ServerInit = async () => {
65
+ if (building) return;
66
+ return initServer();
67
+ };
68
+
69
+ export const handle: Handle = async (input) => {
70
+ if (building) {
71
+ // During static build, still replace %gaId% placeholder with empty string
72
+ // to prevent the GA script from loading with an invalid ID
73
+ return input.resolve(input.event, {
74
+ transformPageChunk: ({ html }) => html.replace("%gaId%", ""),
75
+ });
76
+ }
77
+ return handleRequest(input);
78
+ };
79
+
80
+ export const handleError: HandleServerError = async (input) => {
81
+ if (building) throw input.error;
82
+ return handleServerError(input);
83
+ };
84
+
85
+ export const handleFetch: HandleFetch = async (input) => {
86
+ if (building) return input.fetch(input.request);
87
+ return handleFetchRequest(input);
88
+ };
89
+
90
+
91
+ --- src/hooks.ts ---
92
+ import { publicConfigTransporter } from "$lib/utils/PublicConfig.svelte";
93
+ import type { Transport } from "@sveltejs/kit";
94
+
95
+ export const transport: Transport = {
96
+ PublicConfig: publicConfigTransporter,
97
+ };
98
+
99
+
100
+ --- src/lib/APIClient.ts ---
101
+ import { base } from "$app/paths";
102
+ import { browser } from "$app/environment";
103
+ import superjson from "superjson";
104
+ import ObjectId from "bson-objectid";
105
+
106
+ superjson.registerCustom<ObjectId, string>(
107
+ {
108
+ isApplicable: (value): value is ObjectId => {
109
+ if (typeof value !== "string" && ObjectId.isValid(value)) {
110
+ const str = value.toString();
111
+ return /^[0-9a-fA-F]{24}$/.test(str);
112
+ }
113
+ return false;
114
+ },
115
+ serialize: (value) => value.toString(),
116
+ deserialize: (value) => new ObjectId(value),
117
+ },
118
+ "ObjectId"
119
+ );
120
+
121
+ type FetchFn = typeof globalThis.fetch;
122
+
123
+ interface ApiResponse<T = unknown> {
124
+ data: T | null;
125
+ error: unknown;
126
+ status: number;
127
+ }
128
+
129
+ async function apiCall<T = unknown>(
130
+ fetcher: FetchFn,
131
+ url: string,
132
+ method: string,
133
+ body?: unknown,
134
+ query?: Record<string, string | number | undefined>
135
+ ): Promise<ApiResponse<T>> {
136
+ const u = new URL(url);
137
+ if (query) {
138
+ for (const [k, v] of Object.entries(query)) {
139
+ if (v !== undefined && v !== null) {
140
+ u.searchParams.set(k, String(v));
141
+ }
142
+ }
143
+ }
144
+
145
+ const init: RequestInit = { method };
146
+ if (body !== undefined && body !== null) {
147
+ init.headers = { "Content-Type": "application/json" };
148
+ init.body = JSON.stringify(body);
149
+ }
150
+
151
+ const res = await fetcher(u.toString(), init);
152
+ if (!res.ok) {
153
+ let errorBody: unknown;
154
+ try {
155
+ errorBody = await res.json();
156
+ } catch {
157
+ errorBody = await res.text().catch(() => res.statusText);
158
+ }
159
+ return { data: null, error: errorBody, status: res.status };
160
+ }
161
+
162
+ // Handle empty responses (e.g. POST /user/settings returns empty body)
163
+ const text = await res.text();
164
+ if (!text) {
165
+ return { data: null, error: null, status: res.status };
166
+ }
167
+
168
+ return { data: text as unknown as T, error: null, status: res.status };
169
+ }
170
+
171
+ function endpoint(fetcher: FetchFn, baseUrl: string) {
172
+ return {
173
+ get(opts?: { query?: Record<string, string | number | undefined> }) {
174
+ return apiCall(fetcher, baseUrl, "GET", undefined, opts?.query);
175
+ },
176
+ post(body?: unknown) {
177
+ return apiCall(fetcher, baseUrl, "POST", body);
178
+ },
179
+ patch(body?: unknown) {
180
+ return apiCall(fetcher, baseUrl, "PATCH", body);
181
+ },
182
+ delete() {
183
+ return apiCall(fetcher, baseUrl, "DELETE");
184
+ },
185
+ };
186
+ }
187
+
188
+ export function useAPIClient({
189
+ fetch: customFetch,
190
+ origin,
191
+ }: {
192
+ fetch?: FetchFn;
193
+ origin?: string;
194
+ } = {}) {
195
+ const fetcher = customFetch ?? globalThis.fetch;
196
+ const baseUrl = browser
197
+ ? `${window.location.origin}${base}/api/v2`
198
+ : `${origin ?? `http://localhost:5173`}${base}/api/v2`;
199
+
200
+ return {
201
+ conversations: Object.assign(
202
+ // client.conversations({ id: "..." }) — returns endpoint for /conversations/:id
203
+ (params: { id: string }) => ({
204
+ ...endpoint(fetcher, `${baseUrl}/conversations/${params.id}`),
205
+ message: (msgParams: { messageId: string }) =>
206
+ endpoint(fetcher, `${baseUrl}/conversations/${params.id}/message/${msgParams.messageId}`),
207
+ }),
208
+ // client.conversations.get(), .delete()
209
+ {
210
+ ...endpoint(fetcher, `${baseUrl}/conversations`),
211
+ "import-share": endpoint(fetcher, `${baseUrl}/conversations/import-share`),
212
+ }
213
+ ),
214
+ user: {
215
+ ...endpoint(fetcher, `${baseUrl}/user`),
216
+ settings: endpoint(fetcher, `${baseUrl}/user/settings`),
217
+ reports: endpoint(fetcher, `${baseUrl}/user/reports`),
218
+ "billing-orgs": endpoint(fetcher, `${baseUrl}/user/billing-orgs`),
219
+ },
220
+ models: {
221
+ ...endpoint(fetcher, `${baseUrl}/models`),
222
+ old: endpoint(fetcher, `${baseUrl}/models/old`),
223
+ refresh: endpoint(fetcher, `${baseUrl}/models/refresh`),
224
+ },
225
+ "public-config": endpoint(fetcher, `${baseUrl}/public-config`),
226
+ "feature-flags": endpoint(fetcher, `${baseUrl}/feature-flags`),
227
+ debug: {
228
+ config: endpoint(fetcher, `${baseUrl}/debug/config`),
229
+ refresh: endpoint(fetcher, `${baseUrl}/debug/refresh`),
230
+ },
231
+ export: endpoint(fetcher, `${baseUrl}/export`),
232
+ };
233
+ }
234
+
235
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
236
+ export function handleResponse(response: ApiResponse<any>): any {
237
+ if (response.error) {
238
+ throw new Error(JSON.stringify(response.error));
239
+ }
240
+
241
+ if (response.data === null) {
242
+ return null;
243
+ }
244
+
245
+ return superjson.parse(
246
+ typeof response.data === "string" ? response.data : JSON.stringify(response.data)
247
+ );
248
+ }
249
+
250
+
251
+ --- src/lib/actions/clickOutside.ts ---
252
+ export function clickOutside(element: HTMLElement, callbackFunction: () => void) {
253
+ function onClick(event: MouseEvent) {
254
+ if (!element.contains(event.target as Node)) {
255
+ callbackFunction();
256
+ }
257
+ }
258
+
259
+ document.body.addEventListener("click", onClick);
260
+
261
+ return {
262
+ update(newCallbackFunction: () => void) {
263
+ callbackFunction = newCallbackFunction;
264
+ },
265
+ destroy() {
266
+ document.body.removeEventListener("click", onClick);
267
+ },
268
+ };
269
+ }
270
+
271
+
272
+ --- src/lib/actions/snapScrollToBottom.ts (truncated) ---
273
+ import { navigating } from "$app/state";
274
+ import { tick } from "svelte";
275
+
276
+ // Threshold to determine if user is "at bottom" - larger value prevents false detachment
277
+ const BOTTOM_THRESHOLD = 50;
278
+ const USER_SCROLL_DEBOUNCE_MS = 150;
279
+ const PROGRAMMATIC_SCROLL_GRACE_MS = 100;
280
+ const TOUCH_DETACH_THRESHOLD_PX = 10;
281
+
282
+ interface ScrollDependency {
283
+ signal: unknown;
284
+ forceReattach?: number;
285
+ }
286
+
287
+ type MaybeScrollDependency = ScrollDependency | unknown;
288
+
289
+ const getForceReattach = (value: MaybeScrollDependency): number => {
290
+ if (typeof value === "object" && value !== null && "forceReattach" in value) {
291
+ return (value as ScrollDependency).forceReattach ?? 0;
292
+ }
293
+ return 0;
294
+ };
295
+
296
+ /**
297
+ * Auto-scroll action that snaps to bottom while respecting user scroll intent.
298
+ *
299
+ * Key behaviors:
300
+ * 1. Uses wheel/touch events to detect actual user intent
301
+ * 2. Uses IntersectionObserver on a sentinel element to reliably detect "at bottom" state
302
+ * 3. Larger threshold to prevent edge-case false detachments
303
+ *
304
+ * @param node element to snap scroll to bottom
305
+ * @param dependency pass in { signal, forceReattach } - signal triggers scroll updates,
306
+ * forceReattach (counter) forces re-attachment when incremented
307
+ */
308
+ export const snapScrollToBottom = (node: HTMLElement, dependency: MaybeScrollDependency) => {
309
+ // --- State ----------------------------------------------------------------
310
+
311
+ // Track whether user has intentionally scrolled away from bottom
312
+ let isDetached = false;
313
+
314
+ // Track the last forceReattach value to detect changes
315
+ let lastForceReattach = getForceReattach(dependency);
316
+
317
+ // Track if user is actively scrolling (via wheel/touch)
318
+ let userScrolling = false;
319
+ let userScrollTimeout: ReturnType<typeof setTimeout> | undefined;
320
+
321
+ // Track programmatic scrolls to avoid treating them as user scrolls
322
+ let isProgrammaticScroll = false;
323
+ let lastProgrammaticScrollTime = 0;
324
+
325
+ // Track previous scroll position to detect scrollbar drags
326
+ let prevScrollTop = node.scrollTop;
327
+
328
+ // Touch handling state
329
+ let touchStartY = 0;
330
+
331
+ // Observers and sentinel
332
+ let resizeObserver: ResizeObserver | undefined;
333
+ let intersectionObserver: IntersectionObserver | undefined;
334
+ let sentinel: HTMLDivElement | undefined;
335
+
336
+ // Track content height for early-return optimization during streaming
337
+ let lastScrollHeight = node.scrollHeight;
338
+
339
+ // --- Helpers --------------------------------------------------------------
340
+
341
+ const clearUserScrollTimeout = () => {
342
+ if (userScrollTimeout) {
343
+ clearTimeout(userScrollTimeout);
344
+ userScrollTimeout = undefined;
345
+ }
346
+ };
347
+
348
+ const distanceFromBottom = () => node.scrollHeight - node.scrollTop - node.clientHeight;
349
+
350
+ const isAtBottom = () => distanceFromBottom() <= BOTTOM_THRESHOLD;
351
+
352
+ const scrollToBottom = () => {
353
+ isProgrammaticScroll = true;
354
+ lastProgrammaticScrollTime = Date.now();
355
+
356
+ node.scrollTo({ top: node.scrollHeight });
357
+
358
+ if (typeof requestAnimationFrame === "function") {
359
+ requestAnimationFrame(() => {
360
+ isProgrammaticScroll = false;
361
+ });
362
+ } else {
363
+ isProgrammaticScroll = false;
364
+ }
365
+ };
366
+
367
+ const settleScrollAfterLayout = async () => {
368
+ if (typeof requestAnimationFrame !== "function") return;
369
+
370
+ const raf = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
371
+
372
+ await raf();
373
+ if (!userScrolling && !isDetached) {
374
+ scrollToBottom();
375
+ }
376
+
377
+ await raf();
378
+ if (!userScrolling && !isDetached) {
379
+ scrollToBottom();
380
+ }
381
+ };
382
+
383
+ const scheduleUserScrollEndCheck = () => {
384
+ userScrolling = true;
385
+ clearUserScrollTimeout();
386
+
387
+ userScrollTimeout = setTimeout(() => {
388
+ userScrolling = false;
389
+
390
+ // If user scrolled back to bottom, re-attach
391
+ if (isAtBottom()) {
392
+ isDetached = false;
393
+ }
394
+
395
+ // Re-trigger scroll if still attached, to catch content that arrived during scrolling
396
+ if (!isDetached) {
397
+ scrollToBottom();
398
+ }
399
+ }, USER_SCROLL_DEBOUNCE_MS);
400
+ };
401
+
402
+ const createSentinel = () => {
403
+ sentinel = document.createElement("div");
404
+ sentinel.style.height = "1px";
405
+ sentinel.style.width = "100%";
406
+ sentinel.setAttribute("aria-hidden", "true");
407
+ sentinel.setAttribute("data-scroll-sentinel", "");
408
+
409
+ // Find the content container (first child) and append sentinel there
410
+ const container = node.firstElementChild;
411
+ if (container) {
412
+ container.appendChild(sentinel);
413
+ } else {
414
+ node.appendChild(sentinel);
415
+ }
416
+ };
417
+
418
+ const setupIntersectionObserver = () => {
419
+ if (typeof IntersectionObserver === "undefined" || !sentinel) return;
420
+
421
+ intersectionObserver = new IntersectionObserver(
422
+ (entries) => {
423
+ const entry = entries[0];
424
+
425
+ // If sentinel is visible and user isn't actively scrolling, we're at bottom
426
+ if (entry?.isIntersecting && !userScrolling) {
427
+ isDetached = false;
428
+ // Immediately scroll to catch up with any content that arrived while detached
429
+ scrollToBottom();
430
+ }
431
+ },
432
+ {
433
+ root: node,
434
+ threshold: 0,
435
+ rootMargin: `0px 0px ${BOTTOM_THRESHOLD}px 0px`,
436
+ }
437
+ );
438
+
439
+ intersectionObserver.observe(sentinel);
440
+ };
441
+
442
+ const setupResizeObserver = () =>
443
+
444
+ --- src/lib/buildPrompt.ts ---
445
+ import type { EndpointParameters } from "./server/endpoints/endpoints";
446
+ import type { BackendModel } from "./server/models";
447
+
448
+ type buildPromptOptions = Pick<EndpointParameters, "messages" | "preprompt"> & {
449
+ model: BackendModel;
450
+ };
451
+
452
+ export async function buildPrompt({
453
+ messages,
454
+ model,
455
+ preprompt,
456
+ }: buildPromptOptions): Promise<string> {
457
+ const filteredMessages = messages;
458
+
459
+ if (filteredMessages[0].from === "system" && preprompt) {
460
+ filteredMessages[0].content = preprompt;
461
+ }
462
+
463
+ const prompt = model
464
+ .chatPromptRender({
465
+ messages: filteredMessages.map((m) => ({
466
+ ...m,
467
+ role: m.from,
468
+ })),
469
+ preprompt,
470
+ })
471
+ // Not super precise, but it's truncated in the model's backend anyway
472
+ .split(" ")
473
+ .slice(-(model.parameters?.truncate ?? 0))
474
+ .join(" ");
475
+
476
+ return prompt;
477
+ }
478
+
479
+
480
+ --- src/lib/components/chat/MarkdownRenderer.svelte.test.ts ---
481
+ import MarkdownRenderer from "./MarkdownRenderer.svelte";
482
+ import { render } from "vitest-browser-svelte";
483
+ import { page } from "@vitest/browser/context";
484
+
485
+ import { describe, expect, it } from "vitest";
486
+
487
+ describe("MarkdownRenderer", () => {
488
+ it("renders", () => {
489
+ render(MarkdownRenderer, { content: "Hello, world!" });
490
+ expect(page.getByText("Hello, world!")).toBeInTheDocument();
491
+ });
492
+ it("renders headings", () => {
493
+ render(MarkdownRenderer, { content: "# Hello, world!" });
494
+ expect(page.getByRole("heading", { level: 1 })).toBeInTheDocument();
495
+ });
496
+ it("renders links", () => {
497
+ render(MarkdownRenderer, { content: "[Hello, world!](https://example.com)" });
498
+ const link = page.getByRole("link", { name: "Hello, world!" });
499
+ expect(link).toBeInTheDocument();
500
+ expect(link).toHaveAttribute("href", "https://example.com");
501
+ expect(link).toHaveAttribute("target", "_blank");
502
+ expect(link).toHaveAttribute("rel", "noreferrer");
503
+ });
504
+ it("renders inline codespans", () => {
505
+ render(MarkdownRenderer, { content: "`foobar`" });
506
+ expect(page.getByRole("code")).toHaveTextContent("foobar");
507
+ });
508
+ it("renders block codes", () => {
509
+ render(MarkdownRenderer, { content: "```foobar```" });
510
+ expect(page.getByRole("code")).toHaveTextContent("foobar");
511
+ });
512
+ it("doesnt render raw html directly", () => {
513
+ render(MarkdownRenderer, { content: "<button>Click me</button>" });
514
+ expect(page.getByRole("button").elements).toHaveLength(0);
515
+ // htmlparser2 escapes disallowed tags
516
+ expect(page.getByRole("paragraph")).toHaveTextContent("<button>Click me</button>");
517
+ });
518
+ it("renders latex", () => {
519
+ const { baseElement } = render(MarkdownRenderer, { content: "$(oo)^2$" });
520
+ expect(baseElement.querySelectorAll(".katex")).toHaveLength(1);
521
+ });
522
+ it("does not render latex in code blocks", () => {
523
+ const { baseElement } = render(MarkdownRenderer, { content: "```\n$(oo)^2$\n```" });
524
+ expect(baseElement.querySelectorAll(".katex")).toHaveLength(0);
525
+ });
526
+ it("does not render latex in inline codes", () => {
527
+ const { baseElement } = render(MarkdownRenderer, { content: "`$oo` and `$bar`" });
528
+ expect(baseElement.querySelectorAll(".katex")).toHaveLength(0);
529
+ });
530
+ it("does not render latex across multiple lines", () => {
531
+ const { baseElement } = render(MarkdownRenderer, { content: "* $oo \n* $aa" });
532
+ expect(baseElement.querySelectorAll(".katex")).toHaveLength(0);
533
+ });
534
+ it("renders latex with some < and > symbols", () => {
535
+ const { baseElement } = render(MarkdownRenderer, { content: "$foo < bar > baz$" });
536
+ expect(baseElement.querySelectorAll(".katex")).toHaveLength(1);
537
+ });
538
+ });
539
+
540
+
541
+ --- src/lib/constants/mcpExamples.ts ---
542
+ import type { RouterExample } from "./routerExamples";
543
+
544
+ // Examples that showcase RuFlo MCP capabilities — agents, memory,
545
+ // intelligence, dev tools, and the WASM gallery.
546
+ export const mcpExamples: RouterExample[] = [
547
+ {
548
+ title: "Spawn a coding swarm",
549
+ prompt:
550
+ "Spawn a hierarchical swarm with 5 agents (architect, coder, tester, reviewer, security-auditor) to refactor a Python CLI tool to TypeScript. Use ruflo__swarm_init then ruflo__agent_spawn for each role.",
551
+ followUps: [
552
+ {
553
+ title: "Show progress",
554
+ prompt: "Use ruflo__progress_summary to show the swarm's current state.",
555
+ },
556
+ {
557
+ title: "Add tests",
558
+ prompt: "Spawn a tester agent to write integration tests for the swarm output.",
559
+ },
560
+ ],
561
+ },
562
+ {
563
+ title: "Save & recall memory",
564
+ prompt:
565
+ "Use ruflo__memory_store to save: namespace='preferences', key='editor_theme', value='solarized-dark'. Then ruflo__memory_search query='theme' to verify.",
566
+ followUps: [
567
+ {
568
+ title: "List entries",
569
+ prompt: "List all entries in the 'preferences' namespace using ruflo__memory_list.",
570
+ },
571
+ {
572
+ title: "Semantic search",
573
+ prompt: "Find related memories with ruvector__hooks_recall query='editor settings'.",
574
+ },
575
+ ],
576
+ },
577
+ {
578
+ title: "Route a task",
579
+ prompt:
580
+ "Use ruvector__hooks_route on the task: 'add OAuth2 to a SvelteKit API'. Tell me which agent type and topology you'd recommend.",
581
+ followUps: [
582
+ {
583
+ title: "Spawn the agent",
584
+ prompt: "Spawn the recommended agent with ruflo__agent_spawn.",
585
+ },
586
+ {
587
+ title: "Track trajectory",
588
+ prompt: "Begin a trajectory with ruvector__hooks_trajectory_begin to record the work.",
589
+ },
590
+ ],
591
+ },
592
+ {
593
+ title: "Analyze a diff",
594
+ prompt:
595
+ "Use ruflo__analyze_diff to assess risk and ruflo__analyze_diff-reviewers to suggest reviewers for the PR at github.com/ruvnet/ruflo/pull/1687.",
596
+ followUps: [
597
+ {
598
+ title: "Repo metrics",
599
+ prompt: "Get repository metrics with ruflo__github_repo_analyze for ruvnet/ruflo.",
600
+ },
601
+ {
602
+ title: "Open issues",
603
+ prompt: "List recent issues with ruflo__github_issue_track for ruvnet/ruflo.",
604
+ },
605
+ ],
606
+ },
607
+ {
608
+ title: "System health check",
609
+ prompt:
610
+ "Run ruflo__system_status, ruflo__performance_metrics, and ruflo__performance_bottleneck. Summarize anything concerning.",
611
+ followUps: [
612
+ {
613
+ title: "Optimize",
614
+ prompt: "Use ruflo__performance_optimize on the slowest component identified.",
615
+ },
616
+ {
617
+ title: "Benchmark",
618
+ prompt: "Run ruflo__performance_benchmark with --suite=all.",
619
+ },
620
+ ],
621
+ },
622
+ {
623
+ title: "Browse WASM gallery",
624
+ prompt:
625
+ "Show me the templates in the WASM gallery (browser-side rvagent server) and explain what each one does.",
626
+ followUps: [
627
+ {
628
+ title: "Load a template",
629
+ prompt: "Load the most popular template into the local WASM MCP server.",
630
+ },
631
+ ],
632
+ },
633
+ {
634
+ title: "Plan with GOAP",
635
+ prompt:
636
+ "Use the goal-planner pattern: I want to migrate a Postgres schema with zero downtime. Decompose into ruflo agents and tasks.",
637
+ followUps: [
638
+ {
639
+ title: "Risk analysis",
640
+ prompt: "Run ruflo__analyze_file-risk on the migration file.",
641
+ },
642
+ ],
643
+ },
644
+ {
645
+ title: "Train neural pattern",
646
+ prompt:
647
+ "Use ruvector__neural_train to learn from this successful pattern: 'JWT auth with refresh tokens — store refresh in httpOnly cookie, access in memory'.",
648
+ followUps: [
649
+ {
650
+ title: "Predict",
651
+ prompt: "Use ruvector__neural_predict for the task 'add session-based auth'.",
652
+ },
653
+ ],
654
+ },
655
+ ];
656
+
657
+
658
+ --- src/lib/constants/mime.ts ---
659
+ // Centralized MIME allowlists used across client and server
660
+ // Keep these lists minimal and consistent with server processing.
661
+
662
+ export const TEXT_MIME_ALLOWLIST = [
663
+ "text/*",
664
+ "application/json",
665
+ "application/xml",
666
+ "application/csv",
667
+ ] as const;
668
+
669
+ export const IMAGE_MIME_ALLOWLIST_DEFAULT = ["image/jpeg", "image/png"] as const;
670
+
671
+
672
+ --- src/lib/constants/pagination.ts ---
673
+ export const CONV_NUM_PER_PAGE = 30;
674
+
675
+
676
+ --- src/lib/constants/publicSepToken.ts ---
677
+ export const PUBLIC_SEP_TOKEN = "</s>";
678
+
679
+
680
+ --- src/lib/constants/routerExamples.ts ---
681
+ export type RouterFollowUp = {
682
+ title: string;
683
+ prompt: string;
684
+ };
685
+
686
+ export type RouterExampleAttachment = {
687
+ src: string;
688
+ };
689
+
690
+ export type RouterExample = {
691
+ title: string;
692
+ prompt: string;
693
+ followUps?: RouterFollowUp[];
694
+ attachments?: RouterExampleAttachment[];
695
+ };
696
+
697
+ // RuFlo-themed router examples — shown on the empty-state welcome screen
698
+ // when the user hasn't enabled the full MCP toolset. Keep these light enough
699
+ // that even a model without tool-calling can answer (no explicit tool names).
700
+ export const routerExamples: RouterExample[] = [
701
+ {
702
+ title: "Build a coding swarm",
703
+ prompt: "Design a 5-agent coding swarm to refactor a Python CLI to TypeScript. Suggest topology, roles, and the order each agent should run.",
704
+ followUps: [
705
+ {
706
+ title: "Add tests",
707
+ prompt: "Add a tester agent and a security-auditor. What should each one own?",
708
+ },
709
+ {
710
+ title: "Trade-offs",
711
+ prompt: "Compare hierarchical vs mesh topology for this swarm.",
712
+ },
713
+ {
714
+ title: "Failure mode",
715
+ prompt: "What happens if the architect agent fails halfway through?",
716
+ },
717
+ ],
718
+ },
719
+ {
720
+ title: "Memory & recall",
721
+ prompt: "Explain how RuFlo's persistent memory works across sessions, and give me a 3-step example of saving a preference and recalling it later.",
722
+ followUps: [
723
+ {
724
+ title: "Namespaces",
725
+ prompt: "When should I use separate memory namespaces vs one shared namespace?",
726
+ },
727
+ {
728
+ title: "Vector vs key",
729
+ prompt: "When should I use semantic search vs exact key retrieval?",
730
+ },
731
+ ],
732
+ },
733
+ {
734
+ title: "Plan a migration",
735
+ prompt: "Plan a zero-downtime Postgres schema migration. Use Goal-Oriented Action Planning to break it into phases with rollback points.",
736
+ followUps: [
737
+ {
738
+ title: "Risk scoring",
739
+ prompt: "Which phases are highest-risk and how would you mitigate them?",
740
+ },
741
+ {
742
+ title: "Verification",
743
+ prompt: "How would you verify each phase before proceeding?",
744
+ },
745
+ ],
746
+ },
747
+ {
748
+ title: "Review a diff",
749
+ prompt: "What signals would you use to risk-score a code diff (size, files touched, hot paths) and how would you suggest reviewers?",
750
+ followUps: [
751
+ {
752
+ title: "Auto-classify",
753
+ prompt: "Classify a diff as feature/bugfix/refactor/docs from its file mix and message.",
754
+ },
755
+ {
756
+ title: "Security focus",
757
+ prompt: "Which patterns in a diff should trigger a security review?",
758
+ },
759
+ ],
760
+ },
761
+ {
762
+ title: "Explain HNSW",
763
+ prompt: "Explain HNSW vector indexing in plain language, and why it's 150x-12,500x faster than brute-force similarity search at scale.",
764
+ followUps: [
765
+ {
766
+ title: "Quantization",
767
+ prompt: "What does Int8 quantization buy you, and what's the trade-off?",
768
+ },
769
+ {
770
+ title: "Use case",
771
+ prompt: "When would you reach for HNSW vs a relational keyword index?",
772
+ },
773
+ ],
774
+ },
775
+ {
776
+ title: "Choose a topology",
777
+ prompt: "I have 12 agents to coordinate on a multi-step refactor. Compare hierarchical, mesh, hierarchical-mesh, and adaptive topologies — pick one and explain why.",
778
+ followUps: [
779
+ {
780
+ title: "Anti-drift",
781
+ prompt: "What's 'anti-drift' coordination and why does it matter for >8 agents?",
782
+ },
783
+ {
784
+ title: "Consensus",
785
+ prompt: "Compare Raft, Byzantine, gossip, and CRDT consensus for this swarm.",
786
+ },
787
+ ],
788
+ },
789
+ {
790
+ title: "Track a long task",
791
+ prompt: "I'm starting a 4-week migration. How should I structure horizon tracking, milestone checkpoints, and drift detection in RuFlo?",
792
+ followUps: [
793
+ {
794
+ title: "Resume after break",
795
+ prompt: "What state should be persisted so I can resume next week?",
796
+ },
797
+ ],
798
+ },
799
+ {
800
+ title: "Local WASM tools",
801
+ prompt: "What's the difference between the in-browser WASM MCP server and the cloud bridge MCP servers? When should I use each?",
802
+ followUps: [
803
+ {
804
+ title: "Privacy",
805
+ prompt: "Which tools never leave my browser?",
806
+ },
807
+ {
808
+ title: "Offline",
809
+ prompt: "What can RuFlo still do if my network drops?",
810
+ },
811
+ ],
812
+ },
813
+ ];
814
+
815
+
816
+ --- src/lib/constants/rvagentPresets.ts (truncated) ---
817
+ /**
818
+ * rvAgent MCP Server Presets
819
+ *
820
+ * Pre-configured server configurations for the rvagent-mcp server
821
+ * with different tool group combinations. These presets correspond
822
+ * to the tool groups defined in ADR-112.
823
+ *
824
+ * Tool Groups:
825
+ * - file: read, write, edit, ls, glob, grep
826
+ * - shell: execute, bash
827
+ * - memory: semantic_search, store, retrieve
828
+ * - agent: spawn, status, orchestrate
829
+ * - git: status, commit, diff, log
830
+ * - web: fetch, search
831
+ * - brain: search, share, vote (π Brain)
832
+ * - task: create, list, complete
833
+ * - core: ping, initialize (always included)
834
+ */
835
+
836
+ export interface RvAgentPreset {
837
+ /** Unique identifier for the preset */
838
+ id: string;
839
+ /** Display name */
840
+ name: string;
841
+ /** Short description */
842
+ description: string;
843
+ /** Tool groups to enable */
844
+ groups: string[];
845
+ /** Default port (user can override) */
846
+ defaultPort: number;
847
+ /** Icon/emoji for display */
848
+ icon: string;
849
+ /** Recommended use cases */
850
+ useCases: string[];
851
+ }
852
+
853
+ /**
854
+ * Pre-configured rvagent-mcp presets for common use cases
855
+ */
856
+ export const RVAGENT_PRESETS: RvAgentPreset[] = [
857
+ {
858
+ id: "all-tools",
859
+ name: "All Tools",
860
+ description: "Full access to all 46+ rvAgent tools",
861
+ groups: ["all"],
862
+ defaultPort: 9000,
863
+ icon: "🔧",
864
+ useCases: ["Development", "Testing", "Full automation"],
865
+ },
866
+ {
867
+ id: "file-shell",
868
+ name: "File & Shell",
869
+ description: "File operations and command execution",
870
+ groups: ["file", "shell"],
871
+ defaultPort: 9001,
872
+ icon: "📂",
873
+ useCases: ["Code editing", "Build scripts", "File management"],
874
+ },
875
+ {
876
+ id: "memory-agent",
877
+ name: "Memory & Agent",
878
+ description: "Vector memory and multi-agent orchestration",
879
+ groups: ["memory", "agent"],
880
+ defaultPort: 9002,
881
+ icon: "🧠",
882
+ useCases: ["Knowledge retrieval", "Agent coordination", "RAG"],
883
+ },
884
+ {
885
+ id: "git-web",
886
+ name: "Git & Web",
887
+ description: "Version control and web operations",
888
+ groups: ["git", "web"],
889
+ defaultPort: 9003,
890
+ icon: "🌐",
891
+ useCases: ["Code review", "Research", "Documentation"],
892
+ },
893
+ {
894
+ id: "brain-task",
895
+ name: "Brain & Tasks",
896
+ description: "π Brain integration and task management",
897
+ groups: ["brain", "task"],
898
+ defaultPort: 9004,
899
+ icon: "🎯",
900
+ useCases: ["Knowledge sharing", "Task tracking", "Collaboration"],
901
+ },
902
+ {
903
+ id: "dev-minimal",
904
+ name: "Dev Minimal",
905
+ description: "Essential development tools only",
906
+ groups: ["file", "shell", "git"],
907
+ defaultPort: 9005,
908
+ icon: "💻",
909
+ useCases: ["Quick edits", "Simple scripts", "Git operations"],
910
+ },
911
+ {
912
+ id: "research",
913
+ name: "Research Mode",
914
+ description: "Memory, web search, and brain tools",
915
+ groups: ["memory", "web", "brain"],
916
+ defaultPort: 9006,
917
+ icon: "🔬",
918
+ useCases: ["Research", "Knowledge discovery", "Analysis"],
919
+ },
920
+ {
921
+ id: "orchestration",
922
+ name: "Orchestration",
923
+ description: "Agent spawning and task coordination",
924
+ groups: ["agent", "task", "memory"],
925
+ defaultPort: 9007,
926
+ icon: "🎭",
927
+ useCases: ["Multi-agent workflows", "Complex tasks", "Automation"],
928
+ },
929
+ ];
930
+
931
+ /**
932
+ * Get preset by ID
933
+ */
934
+ export function getPresetById(id: string): RvAgentPreset | undefined {
935
+ return RVAGENT_PRESETS.find((p) => p.id === id);
936
+ }
937
+
938
+ /**
939
+ * Build the SSE URL for a preset
940
+ */
941
+ export function buildPresetUrl(preset: RvAgentPreset, host = "localhost", port?: number): string {
942
+ const actualPort = port ?? preset.defaultPort;
943
+ return `http://${host}:${actualPort}/sse`;
944
+ }
945
+
946
+ /**
947
+ * Build CLI command to start the server with preset configuration
948
+ */
949
+ export function buildPresetCliCommand(preset: RvAgentPreset, port?: number): string {
950
+ const actualPort = port ?? preset.defaultPort;
951
+ const groupsArg = preset.groups.includes("all") ? "--all" : `--groups ${preset.groups.join(",")}`;
952
+
953
+ return `rvagent-mcp --transport sse --port ${actualPort} ${groupsArg}`;
954
+ }
955
+
956
+ /**
957
+ * Get all available tool group names
958
+ */
959
+ export const TOOL_GROUPS = [
960
+ "file",
961
+ "shell",
962
+ "memory",
963
+ "agent",
964
+ "git",
965
+ "web",
966
+ "brain",
967
+ "task",
968
+ "core",
969
+ ] as const;
970
+
971
+ export type ToolGroupName = (typeof TOOL_GROUPS)[number];
972
+
973
+ /**
974
+ * Tool group descriptions for UI display
975
+ */
976
+ export const TOOL_GROUP_INFO: Record<ToolGroupName, { name: string; tools: string[]; icon: string }> = {
977
+ file: {
978
+ name: "File Operations",
979
+ tools: ["read_file", "write_file", "edit_file", "ls", "glob", "grep"],
980
+ icon: "📁",
981
+ },
982
+ shell: {
983
+ name: "Shell Execution",
984
+ tools: ["execute", "bash"],
985
+ icon: "💻",
986
+ },
987
+ memory: {
988
+ name: "Vector Memory",
989
+ tools: ["semantic_search", "store_memory", "retrieve_memory"],
990
+ icon: "🧠",
991
+ },
992
+ agent: {
993
+ name: "Multi-Agent",
994
+ tools: ["spawn_agent", "agent_status", "orchestrate"],
995
+ icon: "🤖",
996
+ },
997
+ git: {
998
+ name: "Version Control",
999
+ tools: ["git_status", "git_commit", "git_diff", "git_log"],
1000
+ icon: "📦",
1001
+ },
1002
+ web: {
1003
+ name: "Web Operations",
1004
+ tools: ["web_fetch", "web_search"],
1005
+ icon: "🌐",
1006
+ },
1007
+ brain: {
1008
+ name: "π Brain",
1009
+ tools: ["brain_search", "brain_share", "brain_vote"],
1010
+ icon: "🧪",
1011
+ },
1012
+ task: {
1013
+ name: "Task Management",
1014
+ tools: ["create_task", "list_tasks", "complete_task"],
1015
+ icon: "✅",
1016
+ },
1017
+ core: {
1018
+ n
1019
+
1020
+ --- src/lib/createShareLink.ts ---
1021
+ import { base } from "$app/paths";
1022
+ import { page } from "$app/state";
1023
+
1024
+ // Returns a public share URL for a conversation id.
1025
+ // If `id` is already a 7-char share id, no network call is made.
1026
+ export async function createShareLink(id: string): Promise<string> {
1027
+ const prefix =
1028
+ page.data.publicConfig.PUBLIC_SHARE_PREFIX ||
1029
+ `${page.data.publicConfig.PUBLIC_ORIGIN || page.url.origin}${base}`;
1030
+
1031
+ if (id.length === 7) {
1032
+ return `${prefix}/r/${id}`;
1033
+ }
1034
+
1035
+ const res = await fetch(`${base}/conversation/${id}/share`, {
1036
+ method: "POST",
1037
+ headers: { "Content-Type": "application/json" },
1038
+ });
1039
+
1040
+ if (!res.ok) {
1041
+ const text = await res.text().catch(() => "");
1042
+ throw new Error(text || "Failed to create share link");
1043
+ }
1044
+
1045
+ const { shareId } = await res.json();
1046
+ return `${prefix}/r/${shareId}`;
1047
+ }
1048
+
1049
+
1050
+ --- src/lib/jobs/refresh-conversation-stats.ts (truncated) ---
1051
+ import type { ConversationStats } from "$lib/types/ConversationStats";
1052
+ import { CONVERSATION_STATS_COLLECTION, collections } from "$lib/server/database";
1053
+ import { logger } from "$lib/server/logger";
1054
+ import type { ObjectId } from "mongodb";
1055
+ import { acquireLock, refreshLock } from "$lib/migrations/lock";
1056
+ import { Semaphores } from "$lib/types/Semaphore";
1057
+
1058
+ async function getLastComputationTime(): Promise<Date> {
1059
+ const lastStats = await collections.conversationStats.findOne({}, { sort: { "date.at": -1 } });
1060
+ return lastStats?.date?.at || new Date(0);
1061
+ }
1062
+
1063
+ async function shouldComputeStats(): Promise<boolean> {
1064
+ const lastComputationTime = await getLastComputationTime();
1065
+ const oneDayAgo = new Date(Date.now() - 24 * 3_600_000);
1066
+ return lastComputationTime < oneDayAgo;
1067
+ }
1068
+
1069
+ export async function computeAllStats() {
1070
+ for (const span of ["day", "week", "month"] as const) {
1071
+ computeStats({ dateField: "updatedAt", type: "conversation", span }).catch((e) =>
1072
+ logger.error(e, "Error computing conversation stats for updatedAt")
1073
+ );
1074
+ computeStats({ dateField: "createdAt", type: "conversation", span }).catch((e) =>
1075
+ logger.error(e, "Error computing conversation stats for createdAt")
1076
+ );
1077
+ computeStats({ dateField: "createdAt", type: "message", span }).catch((e) =>
1078
+ logger.error(e, "Error computing message stats for createdAt")
1079
+ );
1080
+ }
1081
+ }
1082
+
1083
+ async function computeStats(params: {
1084
+ dateField: ConversationStats["date"]["field"];
1085
+ span: ConversationStats["date"]["span"];
1086
+ type: ConversationStats["type"];
1087
+ }) {
1088
+ const indexes = await collections.semaphores.listIndexes().toArray();
1089
+ if (indexes.length <= 2) {
1090
+ logger.info("Indexes not created, skipping stats computation");
1091
+ return;
1092
+ }
1093
+
1094
+ const lastComputed = await collections.conversationStats.findOne(
1095
+ { "date.field": params.dateField, "date.span": params.span, type: params.type },
1096
+ { sort: { "date.at": -1 } }
1097
+ );
1098
+
1099
+ // If the last computed week is at the beginning of the last computed month, we need to include some days from the previous month
1100
+ // In those cases we need to compute the stats from before the last month as everything is one aggregation
1101
+ const minDate = lastComputed ? lastComputed.date.at : new Date(0);
1102
+
1103
+ logger.debug(
1104
+ { minDate, dateField: params.dateField, span: params.span, type: params.type },
1105
+ "Computing conversation stats"
1106
+ );
1107
+
1108
+ const dateField = params.type === "message" ? "messages." + params.dateField : params.dateField;
1109
+
1110
+ const pipeline = [
1111
+ {
1112
+ $match: {
1113
+ [dateField]: { $gte: minDate },
1114
+ },
1115
+ },
1116
+ // For message stats: use $filter to reduce data before $unwind (optimization)
1117
+ // For conversation stats: simple projection
1118
+ ...(params.type === "message"
1119
+ ? [
1120
+ {
1121
+ $project: {
1122
+ // Filter messages by date, then map to only keep the date field
1123
+ // This avoids carrying large message payloads (content, files, etc.) through the pipeline
1124
+ messages: {
1125
+ $map: {
1126
+ input: {
1127
+ $filter: {
1128
+ input: "$messages",
1129
+ as: "msg",
1130
+ cond: { $gte: [`$$msg.${params.dateField}`, minDate] },
1131
+ },
1132
+ },
1133
+ as: "msg",
1134
+ in: { [params.dateField]: `$$msg.${params.dateField}` },
1135
+ },
1136
+ },
1137
+ sessionId: 1,
1138
+ userId: 1,
1139
+ },
1140
+ },
1141
+ {
1142
+ $unwind: "$messages",
1143
+ },
1144
+ ]
1145
+ : [
1146
+ {
1147
+ $project: {
1148
+ [dateField]: 1,
1149
+ sessionId: 1,
1150
+ userId: 1,
1151
+ },
1152
+ },
1153
+ ]),
1154
+ {
1155
+ $sort: {
1156
+ [dateField]: 1,
1157
+ },
1158
+ },
1159
+ {
1160
+ $facet: {
1161
+ userId: [
1162
+ {
1163
+ $match: {
1164
+ userId: { $exists: true },
1165
+ },
1166
+ },
1167
+ {
1168
+ $group: {
1169
+ _id: {
1170
+ at: { $dateTrunc: { date: `$${dateField}`, unit: params.span } },
1171
+ userId: "$userId",
1172
+ },
1173
+ },
1174
+ },
1175
+ {
1176
+ $group: {
1177
+ _id: "$_id.at",
1178
+ count: { $sum: 1 },
1179
+ },
1180
+ },
1181
+ {
1182
+ $project: {
1183
+ _id: 0,
1184
+ date: {
1185
+ at: "$_id",
1186
+ field: params.dateField,
1187
+ span: params.span,
1188
+ },
1189
+ distinct: "userId",
1190
+ count: 1,
1191
+ },
1192
+ },
1193
+ ],
1194
+ sessionId: [
1195
+ {
1196
+ $match: {
1197
+ sessionId: { $exists: true },
1198
+ },
1199
+ },
1200
+ {
1201
+ $group: {
1202
+ _id: {
1203
+ at: { $dateTrunc: { date: `$${dateField}`, unit: params.span } },
1204
+ sessionId: "$sessionId",
1205
+ },
1206
+ },
1207
+ },
1208
+ {
1209
+ $group: {
1210
+ _id: "$_id.at",
1211
+ count: { $sum: 1 },
1212
+ },
1213
+ },
1214
+ {
1215
+ $project: {
1216
+ _id: 0,
1217
+ date: {
1218
+ at: "$_id",
1219
+ field: params.dateField,
1220
+ span: params.span,
1221
+ },
1222
+ distinct: "sessionId",
1223
+ count: 1,
1224
+ },
1225
+ },
1226
+ ],
1227
+ userOrSessionId: [
1228
+ {
1229
+ $group: {
1230
+ _id: {
1231
+ at: { $dateTrunc: { date: `$${dateField}`, unit: params.span } },
1232
+ userOrSessionId: { $ifNull: ["$userId", "$sessionId"] },
1233
+ },
1234
+ },
1235
+ },
1236
+ {
1237
+ $group: {
1238
+ _id: "$_id.at",
1239
+ count: { $sum: 1 },
1240
+ },
1241
+ },
1242
+ {
1243
+ $project: {
1244
+ _id: 0,
1245
+ date: {
1246
+ at: "$_id
1247
+
1248
+ --- src/lib/migrations/lock.ts ---
1249
+ import { collections } from "$lib/server/database";
1250
+ import { ObjectId } from "mongodb";
1251
+ import type { Semaphores } from "$lib/types/Semaphore";
1252
+
1253
+ /**
1254
+ * Returns the lock id if the lock was acquired, false otherwise
1255
+ */
1256
+ export async function acquireLock(key: Semaphores | string): Promise<ObjectId | false> {
1257
+ try {
1258
+ const id = new ObjectId();
1259
+
1260
+ const insert = await collections.semaphores.insertOne({
1261
+ _id: id,
1262
+ key,
1263
+ createdAt: new Date(),
1264
+ updatedAt: new Date(),
1265
+ deleteAt: new Date(Date.now() + 1000 * 60 * 3), // 3 minutes
1266
+ });
1267
+
1268
+ return insert.acknowledged ? id : false; // true if the document was inserted
1269
+ } catch (e) {
1270
+ // unique index violation, so there must already be a lock
1271
+ return false;
1272
+ }
1273
+ }
1274
+
1275
+ export async function releaseLock(key: Semaphores | string, lockId: ObjectId) {
1276
+ await collections.semaphores.deleteOne({
1277
+ _id: lockId,
1278
+ key,
1279
+ });
1280
+ }
1281
+
1282
+ export async function isDBLocked(key: Semaphores | string): Promise<boolean> {
1283
+ const res = await collections.semaphores.countDocuments({
1284
+ key,
1285
+ });
1286
+ return res > 0;
1287
+ }
1288
+
1289
+ export async function refreshLock(key: Semaphores | string, lockId: ObjectId): Promise<boolean> {
1290
+ const result = await collections.semaphores.updateOne(
1291
+ {
1292
+ _id: lockId,
1293
+ key,
1294
+ },
1295
+ {
1296
+ $set: {
1297
+ updatedAt: new Date(),
1298
+ deleteAt: new Date(Date.now() + 1000 * 60 * 3), // 3 minutes
1299
+ },
1300
+ }
1301
+ );
1302
+
1303
+ return result.matchedCount > 0;
1304
+ }
1305
+
1306
+
1307
+ --- src/lib/migrations/migrations.spec.ts ---
1308
+ import { afterEach, assert, beforeAll, describe, expect, it } from "vitest";
1309
+ import { migrations } from "./routines";
1310
+ import { acquireLock, isDBLocked, refreshLock, releaseLock } from "./lock";
1311
+ import { Semaphores } from "$lib/types/Semaphore";
1312
+ import { collections, ready } from "$lib/server/database";
1313
+
1314
+ describe(
1315
+ "migrations",
1316
+ {
1317
+ retry: 3,
1318
+ },
1319
+ () => {
1320
+ beforeAll(async () => {
1321
+ await ready;
1322
+ try {
1323
+ await collections.semaphores.createIndex({ key: 1 }, { unique: true });
1324
+ } catch (e) {
1325
+ // Index might already exist, ignore error
1326
+ }
1327
+ }, 20000);
1328
+
1329
+ it("should not have duplicates guid", async () => {
1330
+ const guids = migrations.map((m) => m._id.toString());
1331
+ const uniqueGuids = [...new Set(guids)];
1332
+ expect(uniqueGuids.length).toBe(guids.length);
1333
+ });
1334
+
1335
+ it("should acquire only one lock on DB", async () => {
1336
+ const results = await Promise.all(
1337
+ new Array(1000).fill(0).map(() => acquireLock(Semaphores.TEST_MIGRATION))
1338
+ );
1339
+ const locks = results.filter((r) => r);
1340
+
1341
+ const semaphores = await collections.semaphores.find({}).toArray();
1342
+
1343
+ expect(locks.length).toBe(1);
1344
+ expect(semaphores).toBeDefined();
1345
+ expect(semaphores.length).toBe(1);
1346
+ expect(semaphores?.[0].key).toBe(Semaphores.TEST_MIGRATION);
1347
+ });
1348
+
1349
+ it("should read the lock correctly", async () => {
1350
+ const lockId = await acquireLock(Semaphores.TEST_MIGRATION);
1351
+ assert(lockId);
1352
+ expect(await isDBLocked(Semaphores.TEST_MIGRATION)).toBe(true);
1353
+ expect(!!(await acquireLock(Semaphores.TEST_MIGRATION))).toBe(false);
1354
+ await releaseLock(Semaphores.TEST_MIGRATION, lockId);
1355
+ expect(await isDBLocked(Semaphores.TEST_MIGRATION)).toBe(false);
1356
+ });
1357
+
1358
+ it("should refresh the lock", async () => {
1359
+ const lockId = await acquireLock(Semaphores.TEST_MIGRATION);
1360
+
1361
+ assert(lockId);
1362
+
1363
+ // get the updatedAt time
1364
+
1365
+ const updatedAtInitially = (await collections.semaphores.findOne({}))?.updatedAt;
1366
+
1367
+ await refreshLock(Semaphores.TEST_MIGRATION, lockId);
1368
+
1369
+ const updatedAtAfterRefresh = (await collections.semaphores.findOne({}))?.updatedAt;
1370
+
1371
+ expect(updatedAtInitially).toBeDefined();
1372
+ expect(updatedAtAfterRefresh).toBeDefined();
1373
+ expect(updatedAtInitially).not.toBe(updatedAtAfterRefresh);
1374
+ });
1375
+
1376
+ afterEach(async () => {
1377
+ await collections.semaphores.deleteMany({});
1378
+ await collections.migrationResults.deleteMany({});
1379
+ });
1380
+ }
1381
+ );
1382
+
1383
+
1384
+ --- src/lib/migrations/migrations.ts ---
1385
+ import { Database } from "$lib/server/database";
1386
+ import { migrations } from "./routines";
1387
+ import { acquireLock, releaseLock, isDBLocked, refreshLock } from "./lock";
1388
+ import { Semaphores } from "$lib/types/Semaphore";
1389
+ import { logger } from "$lib/server/logger";
1390
+ import { config } from "$lib/server/config";
1391
+
1392
+ export async function checkAndRunMigrations() {
1393
+ // make sure all GUIDs are unique
1394
+ if (new Set(migrations.map((m) => m._id.toString())).size !== migrations.length) {
1395
+ throw new Error("Duplicate migration GUIDs found.");
1396
+ }
1397
+
1398
+ // check if all migrations have already been run
1399
+ const migrationResults = await (await Database.getInstance())
1400
+ .getCollections()
1401
+ .migrationResults.find()
1402
+ .toArray();
1403
+
1404
+ logger.debug("[MIGRATIONS] Begin check...");
1405
+
1406
+ const lockId = await acquireLock(Semaphores.MIGRATION);
1407
+
1408
+ if (!lockId) {
1409
+ // another instance already has the lock, so we exit early
1410
+ logger.debug(
1411
+ "[MIGRATIONS] Another instance already has the lock. Waiting for DB to be unlocked."
1412
+ );
1413
+
1414
+ // block until the lock is released
1415
+ while (await isDBLocked(Semaphores.MIGRATION)) {
1416
+ await new Promise((resolve) => setTimeout(resolve, 1000));
1417
+ }
1418
+ return;
1419
+ }
1420
+
1421
+ // once here, we have the lock
1422
+ // make sure to refresh it regularly while it's running
1423
+ const refreshInterval = setInterval(async () => {
1424
+ await refreshLock(Semaphores.MIGRATION, lockId);
1425
+ }, 1000 * 10);
1426
+
1427
+ // iterate over all migrations
1428
+ for (const migration of migrations) {
1429
+ // check if the migration has already been applied
1430
+ const shouldRun =
1431
+ migration.runEveryTime ||
1432
+ !migrationResults.find((m) => m._id.toString() === migration._id.toString());
1433
+
1434
+ // check if the migration has already been applied
1435
+ if (!shouldRun) {
1436
+ logger.debug(`[MIGRATIONS] "${migration.name}" already applied. Skipping...`);
1437
+ } else {
1438
+ // check the modifiers to see if some cases match
1439
+ if (
1440
+ (migration.runForHuggingChat === "only" && !config.isHuggingChat) ||
1441
+ (migration.runForHuggingChat === "never" && config.isHuggingChat)
1442
+ ) {
1443
+ logger.debug(
1444
+ `[MIGRATIONS] "${migration.name}" should not be applied for this run. Skipping...`
1445
+ );
1446
+ continue;
1447
+ }
1448
+
1449
+ // otherwise all is good and we can run the migration
1450
+ logger.debug(
1451
+ `[MIGRATIONS] "${migration.name}" ${
1452
+ migration.runEveryTime ? "should run every time" : "not applied yet"
1453
+ }. Applying...`
1454
+ );
1455
+
1456
+ await (await Database.getInstance()).getCollections().migrationResults.updateOne(
1457
+ { _id: migration._id },
1458
+ {
1459
+ $set: {
1460
+ name: migration.name,
1461
+ status: "ongoing",
1462
+ },
1463
+ },
1464
+ { upsert: true }
1465
+ );
1466
+
1467
+ let result = false;
1468
+
1469
+ try {
1470
+ // RVF store: no transactions needed, run migration directly
1471
+ result = await migration.up(await Database.getInstance());
1472
+ } catch (e) {
1473
+ logger.error(e, `[MIGRATIONS] "${migration.name}" failed!`);
1474
+ }
1475
+
1476
+ await (await Database.getInstance()).getCollections().migrationResults.updateOne(
1477
+ { _id: migration._id },
1478
+ {
1479
+ $set: {
1480
+ name: migration.name,
1481
+ status: result ? "success" : "failure",
1482
+ },
1483
+ },
1484
+ { upsert: true }
1485
+ );
1486
+ }
1487
+ }
1488
+
1489
+ logger.debug("[MIGRATIONS] All migrations applied. Releasing lock");
1490
+
1491
+ clearInterval(refreshInterval);
1492
+ await releaseLock(Semaphores.MIGRATION, lockId);
1493
+ }
1494
+
1495
+
1496
+ ## Instructions
1497
+
1498
+ Analyze the above codebase context and provide your response following the format specified in the task.