@gmickel/gno 2.6.0 → 2.7.0

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 (89) hide show
  1. package/README.md +1 -1
  2. package/assets/skill/SKILL.md +5 -3
  3. package/assets/skill/cli-reference.md +9 -2
  4. package/assets/skill/mcp-reference.md +2 -1
  5. package/assets/spa-production.json.gz +0 -0
  6. package/browser-extension/artifacts/{gno-browser-clipper-v2.6.0.zip → gno-browser-clipper-v2.7.0.zip} +0 -0
  7. package/browser-extension/artifacts/gno-browser-clipper-v2.7.0.zip.sha256 +1 -0
  8. package/browser-extension/dist/manifest.json +1 -1
  9. package/package.json +1 -1
  10. package/spec/cli.md +109 -18
  11. package/spec/mcp.md +36 -2
  12. package/spec/output-schemas/ask.schema.json +1 -1
  13. package/spec/output-schemas/capture-receipt.schema.json +1 -1
  14. package/spec/output-schemas/doctor.schema.json +88 -0
  15. package/spec/output-schemas/error.schema.json +11 -2
  16. package/spec/output-schemas/get.schema.json +1 -1
  17. package/spec/output-schemas/mcp-capture-result.schema.json +1 -2
  18. package/spec/output-schemas/memory-remember.schema.json +2 -2
  19. package/spec/output-schemas/multi-get.schema.json +4 -1
  20. package/spec/output-schemas/peek.schema.json +2 -9
  21. package/spec/output-schemas/resident-status.schema.json +22 -0
  22. package/spec/output-schemas/search-result.schema.json +1 -1
  23. package/spec/output-schemas/search-results.schema.json +1 -1
  24. package/spec/output-schemas/status.schema.json +98 -0
  25. package/src/cli/commands/doctor.ts +54 -20
  26. package/src/cli/commands/embed.ts +41 -3
  27. package/src/cli/commands/query.ts +5 -0
  28. package/src/cli/commands/status.ts +63 -5
  29. package/src/cli/commands/vec.ts +54 -0
  30. package/src/cli/detach.ts +29 -1
  31. package/src/cli/errors.ts +13 -9
  32. package/src/cli/program.ts +53 -1
  33. package/src/core/capture-sync.ts +9 -2
  34. package/src/core/host-paths.ts +31 -0
  35. package/src/core/memory-remember.ts +4 -3
  36. package/src/core/shutdown-budget.ts +6 -0
  37. package/src/core/vector-partition-status.ts +52 -0
  38. package/src/embed/backlog.ts +124 -18
  39. package/src/embed/fingerprint.ts +6 -3
  40. package/src/embed/retry.ts +66 -27
  41. package/src/embed/variant-backlog.ts +15 -10
  42. package/src/embed/variant-retry.ts +31 -22
  43. package/src/index.ts +21 -2
  44. package/src/llm/native-worker/dispatcher.ts +2 -0
  45. package/src/llm/native-worker/embedding-identity.ts +42 -0
  46. package/src/llm/native-worker/protocol.ts +1 -0
  47. package/src/llm/types.ts +3 -0
  48. package/src/mcp/context.ts +9 -0
  49. package/src/mcp/resources/index.ts +6 -5
  50. package/src/mcp/tool-descriptions-core.ts +1 -1
  51. package/src/mcp/tools/capture.ts +1 -3
  52. package/src/mcp/tools/index.ts +11 -4
  53. package/src/mcp/tools/memory-remember.ts +1 -1
  54. package/src/mcp/tools/status.ts +4 -0
  55. package/src/pipeline/hybrid.ts +37 -7
  56. package/src/pipeline/vsearch.ts +14 -2
  57. package/src/serve/embed-scheduler.ts +133 -19
  58. package/src/serve/host-path-redaction.ts +79 -0
  59. package/src/serve/public/components/sessions/SessionSearch.tsx +2 -2
  60. package/src/serve/public/globals.built.css +1 -1
  61. package/src/serve/public/hooks/use-api.ts +17 -2
  62. package/src/serve/public/lib/request-intent.ts +8 -0
  63. package/src/serve/public/{components/sessions → lib}/snippet.tsx +2 -3
  64. package/src/serve/public/pages/Dashboard.tsx +12 -9
  65. package/src/serve/public/pages/DocView.tsx +10 -5
  66. package/src/serve/public/pages/DocumentEditor.tsx +91 -14
  67. package/src/serve/public/pages/Search.tsx +1 -41
  68. package/src/serve/resident-runtime.ts +26 -1
  69. package/src/serve/resident-status.ts +13 -1
  70. package/src/serve/server.ts +10 -9
  71. package/src/serve/status-model.ts +16 -0
  72. package/src/serve/status.ts +2 -0
  73. package/src/serve/watch-reconciliation-shared.ts +3 -0
  74. package/src/serve/watch-service-events.ts +3 -2
  75. package/src/serve/watch-service-run-flush.ts +35 -2
  76. package/src/serve/watch-service.ts +5 -0
  77. package/src/store/migrations/031-runtime-independent-vectors.ts +29 -0
  78. package/src/store/migrations/032-vector-runtime-callers.ts +17 -0
  79. package/src/store/migrations/index.ts +4 -0
  80. package/src/store/sqlite/adapter.ts +22 -1
  81. package/src/store/types.ts +11 -1
  82. package/src/store/vector/lazy.ts +46 -43
  83. package/src/store/vector/runtime-compat.ts +651 -0
  84. package/src/store/vector/sqlite-vec.ts +20 -2
  85. package/src/store/vector/status.ts +276 -35
  86. package/src/store/vector/types.ts +2 -0
  87. package/src/store/vector/variant-search.ts +71 -23
  88. package/src/store/vector/variants.ts +49 -14
  89. package/browser-extension/artifacts/gno-browser-clipper-v2.6.0.zip.sha256 +0 -1
@@ -1,5 +1,7 @@
1
1
  import { useCallback, useState } from "react";
2
2
 
3
+ import { writeOutcomeUnknown } from "../lib/request-intent";
4
+
3
5
  interface ApiState<T> {
4
6
  data: T | null;
5
7
  loading: boolean;
@@ -86,6 +88,11 @@ export async function apiFetch<T>(
86
88
  error: string | null;
87
89
  /** `error.details` of the JSON error envelope, when the server sent one. */
88
90
  details?: Record<string, unknown>;
91
+ /**
92
+ * A write may or may not have committed: no readable response, a server
93
+ * error, or the request is still pending under its request ID.
94
+ */
95
+ outcomeUnknown?: true;
89
96
  }> {
90
97
  try {
91
98
  const res = await fetch(endpoint, {
@@ -96,17 +103,24 @@ export async function apiFetch<T>(
96
103
  const { json, parseError } = await parseJsonSafe(res);
97
104
 
98
105
  if (parseError) {
99
- return { data: null, error: parseError };
106
+ return { data: null, error: parseError, outcomeUnknown: true };
100
107
  }
101
108
 
102
109
  if (!res.ok) {
103
110
  const apiError = json as {
104
- error?: { message?: string; details?: Record<string, unknown> };
111
+ error?: {
112
+ code?: string;
113
+ message?: string;
114
+ details?: Record<string, unknown>;
115
+ };
105
116
  };
106
117
  return {
107
118
  data: null,
108
119
  error: apiError.error?.message || `Request failed: ${res.status}`,
109
120
  details: apiError.error?.details,
121
+ ...(writeOutcomeUnknown(res.status, apiError.error?.code)
122
+ ? { outcomeUnknown: true as const }
123
+ : {}),
110
124
  };
111
125
  }
112
126
 
@@ -115,6 +129,7 @@ export async function apiFetch<T>(
115
129
  return {
116
130
  data: null,
117
131
  error: err instanceof Error ? err.message : "Network error",
132
+ outcomeUnknown: true,
118
133
  };
119
134
  }
120
135
  }
@@ -67,3 +67,11 @@ export function clearRequestIntent(
67
67
  // Nothing persisted to clear.
68
68
  }
69
69
  }
70
+
71
+ /**
72
+ * Whether a failed write's HTTP answer leaves its commit unknown: a server
73
+ * error, or the request ID's earlier attempt is still in progress.
74
+ */
75
+ export function writeOutcomeUnknown(status: number, code?: string): boolean {
76
+ return status >= 500 || code === "REQUEST_PENDING";
77
+ }
@@ -14,10 +14,9 @@ export function unescapeMarkdown(text: string): string {
14
14
  /**
15
15
  * Render a search snippet: FTS `<mark>` highlights become real <mark>
16
16
  * elements, everything else stays React text (never parsed as HTML).
17
- * Mirrors the Search page renderer, plus Markdown-escape removal because
18
- * archived turns are indexed as escaped Markdown.
17
+ * Markdown backslash escapes are dropped because indexed text keeps them.
19
18
  */
20
- export function renderSessionSnippet(snippet: string): ReactNode[] {
19
+ export function renderSnippet(snippet: string): ReactNode[] {
21
20
  const parts: ReactNode[] = [];
22
21
  let remaining = snippet;
23
22
  let key = 0;
@@ -788,25 +788,28 @@ export default function Dashboard({ navigate }: PageProps) {
788
788
  }
789
789
  style={{ animationDelay: `${0.4 + index * 0.1}s` }}
790
790
  >
791
- <CardContent className="flex items-center justify-between py-4">
792
- <div className="flex items-center gap-3">
791
+ <CardContent className="flex flex-wrap items-center justify-between gap-3 py-4">
792
+ <div className="flex min-w-0 flex-1 basis-48 items-center gap-3">
793
793
  {syncing ? (
794
- <Loader2Icon className="size-4 animate-spin text-amber-500" />
794
+ <Loader2Icon className="size-4 shrink-0 animate-spin text-amber-500" />
795
795
  ) : collection.embeddedCount >= collection.chunkCount ? (
796
- <CheckCircle2Icon className="size-4 text-green-500" />
796
+ <CheckCircle2Icon className="size-4 shrink-0 text-green-500" />
797
797
  ) : (
798
- <div className="size-4 rounded-full border-2 border-amber-500" />
798
+ <div className="size-4 shrink-0 rounded-full border-2 border-amber-500" />
799
799
  )}
800
- <div>
801
- <div className="font-medium text-lg transition-colors group-hover:text-primary">
800
+ <div className="min-w-0">
801
+ <div className="truncate font-medium text-lg transition-colors group-hover:text-primary">
802
802
  {collection.name}
803
803
  </div>
804
- <div className="font-mono text-muted-foreground text-sm">
804
+ <div
805
+ className="truncate font-mono text-muted-foreground text-sm"
806
+ title={collection.path}
807
+ >
805
808
  {collection.path}
806
809
  </div>
807
810
  </div>
808
811
  </div>
809
- <div className="flex items-center gap-3 text-right">
812
+ <div className="flex shrink-0 items-center gap-3 text-right">
810
813
  <Button
811
814
  onClick={(event) => {
812
815
  event.stopPropagation();
@@ -37,6 +37,7 @@ import {
37
37
  type FileRefactorPreviewPlan,
38
38
  } from "../../../core/file-refactor-contract";
39
39
  import { extractSections } from "../../../core/sections";
40
+ import { updateFrontmatterTags } from "../../../ingestion/frontmatter";
40
41
  import {
41
42
  CodeBlock,
42
43
  CodeBlockCopyButton,
@@ -583,11 +584,10 @@ export default function DocView({ navigate }: PageProps) {
583
584
  return buildDocAssetUrl(doc.uri, doc.relPath);
584
585
  }, [doc, isPdf]);
585
586
 
586
- // Remote "Open original" target for any document that has a source file:
587
- // /api/doc-asset serves any collection file inline, so this keeps the
588
- // previous file:// scope (every read-only source) for remote clients.
587
+ // Remote "Open original" target: /api/doc-asset serves any collection file
588
+ // inline by URI, and remote clients never receive the host absPath.
589
589
  const sourceAssetUrl = useMemo(() => {
590
- if (!doc?.source.absPath) {
590
+ if (!doc) {
591
591
  return null;
592
592
  }
593
593
  return buildDocAssetUrl(doc.uri, doc.relPath);
@@ -1243,10 +1243,15 @@ export default function DocView({ navigate }: PageProps) {
1243
1243
  }
1244
1244
 
1245
1245
  clearRequestIntent(tagIntentRef, TAG_INTENT_KEY);
1246
- // Update doc with new tags
1246
+ // Mirror the committed write-back so the frontmatter tag list is current
1247
+ // without waiting for the index to resync.
1247
1248
  setDoc({
1248
1249
  ...doc,
1249
1250
  tags: editedTags,
1251
+ content:
1252
+ data?.writeBack === "applied" && doc.content !== null
1253
+ ? updateFrontmatterTags(doc.content, editedTags)
1254
+ : doc.content,
1250
1255
  source: {
1251
1256
  ...doc.source,
1252
1257
  sourceHash: data?.version.sourceHash ?? doc.source.sourceHash,
@@ -207,8 +207,12 @@ export default function DocumentEditor({ navigate }: PageProps) {
207
207
  const [saveError, setSaveError] = useState<string | null>(null);
208
208
  const [creatingCopy, setCreatingCopy] = useState(false);
209
209
  const [copyError, setCopyError] = useState<string | null>(null);
210
- const [externalChangeNotice, setExternalChangeNotice] = useState<
211
- string | null
210
+ /**
211
+ * `outside`: the file changed under the editor. `unconfirmed`: a save's
212
+ * response was lost, so a change on disk may be that save's own commit.
213
+ */
214
+ const [changeNotice, setChangeNotice] = useState<
215
+ "outside" | "unconfirmed" | null
212
216
  >(null);
213
217
  const [historyEntries, setHistoryEntries] = useState<LocalHistoryEntry[]>([]);
214
218
  const [historyDialogOpen, setHistoryDialogOpen] = useState(false);
@@ -322,12 +326,21 @@ export default function DocumentEditor({ navigate }: PageProps) {
322
326
  // read the current revision and last committed content from refs.
323
327
  const docRef = useRef(doc);
324
328
  const committedContentRef = useRef(originalContent);
329
+ const draftRef = useRef(content);
325
330
  useEffect(() => {
326
331
  docRef.current = doc;
327
332
  }, [doc]);
333
+ useEffect(() => {
334
+ draftRef.current = content;
335
+ }, [content]);
328
336
  // One request ID per (document revision, content) save intent: retrying a
329
337
  // save whose response was lost replays it instead of reporting a conflict.
330
338
  const saveIntentRef = useRef<RequestIntent | null>(null);
339
+ // Content of a save that is in flight or whose response was lost: its
340
+ // commit is unknown, so change events are held until a response says whose
341
+ // change they were. Retry save re-sends exactly this content.
342
+ const unknownSaveRef = useRef<string | null>(null);
343
+ const changedWhileUnknownRef = useRef(false);
331
344
 
332
345
  /** Save `contentToSave`; resolves true once it is committed on disk. */
333
346
  const persistContent = useCallback(
@@ -341,8 +354,13 @@ export default function DocumentEditor({ navigate }: PageProps) {
341
354
 
342
355
  setSaveStatus("saving");
343
356
  setSaveError(null);
357
+ unknownSaveRef.current = contentToSave;
344
358
 
345
- const { data, error: err } = await apiFetch<UpdateDocResponse>(
359
+ const {
360
+ data,
361
+ error: err,
362
+ outcomeUnknown,
363
+ } = await apiFetch<UpdateDocResponse>(
346
364
  `/api/docs/${encodeURIComponent(current.docid)}`,
347
365
  {
348
366
  method: "PUT",
@@ -363,23 +381,45 @@ export default function DocumentEditor({ navigate }: PageProps) {
363
381
  if (err) {
364
382
  setSaveStatus("error");
365
383
  setSaveError(err);
384
+ if (outcomeUnknown) {
385
+ // Never downgrade a warning about a change from elsewhere.
386
+ setChangeNotice((notice) =>
387
+ notice === "outside" ? notice : "unconfirmed"
388
+ );
389
+ return false;
390
+ }
391
+ // A definitive rejection: this save did not commit, so a change seen
392
+ // meanwhile came from elsewhere.
393
+ unknownSaveRef.current = null;
394
+ if (changedWhileUnknownRef.current) {
395
+ changedWhileUnknownRef.current = false;
396
+ setChangeNotice("outside");
397
+ } else {
398
+ setChangeNotice((notice) =>
399
+ notice === "unconfirmed" ? null : notice
400
+ );
401
+ }
366
402
  return false;
367
403
  }
368
404
 
405
+ unknownSaveRef.current = null;
406
+ changedWhileUnknownRef.current = false;
369
407
  if (data?.request?.replayed) {
370
408
  // A replay reports an earlier commit; disk may have moved on since.
371
- // Clear the change notice only if the file still holds that commit.
409
+ // Only a read showing a different hash is evidence of another writer.
372
410
  const { data: latest } = await apiFetch<DocData>(
373
411
  `/api/doc?uri=${encodeURIComponent(current.uri)}`
374
412
  );
375
- if (latest?.source.sourceHash === data.version.sourceHash) {
376
- setExternalChangeNotice(null);
377
- }
413
+ setChangeNotice(
414
+ latest && latest.source.sourceHash !== data.version.sourceHash
415
+ ? "outside"
416
+ : null
417
+ );
378
418
  } else {
379
419
  // Written now against the loaded revision: the next change event is
380
420
  // this save's own sync, and any earlier notice is stale.
381
421
  ignoreDocEventsUntilRef.current = Date.now() + 5_000;
382
- setExternalChangeNotice(null);
422
+ setChangeNotice(null);
383
423
  }
384
424
  const previous = committedContentRef.current;
385
425
  if (previous !== contentToSave) {
@@ -589,6 +629,18 @@ export default function DocumentEditor({ navigate }: PageProps) {
589
629
  }
590
630
  }, [cancelAutosave, content, hasUnsavedChanges, persistContent]);
591
631
 
632
+ // Retry the unconfirmed save itself (same content, same request ID), even
633
+ // when the draft has since changed or returned to the loaded text; once it
634
+ // is confirmed, the draft as it is then is saved on top of it.
635
+ const retryUnconfirmedSave = useCallback(async () => {
636
+ const pending = unknownSaveRef.current;
637
+ if (pending === null) return;
638
+ cancelAutosave();
639
+ if (await persistContent(pending)) {
640
+ await persistContent(draftRef.current);
641
+ }
642
+ }, [cancelAutosave, persistContent]);
643
+
592
644
  const loadDocument = useCallback(() => {
593
645
  const uri = currentTarget.uri;
594
646
 
@@ -637,16 +689,20 @@ export default function DocumentEditor({ navigate }: PageProps) {
637
689
  return;
638
690
  }
639
691
  handledDocEventRef.current = latestDocEvent.changedAt;
692
+ if (unknownSaveRef.current !== null) {
693
+ changedWhileUnknownRef.current = true;
694
+ return;
695
+ }
640
696
  if (Date.now() < ignoreDocEventsUntilRef.current) {
641
697
  return;
642
698
  }
643
- setExternalChangeNotice(
644
- "This document changed on disk. Reload before continuing."
645
- );
699
+ setChangeNotice("outside");
646
700
  }, [doc, latestDocEvent?.changedAt, latestDocEvent?.uri]);
647
701
 
648
702
  const reloadDocument = useCallback(() => {
649
- setExternalChangeNotice(null);
703
+ unknownSaveRef.current = null;
704
+ changedWhileUnknownRef.current = false;
705
+ setChangeNotice(null);
650
706
  loadDocument();
651
707
  }, [loadDocument]);
652
708
 
@@ -1136,10 +1192,31 @@ export default function DocumentEditor({ navigate }: PageProps) {
1136
1192
  </p>
1137
1193
  )}
1138
1194
 
1139
- {externalChangeNotice && (
1195
+ {changeNotice === "unconfirmed" && (
1196
+ <div className="border-amber-500/30 border-b bg-amber-500/10 px-4 py-3">
1197
+ <div className="flex flex-wrap items-center justify-between gap-3">
1198
+ <p className="text-amber-500 text-sm">
1199
+ The save may have completed, but its response was lost. Retry to
1200
+ confirm it.
1201
+ </p>
1202
+ <Button
1203
+ disabled={saveStatus === "saving"}
1204
+ onClick={retryUnconfirmedSave}
1205
+ size="sm"
1206
+ variant="outline"
1207
+ >
1208
+ Retry save
1209
+ </Button>
1210
+ </div>
1211
+ </div>
1212
+ )}
1213
+
1214
+ {changeNotice === "outside" && (
1140
1215
  <div className="border-amber-500/30 border-b bg-amber-500/10 px-4 py-3">
1141
1216
  <div className="flex flex-wrap items-center justify-between gap-3">
1142
- <p className="text-amber-500 text-sm">{externalChangeNotice}</p>
1217
+ <p className="text-amber-500 text-sm">
1218
+ This document changed on disk. Reload before continuing.
1219
+ </p>
1143
1220
  <Button onClick={reloadDocument} size="sm" variant="outline">
1144
1221
  Reload
1145
1222
  </Button>
@@ -50,50 +50,10 @@ import {
50
50
  fetchServerCapabilities,
51
51
  type ServerCapabilities,
52
52
  } from "../lib/server-capabilities";
53
+ import { renderSnippet } from "../lib/snippet";
53
54
  import { cn } from "../lib/utils";
54
55
  import { AIModelSelector, TagFacets } from "./search-page-widgets";
55
56
 
56
- /**
57
- * Render snippet with <mark> tags as highlighted spans.
58
- * Only allows mark tags - strips all other HTML for safety.
59
- */
60
- function renderSnippet(snippet: string): React.ReactNode {
61
- const parts: React.ReactNode[] = [];
62
- let remaining = snippet;
63
- let key = 0;
64
-
65
- while (remaining.length > 0) {
66
- const markStart = remaining.indexOf("<mark>");
67
- if (markStart === -1) {
68
- parts.push(remaining);
69
- break;
70
- }
71
-
72
- if (markStart > 0) {
73
- parts.push(remaining.slice(0, markStart));
74
- }
75
-
76
- const markEnd = remaining.indexOf("</mark>", markStart);
77
- if (markEnd === -1) {
78
- parts.push(remaining.slice(markStart));
79
- break;
80
- }
81
-
82
- const highlighted = remaining.slice(markStart + 6, markEnd);
83
- parts.push(
84
- <mark
85
- className="rounded bg-primary/20 px-0.5 font-medium text-primary"
86
- key={key++}
87
- >
88
- {highlighted}
89
- </mark>
90
- );
91
- remaining = remaining.slice(markEnd + 7);
92
- }
93
-
94
- return parts;
95
- }
96
-
97
57
  interface PageProps {
98
58
  navigate: (to: string | number) => void;
99
59
  }
@@ -45,9 +45,11 @@ import { JobManager } from "../core/job-manager";
45
45
  import { recordContentMutation } from "../core/mutation-generations";
46
46
  import {
47
47
  shutdownDuration,
48
+ RESIDENT_BUSY_TIMEOUT_MS,
48
49
  SHUTDOWN_DRAIN_MS,
49
50
  SHUTDOWN_ABORT_MS,
50
51
  } from "../core/shutdown-budget";
52
+ import { acquireCliWriteLease } from "../core/write-lease";
51
53
  import { defaultSyncService, withContentTypeRules } from "../ingestion";
52
54
  import { withOwnedInferenceScope } from "../llm/inference-scope";
53
55
  import { getActivePreset } from "../llm/registry";
@@ -60,7 +62,7 @@ import {
60
62
  disposeServerContext,
61
63
  type ServerContext,
62
64
  } from "./context";
63
- import { createEmbedScheduler } from "./embed-scheduler";
65
+ import { createEmbedScheduler, embedSchedulerIssues } from "./embed-scheduler";
64
66
  import { FindingsScheduler, type FindingsPassResult } from "./findings-pass";
65
67
  import { AdmissionController, ReaderGate } from "./resident-admission";
66
68
  import { ResidentBackgroundWork } from "./resident-background-work";
@@ -189,10 +191,25 @@ export type ResidentRuntimeDeps = {
189
191
  eventBus?: DocumentEventBus | null;
190
192
  callbacks?: CollectionWatchCallbacks;
191
193
  syncOptions?: Parameters<typeof withContentTypeRules>[0];
194
+ acquireWriteLease?: () => Promise<(() => Promise<void>) | null>;
192
195
  }) => CollectionWatchService;
193
196
  modelManagerFactory?: (config: Config) => ModelManager;
194
197
  };
195
198
 
199
+ /** No-wait shared writer lease for background writes; null when held elsewhere. */
200
+ async function acquireBackgroundWriteLease(
201
+ dbPath: string,
202
+ command: string
203
+ ): Promise<(() => Promise<void>) | null> {
204
+ const lease = await acquireCliWriteLease({
205
+ dbPath,
206
+ waitMs: 0,
207
+ noWait: true,
208
+ command,
209
+ });
210
+ return lease.ok ? lease.release : null;
211
+ }
212
+
196
213
  export async function startResidentRuntime(
197
214
  options: ResidentRuntimeOptions = {},
198
215
  deps: ResidentRuntimeDeps = {}
@@ -274,6 +291,11 @@ export async function startResidentRuntime(
274
291
  if (!syncCollections.ok) return failStartup(syncCollections.error.message);
275
292
  const syncContexts = await store.syncContexts(initialConfig.contexts ?? []);
276
293
  if (!syncContexts.ok) return failStartup(syncContexts.error.message);
294
+ // SQLite busy waits block this event loop; keep each one short enough that
295
+ // a stop signal is still handled within the stop grace.
296
+ store.setBusyTimeout?.(RESIDENT_BUSY_TIMEOUT_MS);
297
+ const leaseFor = (command: string) => () =>
298
+ acquireBackgroundWriteLease(dbPath, command);
277
299
 
278
300
  let ctx: ServerContext;
279
301
  try {
@@ -322,6 +344,7 @@ export async function startResidentRuntime(
322
344
  onEmbedded: () => {
323
345
  generations.index += 1;
324
346
  },
347
+ acquireWriteLease: leaseFor(`gno ${mode} (background embed)`),
325
348
  });
326
349
  ctxHolder.scheduler = scheduler;
327
350
  ctxHolder.current.scheduler = scheduler;
@@ -351,6 +374,7 @@ export async function startResidentRuntime(
351
374
  },
352
375
  },
353
376
  syncOptions: withContentTypeRules({}, initialConfig),
377
+ acquireWriteLease: leaseFor(`gno ${mode} (watch sync)`),
354
378
  });
355
379
  watchService.start();
356
380
  ctxHolder.watchService = watchService;
@@ -590,6 +614,7 @@ export async function startResidentRuntime(
590
614
  failed: jobs.recent.filter((job) => job.status === "failed").length,
591
615
  },
592
616
  generations: { ...generations },
617
+ backgroundIssues: embedSchedulerIssues(scheduler.getState()),
593
618
  });
594
619
  },
595
620
  setListenerPort(port) {
@@ -1,6 +1,10 @@
1
1
  /** Safe, transport-neutral resident lifecycle status projection. */
2
2
 
3
- import type { ResidentStatus, RuntimeMode } from "./status-model";
3
+ import type {
4
+ BackgroundIssue,
5
+ ResidentStatus,
6
+ RuntimeMode,
7
+ } from "./status-model";
4
8
 
5
9
  const EMPTY_MODELS: ResidentStatus["models"] = {
6
10
  activeLeases: 0,
@@ -50,6 +54,7 @@ export interface ResidentStatusSnapshotInput {
50
54
  models: ResidentStatus["models"];
51
55
  jobs: ResidentStatus["jobs"];
52
56
  generations: ResidentStatus["generations"];
57
+ backgroundIssues?: BackgroundIssue[];
53
58
  now?: number;
54
59
  }
55
60
 
@@ -72,6 +77,13 @@ export function buildResidentStatusSnapshot(
72
77
  models: { ...input.models },
73
78
  jobs: { ...input.jobs },
74
79
  generations: { ...input.generations },
80
+ ...(input.backgroundIssues?.length
81
+ ? {
82
+ backgroundIssues: input.backgroundIssues.map((issue) => ({
83
+ ...issue,
84
+ })),
85
+ }
86
+ : {}),
75
87
  };
76
88
  }
77
89
 
@@ -1,11 +1,4 @@
1
1
  import type { HttpGatewayOverrides } from "../mcp/http-security";
2
- /**
3
- * Bun.serve() web server for GNO web UI.
4
- * Uses Bun's fullstack dev server with HTML imports.
5
- * Opens DB once at startup, closes on shutdown.
6
- *
7
- * @module src/serve/server
8
- */
9
2
  import type { RequestPeerServer } from "./request-locality";
10
3
  import type { ResidentRuntime } from "./resident-runtime";
11
4
  import type { ContextHolder } from "./routes/api";
@@ -23,6 +16,14 @@ import {
23
16
  handlePdfjsVendorRequest,
24
17
  isPdfjsVendorPath,
25
18
  } from "./fn112-routes";
19
+ /**
20
+ * Bun.serve() web server for GNO web UI.
21
+ * Uses Bun's fullstack dev server with HTML imports.
22
+ * Opens DB once at startup, closes on shutdown.
23
+ *
24
+ * @module src/serve/server
25
+ */
26
+ import { withRemoteHostPathRedaction } from "./host-path-redaction";
26
27
  import { PDFJS_ASSET_CACHE_CONTROL } from "./pdfjs-assets";
27
28
  // HTML import - Bun handles bundling TSX/CSS automatically via routes
28
29
  import homepage from "./public/index.html";
@@ -571,7 +572,7 @@ export async function startServer(
571
572
  development: isDev,
572
573
 
573
574
  // Static routes - Bun handles HTML bundling and /_bun/* assets automatically
574
- routes: {
575
+ routes: withRemoteHostPathRedaction({
575
576
  "/mcp": gateway.route,
576
577
  ...clipperRoutesForBind(
577
578
  isHttpGatewayLoopbackBind(gatewayConfig.host),
@@ -1743,7 +1744,7 @@ export async function startServer(
1743
1744
  );
1744
1745
  },
1745
1746
  },
1746
- },
1747
+ }),
1747
1748
  // Production catch-all: /vendor/pdfjs prefix, then hashed SPA chunks
1748
1749
  // (gzip + immutable) and the private SPA source — the same factory the
1749
1750
  // tests mount (no test-only fallback path).
@@ -1,6 +1,10 @@
1
1
  import type { ContentTypeBoostStatus } from "../config/content-types";
2
2
  import type { ActivationStatus } from "../core/activation-status";
3
3
  import type { ChunkingStatus } from "../store/chunking";
4
+ import type {
5
+ VectorPartitionStatus,
6
+ VectorRuntimeStatus,
7
+ } from "../store/vector/status";
4
8
 
5
9
  export type HealthCheckStatus = "ok" | "warn" | "error";
6
10
 
@@ -109,6 +113,16 @@ export interface ResidentStatus {
109
113
  content: number;
110
114
  index: number;
111
115
  };
116
+ /** Present only while a background job is in trouble. */
117
+ backgroundIssues?: BackgroundIssue[];
118
+ }
119
+
120
+ /** A resident background job that keeps failing, has stopped retrying, or overruns. */
121
+ export interface BackgroundIssue {
122
+ job: "embed" | "resident";
123
+ state: "failing" | "parked" | "overrunning" | "unresponsive";
124
+ consecutiveFailures: number;
125
+ runningSeconds: number | null;
112
126
  }
113
127
 
114
128
  export interface BackgroundServiceState {
@@ -186,6 +200,8 @@ export interface AppStatusResponse {
186
200
  totalDocuments: number;
187
201
  totalChunks: number;
188
202
  embeddingBacklog: number;
203
+ vectorPartitions?: VectorPartitionStatus[];
204
+ vectorRuntime?: VectorRuntimeStatus;
189
205
  lastUpdated: string | null;
190
206
  recentErrors: number;
191
207
  healthy: boolean;
@@ -734,6 +734,8 @@ export async function buildAppStatus(
734
734
  totalDocuments: status.activeDocuments,
735
735
  totalChunks: status.totalChunks,
736
736
  embeddingBacklog: status.embeddingBacklog,
737
+ vectorPartitions: status.vectorPartitions,
738
+ vectorRuntime: status.vectorRuntime,
737
739
  chunking: status.chunking,
738
740
  lastUpdated: status.lastUpdatedAt,
739
741
  recentErrors: status.recentErrors,
@@ -45,6 +45,9 @@ export const WATCHER_MAX_SUPPRESSION_ENTRIES = 4_096;
45
45
  /** Bounded retry delay after failed classification/sync. */
46
46
  export const WATCHER_RETRY_BACKOFF_MS = 500;
47
47
 
48
+ /** Retry delay while another writer holds the shared writer lease. */
49
+ export const WATCHER_LEASE_RETRY_MS = 5_000;
50
+
48
51
  /**
49
52
  * Single fixed budget for fallback classification across visited directories,
50
53
  * candidates, removals, dirty dirs, and aggregate store rows.
@@ -219,7 +219,8 @@ export function requeueAfterFailure(
219
219
  collectionName: string,
220
220
  exact: string[],
221
221
  dirty: string[],
222
- forceFlags?: PendingForceFlags
222
+ forceFlags?: PendingForceFlags,
223
+ delayMs = WATCHER_RETRY_BACKOFF_MS
223
224
  ): void {
224
225
  queueWithoutSchedule(host, collectionName, exact, dirty, forceFlags);
225
226
  if (host.disposed()) {
@@ -241,7 +242,7 @@ export function requeueAfterFailure(
241
242
  host.timers.delete(collectionName);
242
243
  host.retryScheduled.delete(collectionName);
243
244
  startFlush(host, collectionName);
244
- }, WATCHER_RETRY_BACKOFF_MS)
245
+ }, delayMs)
245
246
  );
246
247
  }
247
248