@gmickel/gno 1.17.0 → 1.19.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 (167) hide show
  1. package/README.md +18 -4
  2. package/assets/skill/SKILL.md +44 -1
  3. package/assets/skill/mcp-reference.md +21 -0
  4. package/package.json +3 -2
  5. package/spec/AGENTS.md +83 -0
  6. package/spec/CLAUDE.md +83 -0
  7. package/spec/bench-fixture.schema.json +137 -0
  8. package/spec/cli.md +2894 -0
  9. package/spec/db/schema.sql +442 -0
  10. package/spec/evals-agentic.md +510 -0
  11. package/spec/evals.md +1106 -0
  12. package/spec/mcp.md +2229 -0
  13. package/spec/output-schemas/activation-verification.schema.json +515 -0
  14. package/spec/output-schemas/ask.schema.json +366 -0
  15. package/spec/output-schemas/backlinks.schema.json +131 -0
  16. package/spec/output-schemas/bench-result.schema.json +120 -0
  17. package/spec/output-schemas/capture-receipt.schema.json +143 -0
  18. package/spec/output-schemas/collection-list.schema.json +45 -0
  19. package/spec/output-schemas/context-capsule-v1.schema.json +691 -0
  20. package/spec/output-schemas/context-capsule-verification.schema.json +1338 -0
  21. package/spec/output-schemas/context-list.schema.json +21 -0
  22. package/spec/output-schemas/doctor.schema.json +313 -0
  23. package/spec/output-schemas/error.schema.json +30 -0
  24. package/spec/output-schemas/expansion.schema.json +37 -0
  25. package/spec/output-schemas/get.schema.json +140 -0
  26. package/spec/output-schemas/graph-query.schema.json +99 -0
  27. package/spec/output-schemas/graph.schema.json +371 -0
  28. package/spec/output-schemas/links-list.schema.json +186 -0
  29. package/spec/output-schemas/mcp-add-collection-result.schema.json +23 -0
  30. package/spec/output-schemas/mcp-capture-result.schema.json +152 -0
  31. package/spec/output-schemas/mcp-http-error.schema.json +30 -0
  32. package/spec/output-schemas/mcp-job-list.schema.json +58 -0
  33. package/spec/output-schemas/mcp-job-status.schema.json +224 -0
  34. package/spec/output-schemas/mcp-remove-result.schema.json +39 -0
  35. package/spec/output-schemas/mcp-sync-result.schema.json +41 -0
  36. package/spec/output-schemas/mcp-tag-result.schema.json +33 -0
  37. package/spec/output-schemas/models-list.schema.json +93 -0
  38. package/spec/output-schemas/multi-get.schema.json +103 -0
  39. package/spec/output-schemas/process-status.schema.json +119 -0
  40. package/spec/output-schemas/query-diagnose.schema.json +123 -0
  41. package/spec/output-schemas/resident-status.schema.json +154 -0
  42. package/spec/output-schemas/retrieval-trace-common.schema.json +492 -0
  43. package/spec/output-schemas/retrieval-trace-delete.schema.json +16 -0
  44. package/spec/output-schemas/retrieval-trace-export.schema.json +61 -0
  45. package/spec/output-schemas/retrieval-trace-filters.schema.json +139 -0
  46. package/spec/output-schemas/retrieval-trace-judgment.schema.json +15 -0
  47. package/spec/output-schemas/retrieval-trace-list.schema.json +18 -0
  48. package/spec/output-schemas/retrieval-trace-payloads.schema.json +178 -0
  49. package/spec/output-schemas/retrieval-trace-purge.schema.json +31 -0
  50. package/spec/output-schemas/retrieval-trace-qrels.schema.json +303 -0
  51. package/spec/output-schemas/retrieval-trace-replay.schema.json +286 -0
  52. package/spec/output-schemas/retrieval-trace-show.schema.json +69 -0
  53. package/spec/output-schemas/retrieval-trace-summary.schema.json +65 -0
  54. package/spec/output-schemas/search-result.schema.json +154 -0
  55. package/spec/output-schemas/search-results.schema.json +338 -0
  56. package/spec/output-schemas/similar.schema.json +84 -0
  57. package/spec/output-schemas/status.schema.json +676 -0
  58. package/spec/output-schemas/tags-list.schema.json +48 -0
  59. package/src/app/context-runtime-types.ts +3 -0
  60. package/src/app/context-runtime.ts +15 -1
  61. package/src/cli/commands/ask.ts +106 -36
  62. package/src/cli/commands/context-build.ts +56 -9
  63. package/src/cli/commands/daemon.ts +69 -2
  64. package/src/cli/commands/get.ts +64 -3
  65. package/src/cli/commands/models/pull.ts +13 -3
  66. package/src/cli/commands/query.ts +62 -23
  67. package/src/cli/commands/replay.ts +140 -0
  68. package/src/cli/commands/search.ts +48 -3
  69. package/src/cli/commands/shared.ts +3 -1
  70. package/src/cli/commands/status.ts +2 -0
  71. package/src/cli/commands/trace.ts +200 -0
  72. package/src/cli/commands/vsearch.ts +75 -53
  73. package/src/cli/detach.ts +37 -20
  74. package/src/cli/program.ts +329 -27
  75. package/src/config/index.ts +12 -0
  76. package/src/config/retrieval-traces.ts +56 -0
  77. package/src/config/types.ts +41 -0
  78. package/src/core/context-compiler.ts +11 -4
  79. package/src/core/job-manager.ts +19 -0
  80. package/src/core/mutation-generations.ts +33 -0
  81. package/src/core/retrieval-qrels.ts +405 -0
  82. package/src/core/retrieval-replay-candidate.ts +368 -0
  83. package/src/core/retrieval-replay-types.ts +109 -0
  84. package/src/core/retrieval-replay-validation.ts +89 -0
  85. package/src/core/retrieval-replay.ts +441 -0
  86. package/src/core/retrieval-trace-evidence-origin.ts +175 -0
  87. package/src/core/retrieval-trace-export.ts +113 -0
  88. package/src/core/retrieval-trace-filter-normalization.ts +27 -0
  89. package/src/core/retrieval-trace-filters.ts +19 -0
  90. package/src/core/retrieval-trace-management-helpers.ts +247 -0
  91. package/src/core/retrieval-trace-management-types.ts +132 -0
  92. package/src/core/retrieval-trace-management.ts +422 -0
  93. package/src/core/retrieval-trace-request.ts +141 -0
  94. package/src/core/retrieval-trace-session.ts +494 -0
  95. package/src/core/retrieval-trace.ts +472 -0
  96. package/src/llm/cache.ts +13 -3
  97. package/src/llm/nodeLlamaCpp/adapter.ts +10 -1
  98. package/src/llm/nodeLlamaCpp/lifecycle.ts +71 -0
  99. package/src/mcp/context.ts +161 -0
  100. package/src/mcp/http-security.ts +477 -0
  101. package/src/mcp/http-session.ts +272 -0
  102. package/src/mcp/http-transport.ts +370 -0
  103. package/src/mcp/resources/index.ts +141 -134
  104. package/src/mcp/server.ts +19 -79
  105. package/src/mcp/tools/add-collection.ts +3 -1
  106. package/src/mcp/tools/capture.ts +3 -0
  107. package/src/mcp/tools/clear-collection-embeddings.ts +2 -0
  108. package/src/mcp/tools/context.ts +68 -16
  109. package/src/mcp/tools/embed.ts +62 -52
  110. package/src/mcp/tools/get.ts +35 -1
  111. package/src/mcp/tools/index-cmd.ts +88 -74
  112. package/src/mcp/tools/index.ts +96 -2
  113. package/src/mcp/tools/query.ts +95 -64
  114. package/src/mcp/tools/remove-collection.ts +2 -0
  115. package/src/mcp/tools/search.ts +36 -13
  116. package/src/mcp/tools/status.ts +11 -0
  117. package/src/mcp/tools/sync.ts +16 -14
  118. package/src/mcp/tools/trace.ts +143 -0
  119. package/src/mcp/tools/vsearch.ts +71 -38
  120. package/src/mcp/tools/workspace-write.ts +7 -3
  121. package/src/pipeline/answer.ts +167 -26
  122. package/src/pipeline/graph-retrieval.ts +15 -1
  123. package/src/pipeline/hybrid.ts +151 -43
  124. package/src/pipeline/search.ts +36 -3
  125. package/src/pipeline/trace-metadata.ts +47 -0
  126. package/src/pipeline/types.ts +43 -0
  127. package/src/pipeline/vsearch.ts +101 -38
  128. package/src/sdk/client.ts +380 -71
  129. package/src/sdk/documents.ts +48 -1
  130. package/src/sdk/index.ts +17 -0
  131. package/src/sdk/types.ts +28 -0
  132. package/src/serve/background-runtime.ts +12 -212
  133. package/src/serve/context-capsule.ts +67 -8
  134. package/src/serve/embed-scheduler.ts +74 -43
  135. package/src/serve/index.ts +9 -0
  136. package/src/serve/jobs.ts +78 -80
  137. package/src/serve/public/app.tsx +12 -1
  138. package/src/serve/public/components/HealthCenter.tsx +74 -1
  139. package/src/serve/public/globals.built.css +1 -1
  140. package/src/serve/public/lib/workspace-tabs.ts +2 -0
  141. package/src/serve/public/pages/Dashboard.tsx +11 -0
  142. package/src/serve/public/pages/TraceHistory.tsx +478 -0
  143. package/src/serve/public/pages/trace-history-detail.tsx +224 -0
  144. package/src/serve/resident-admission.ts +159 -0
  145. package/src/serve/resident-background-work.ts +39 -0
  146. package/src/serve/resident-request.ts +55 -0
  147. package/src/serve/resident-runtime.ts +490 -0
  148. package/src/serve/resident-status.ts +96 -0
  149. package/src/serve/retrieval-trace.ts +28 -0
  150. package/src/serve/routes/api.ts +629 -239
  151. package/src/serve/routes/mcp.ts +69 -0
  152. package/src/serve/routes/traces.ts +156 -0
  153. package/src/serve/server.ts +276 -37
  154. package/src/serve/status-model.ts +51 -0
  155. package/src/serve/status.ts +5 -0
  156. package/src/store/index.ts +31 -0
  157. package/src/store/migrations/014-retrieval-traces.ts +303 -0
  158. package/src/store/migrations/index.ts +2 -0
  159. package/src/store/retrieval-trace-codec.ts +384 -0
  160. package/src/store/sqlite/adapter.ts +179 -10
  161. package/src/store/sqlite/retrieval-trace-management-store.ts +341 -0
  162. package/src/store/sqlite/retrieval-trace-retention.ts +349 -0
  163. package/src/store/sqlite/retrieval-trace-rows.ts +267 -0
  164. package/src/store/sqlite/retrieval-trace-store.ts +515 -0
  165. package/src/store/types.ts +297 -0
  166. package/src/store/vector/sqlite-vec.ts +76 -1
  167. package/src/store/vector/types.ts +1 -1
@@ -16,6 +16,7 @@ import type {
16
16
  Config,
17
17
  ModelPreset,
18
18
  } from "../../config/types";
19
+ import type { JobManager } from "../../core/job-manager";
19
20
  import type {
20
21
  AskResult,
21
22
  Citation,
@@ -32,6 +33,7 @@ import type {
32
33
  import type { DocumentEventBus } from "../doc-events";
33
34
  import type { EmbedScheduler } from "../embed-scheduler";
34
35
  import type { StartJobError } from "../jobs";
36
+ import type { ResidentStatus } from "../status-model";
35
37
  import type { CollectionWatchService } from "../watch-service";
36
38
 
37
39
  import { modelsPull } from "../../cli/commands/models/pull";
@@ -76,6 +78,11 @@ import {
76
78
  planMoveRefactor,
77
79
  planRenameRefactor,
78
80
  } from "../../core/file-refactors";
81
+ import {
82
+ hasContentMutation,
83
+ recordContentMutation,
84
+ recordIndexMutation,
85
+ } from "../../core/mutation-generations";
79
86
  import {
80
87
  resolveNoteCreatePlan,
81
88
  sanitizeNoteFilename,
@@ -87,6 +94,14 @@ import {
87
94
  resolveNotePreset,
88
95
  type NotePresetId,
89
96
  } from "../../core/note-presets";
97
+ import {
98
+ retrievalTraceFilters,
99
+ startRetrievalTraceRequest,
100
+ } from "../../core/retrieval-trace-request";
101
+ import {
102
+ evidenceFromExactDocument,
103
+ RetrievalTraceSession,
104
+ } from "../../core/retrieval-trace-session";
90
105
  import { extractSections } from "../../core/sections";
91
106
  import { normalizeStructuredQueryInput } from "../../core/structured-query";
92
107
  import {
@@ -111,7 +126,7 @@ import {
111
126
  } from "../../llm/registry";
112
127
  import {
113
128
  generateGroundedAnswer,
114
- processAnswerResult,
129
+ processAnswerResultWithTrace,
115
130
  } from "../../pipeline/answer";
116
131
  import { diagnoseQueryTarget } from "../../pipeline/diagnose";
117
132
  import { searchHybrid } from "../../pipeline/hybrid";
@@ -138,6 +153,10 @@ import {
138
153
  } from "../context";
139
154
  import { analyzeImportPath } from "../import-preview";
140
155
  import { getActiveJob, getJobStatus, startJob } from "../jobs";
156
+ import {
157
+ requestRetrievalTraceId,
158
+ withRetrievalTraceHeader,
159
+ } from "../retrieval-trace";
141
160
  import { buildAppStatus, type StatusBuildDeps } from "../status";
142
161
 
143
162
  /** Mutable context holder for hot-reloading presets */
@@ -147,6 +166,26 @@ export interface ContextHolder {
147
166
  scheduler: EmbedScheduler | null;
148
167
  eventBus: DocumentEventBus | null;
149
168
  watchService: CollectionWatchService | null;
169
+ jobManager?: JobManager;
170
+ markContentMutation?: () => void;
171
+ markIndexMutation?: () => void;
172
+ startBackgroundWork?: (
173
+ operation: (signal: AbortSignal) => Promise<void>
174
+ ) => boolean;
175
+ }
176
+
177
+ async function syncResidentCollection(
178
+ ctxHolder: ContextHolder,
179
+ collection: Collection,
180
+ store: SqliteAdapter,
181
+ options: Parameters<typeof defaultSyncService.syncCollection>[2],
182
+ syncCollection: typeof defaultSyncService.syncCollection = defaultSyncService.syncCollection.bind(
183
+ defaultSyncService
184
+ )
185
+ ): ReturnType<typeof defaultSyncService.syncCollection> {
186
+ const result = await syncCollection(collection, store, options);
187
+ recordContentMutation(result, ctxHolder.markContentMutation);
188
+ return result;
150
189
  }
151
190
 
152
191
  // ─────────────────────────────────────────────────────────────────────────────
@@ -433,6 +472,65 @@ function errorResponse(
433
472
  );
434
473
  }
435
474
 
475
+ interface RestTraceStart {
476
+ session: RetrievalTraceSession | null;
477
+ error: Response | null;
478
+ }
479
+
480
+ async function startRestTrace(
481
+ ctx: ServerContext,
482
+ input: {
483
+ query: string;
484
+ goal?: string;
485
+ filters?: Record<string, unknown>;
486
+ pipeline: string;
487
+ modelUris?: string[];
488
+ }
489
+ ): Promise<RestTraceStart> {
490
+ const started = await startRetrievalTraceRequest({
491
+ store: ctx.store,
492
+ config: ctx.config,
493
+ query: input.query,
494
+ goal: input.goal,
495
+ filters: input.filters,
496
+ pipeline: input.pipeline,
497
+ indexName: ctx.indexName,
498
+ modelUris: input.modelUris,
499
+ });
500
+ return started.ok
501
+ ? { session: started.value, error: null }
502
+ : {
503
+ session: null,
504
+ error: errorResponse(
505
+ "RUNTIME",
506
+ `Retrieval trace start failed: ${started.error.message}`,
507
+ 500
508
+ ),
509
+ };
510
+ }
511
+
512
+ async function finishRestTrace(
513
+ request: Request,
514
+ session: RetrievalTraceSession | null,
515
+ status: "completed" | "partial" | "failed",
516
+ response: Response
517
+ ): Promise<Response> {
518
+ if (!session) return response;
519
+ const terminalStatus = request.signal.aborted ? "cancelled" : status;
520
+ const finished = await session.finish(terminalStatus);
521
+ if (!finished.ok) {
522
+ return withRetrievalTraceHeader(
523
+ errorResponse(
524
+ "RUNTIME",
525
+ `Retrieval trace finalization failed: ${finished.error.message}`,
526
+ 500
527
+ ),
528
+ session
529
+ );
530
+ }
531
+ return withRetrievalTraceHeader(response, session);
532
+ }
533
+
436
534
  function jobConflictResponse(jobResult: StartJobError): Response {
437
535
  return errorResponse("CONFLICT", jobResult.error, 409, {
438
536
  activeJobId: jobResult.activeJobId,
@@ -815,6 +913,12 @@ export async function handleStatus(
815
913
  }
816
914
  }
817
915
 
916
+ export function handleResidentStatus(
917
+ getStatus: () => ResidentStatus
918
+ ): Response {
919
+ return jsonResponse(getStatus());
920
+ }
921
+
818
922
  /**
819
923
  * GET /api/collections
820
924
  * Returns list of collections.
@@ -990,33 +1094,38 @@ export async function handleCreateCollection(
990
1094
  if (!collection) {
991
1095
  return errorResponse("RUNTIME", "Collection not found after add", 500);
992
1096
  }
993
- const jobResult = startJob("add", async (): Promise<SyncResult> => {
994
- const result = await defaultSyncService.syncCollection(
995
- collection,
996
- store,
997
- withContentTypeRules(
998
- {
999
- gitPull: body.gitPull,
1000
- runUpdateCmd: true,
1001
- },
1002
- syncResult.config
1003
- )
1004
- );
1005
- if (result.filesAdded > 0 || result.filesUpdated > 0) {
1006
- if (ctxHolder.scheduler) {
1007
- await ctxHolder.scheduler.triggerNow();
1097
+ const jobResult = await startJob(
1098
+ "add",
1099
+ async (): Promise<SyncResult> => {
1100
+ const result = await syncResidentCollection(
1101
+ ctxHolder,
1102
+ collection,
1103
+ store,
1104
+ withContentTypeRules(
1105
+ {
1106
+ gitPull: body.gitPull,
1107
+ runUpdateCmd: true,
1108
+ },
1109
+ syncResult.config
1110
+ )
1111
+ );
1112
+ if (result.filesAdded > 0 || result.filesUpdated > 0) {
1113
+ if (ctxHolder.scheduler) {
1114
+ await ctxHolder.scheduler.triggerNow();
1115
+ }
1008
1116
  }
1009
- }
1010
- return {
1011
- collections: [result],
1012
- totalDurationMs: result.durationMs,
1013
- totalFilesProcessed: result.filesProcessed,
1014
- totalFilesAdded: result.filesAdded,
1015
- totalFilesUpdated: result.filesUpdated,
1016
- totalFilesErrored: result.filesErrored,
1017
- totalFilesSkipped: result.filesSkipped,
1018
- };
1019
- });
1117
+ return {
1118
+ collections: [result],
1119
+ totalDurationMs: result.durationMs,
1120
+ totalFilesProcessed: result.filesProcessed,
1121
+ totalFilesAdded: result.filesAdded,
1122
+ totalFilesUpdated: result.filesUpdated,
1123
+ totalFilesErrored: result.filesErrored,
1124
+ totalFilesSkipped: result.filesSkipped,
1125
+ };
1126
+ },
1127
+ ctxHolder.jobManager
1128
+ );
1020
1129
 
1021
1130
  if (!jobResult.ok) {
1022
1131
  return jobConflictResponse(jobResult);
@@ -1100,7 +1209,8 @@ export async function handleDeleteCollection(
1100
1209
  const status = statusMap[syncResult.code] ?? 500;
1101
1210
  return errorResponse(syncResult.code, syncResult.error, status);
1102
1211
  }
1103
-
1212
+ ctxHolder.markContentMutation?.();
1213
+ ctxHolder.markIndexMutation?.();
1104
1214
  return jsonResponse({
1105
1215
  success: true,
1106
1216
  collection: name,
@@ -1231,6 +1341,7 @@ export async function handleClearCollectionEmbeddings(
1231
1341
  const status = result.error.code === "INVALID_INPUT" ? 400 : 500;
1232
1342
  return errorResponse(result.error.code, result.error.message, status);
1233
1343
  }
1344
+ recordIndexMutation(result.value.deletedVectors, ctxHolder.markIndexMutation);
1234
1345
 
1235
1346
  return jsonResponse({
1236
1347
  success: true,
@@ -1292,25 +1403,30 @@ export async function handleSync(
1292
1403
  }
1293
1404
 
1294
1405
  // Start background sync job
1295
- const jobResult = startJob("sync", async (): Promise<SyncResult> => {
1296
- const result = await defaultSyncService.syncAll(
1297
- collections,
1298
- store,
1299
- withContentTypeRules(
1300
- {
1301
- gitPull: body.gitPull,
1302
- runUpdateCmd: true,
1303
- },
1304
- ctxHolder.config
1305
- )
1306
- );
1307
- if (result.totalFilesAdded > 0 || result.totalFilesUpdated > 0) {
1308
- if (ctxHolder.scheduler) {
1309
- await ctxHolder.scheduler.triggerNow();
1406
+ const jobResult = await startJob(
1407
+ "sync",
1408
+ async (): Promise<SyncResult> => {
1409
+ const result = await defaultSyncService.syncAll(
1410
+ collections,
1411
+ store,
1412
+ withContentTypeRules(
1413
+ {
1414
+ gitPull: body.gitPull,
1415
+ runUpdateCmd: true,
1416
+ },
1417
+ ctxHolder.config
1418
+ )
1419
+ );
1420
+ recordContentMutation(result, ctxHolder.markContentMutation);
1421
+ if (hasContentMutation(result)) {
1422
+ if (ctxHolder.scheduler) {
1423
+ await ctxHolder.scheduler.triggerNow();
1424
+ }
1310
1425
  }
1311
- }
1312
- return result;
1313
- });
1426
+ return result;
1427
+ },
1428
+ ctxHolder.jobManager
1429
+ );
1314
1430
 
1315
1431
  if (!jobResult.ok) {
1316
1432
  return jobConflictResponse(jobResult);
@@ -1594,7 +1710,8 @@ export async function handleDocsAutocomplete(
1594
1710
  export async function handleDoc(
1595
1711
  store: SqliteAdapter,
1596
1712
  config: Config,
1597
- url: URL
1713
+ url: URL,
1714
+ request?: Request
1598
1715
  ): Promise<Response> {
1599
1716
  const uri = url.searchParams.get("uri");
1600
1717
  if (!uri) {
@@ -1634,7 +1751,7 @@ export async function handleDoc(
1634
1751
  });
1635
1752
  const source = await buildSourceMeta(config.collections, doc);
1636
1753
 
1637
- return jsonResponse({
1754
+ const responseData = {
1638
1755
  docid: doc.docid,
1639
1756
  uri: doc.uri,
1640
1757
  title: doc.title,
@@ -1645,7 +1762,52 @@ export async function handleDoc(
1645
1762
  tags,
1646
1763
  source,
1647
1764
  capabilities,
1765
+ };
1766
+ const continuationTraceId = request
1767
+ ? requestRetrievalTraceId(request)
1768
+ : undefined;
1769
+ if (!continuationTraceId) return jsonResponse(responseData);
1770
+
1771
+ const resumed = await RetrievalTraceSession.resume({
1772
+ store,
1773
+ config: config.retrievalTraces,
1774
+ traceId: continuationTraceId,
1648
1775
  });
1776
+ if (!resumed.ok) {
1777
+ return errorResponse("RUNTIME", resumed.error.message, 500);
1778
+ }
1779
+ const traceSession = resumed.value;
1780
+ if (!traceSession) return jsonResponse(responseData);
1781
+
1782
+ if (content !== null) {
1783
+ const endLine = content.split("\n").length;
1784
+ const evidence = evidenceFromExactDocument({
1785
+ docid: doc.docid,
1786
+ uri: doc.uri,
1787
+ sourceHash: doc.sourceHash,
1788
+ mirrorHash: doc.mirrorHash ?? undefined,
1789
+ content,
1790
+ startLine: 1,
1791
+ endLine,
1792
+ });
1793
+ if (evidence) {
1794
+ const got = await traceSession.recordEvidence("get", [evidence]);
1795
+ if (!got.ok) {
1796
+ return withRetrievalTraceHeader(
1797
+ errorResponse("RUNTIME", got.error.message, 500),
1798
+ traceSession
1799
+ );
1800
+ }
1801
+ const opened = await traceSession.recordEvidence("open", [evidence]);
1802
+ if (!opened.ok) {
1803
+ return withRetrievalTraceHeader(
1804
+ errorResponse("RUNTIME", opened.error.message, 500),
1805
+ traceSession
1806
+ );
1807
+ }
1808
+ }
1809
+ }
1810
+ return withRetrievalTraceHeader(jsonResponse(responseData), traceSession);
1649
1811
  }
1650
1812
 
1651
1813
  /**
@@ -1797,6 +1959,7 @@ export async function handleTags(
1797
1959
  * Deactivate a document (soft delete - does not remove file from disk).
1798
1960
  */
1799
1961
  export async function handleDeactivateDoc(
1962
+ ctxHolder: ContextHolder,
1800
1963
  store: SqliteAdapter,
1801
1964
  docId: string,
1802
1965
  req?: Request
@@ -1821,6 +1984,10 @@ export async function handleDeactivateDoc(
1821
1984
  if (!result.ok) {
1822
1985
  return errorResponse("RUNTIME", result.error.message, 500);
1823
1986
  }
1987
+ if (result.value > 0) {
1988
+ ctxHolder.markContentMutation?.();
1989
+ ctxHolder.markIndexMutation?.();
1990
+ }
1824
1991
 
1825
1992
  return jsonResponse({
1826
1993
  success: true,
@@ -1929,10 +2096,12 @@ export async function handleRenameDoc(
1929
2096
  const nextUri = `gno://${collection.name}/${nextRelPath}`;
1930
2097
  let warning: string | undefined;
1931
2098
  try {
1932
- await syncCollection(
2099
+ await syncResidentCollection(
2100
+ ctxHolder,
1933
2101
  collection,
1934
2102
  store,
1935
- withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
2103
+ withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config),
2104
+ syncCollection
1936
2105
  );
1937
2106
  } catch {
1938
2107
  warning =
@@ -2151,10 +2320,12 @@ export async function handleTrashDoc(
2151
2320
  }
2152
2321
  let warning: string | undefined;
2153
2322
  try {
2154
- await syncCollection(
2323
+ await syncResidentCollection(
2324
+ ctxHolder,
2155
2325
  collection,
2156
2326
  store,
2157
- withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
2327
+ withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config),
2328
+ syncCollection
2158
2329
  );
2159
2330
  } catch {
2160
2331
  warning =
@@ -2274,7 +2445,8 @@ export async function handleMoveDoc(
2274
2445
  await renameFilePath(fullPath, nextFullPath);
2275
2446
  let warning: string | undefined;
2276
2447
  try {
2277
- await defaultSyncService.syncCollection(
2448
+ await syncResidentCollection(
2449
+ ctxHolder,
2278
2450
  collection,
2279
2451
  store,
2280
2452
  withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
@@ -2397,7 +2569,8 @@ export async function handleDuplicateDoc(
2397
2569
  await copyFilePath(fullPath, nextFullPath);
2398
2570
  let warning: string | undefined;
2399
2571
  try {
2400
- await defaultSyncService.syncCollection(
2572
+ await syncResidentCollection(
2573
+ ctxHolder,
2401
2574
  collection,
2402
2575
  store,
2403
2576
  withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
@@ -2546,7 +2719,10 @@ export async function handleUpdateDoc(
2546
2719
  ctxHolder: ContextHolder,
2547
2720
  store: SqliteAdapter,
2548
2721
  docId: string,
2549
- req: Request
2722
+ req: Request,
2723
+ deps?: {
2724
+ syncCollection?: typeof defaultSyncService.syncCollection;
2725
+ }
2550
2726
  ): Promise<Response> {
2551
2727
  let body: UpdateDocRequestBody;
2552
2728
  try {
@@ -2726,32 +2902,38 @@ export async function handleUpdateDoc(
2726
2902
  // Note: embedding handled separately by embed-scheduler (not inline)
2727
2903
  let jobId: string | null = null;
2728
2904
  if (contentToWrite !== undefined) {
2729
- const jobResult = startJob("sync", async (): Promise<SyncResult> => {
2730
- const result = await defaultSyncService.syncCollection(
2731
- collection,
2732
- store,
2733
- withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
2734
- );
2735
- // Notify scheduler after sync completes
2736
- ctxHolder.scheduler?.notifySyncComplete([doc.docid]);
2737
- ctxHolder.eventBus?.emit({
2738
- type: "document-changed",
2739
- uri: doc.uri,
2740
- collection: doc.collection,
2741
- relPath: doc.relPath,
2742
- origin: "save",
2743
- changedAt: new Date().toISOString(),
2744
- });
2745
- return {
2746
- collections: [result],
2747
- totalDurationMs: result.durationMs,
2748
- totalFilesProcessed: result.filesProcessed,
2749
- totalFilesAdded: result.filesAdded,
2750
- totalFilesUpdated: result.filesUpdated,
2751
- totalFilesErrored: result.filesErrored,
2752
- totalFilesSkipped: result.filesSkipped,
2753
- };
2754
- });
2905
+ const jobResult = await startJob(
2906
+ "sync",
2907
+ async (): Promise<SyncResult> => {
2908
+ const result = await syncResidentCollection(
2909
+ ctxHolder,
2910
+ collection,
2911
+ store,
2912
+ withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config),
2913
+ deps?.syncCollection
2914
+ );
2915
+ // Notify scheduler after sync completes
2916
+ ctxHolder.scheduler?.notifySyncComplete([doc.docid]);
2917
+ ctxHolder.eventBus?.emit({
2918
+ type: "document-changed",
2919
+ uri: doc.uri,
2920
+ collection: doc.collection,
2921
+ relPath: doc.relPath,
2922
+ origin: "save",
2923
+ changedAt: new Date().toISOString(),
2924
+ });
2925
+ return {
2926
+ collections: [result],
2927
+ totalDurationMs: result.durationMs,
2928
+ totalFilesProcessed: result.filesProcessed,
2929
+ totalFilesAdded: result.filesAdded,
2930
+ totalFilesUpdated: result.filesUpdated,
2931
+ totalFilesErrored: result.filesErrored,
2932
+ totalFilesSkipped: result.filesSkipped,
2933
+ };
2934
+ },
2935
+ ctxHolder.jobManager
2936
+ );
2755
2937
  jobId = jobResult.ok ? jobResult.jobId : null;
2756
2938
  }
2757
2939
 
@@ -2906,7 +3088,10 @@ export async function handleCreateEditableCopy(
2906
3088
  export async function handleCreateCapture(
2907
3089
  ctxHolder: ContextHolder,
2908
3090
  store: SqliteAdapter,
2909
- req: Request
3091
+ req: Request,
3092
+ deps?: {
3093
+ syncCollection?: typeof defaultSyncService.syncCollection;
3094
+ }
2910
3095
  ): Promise<Response> {
2911
3096
  let body: CreateCaptureRequestBody;
2912
3097
  try {
@@ -2994,31 +3179,37 @@ export async function handleCreateCapture(
2994
3179
  await writeCapturePlanFile(plan, fullPath);
2995
3180
 
2996
3181
  const gnoUri = `gno://${collection.name}/${plan.relPath}`;
2997
- const jobResult = startJob("sync", async (): Promise<SyncResult> => {
2998
- const result = await defaultSyncService.syncCollection(
2999
- collection,
3000
- store,
3001
- withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
3002
- );
3003
- ctxHolder.scheduler?.notifySyncComplete([plan.relPath]);
3004
- ctxHolder.eventBus?.emit({
3005
- type: "document-changed",
3006
- uri: gnoUri,
3007
- collection: collection.name,
3008
- relPath: plan.relPath,
3009
- origin: "create",
3010
- changedAt: new Date().toISOString(),
3011
- });
3012
- return {
3013
- collections: [result],
3014
- totalDurationMs: result.durationMs,
3015
- totalFilesProcessed: result.filesProcessed,
3016
- totalFilesAdded: result.filesAdded,
3017
- totalFilesUpdated: result.filesUpdated,
3018
- totalFilesErrored: result.filesErrored,
3019
- totalFilesSkipped: result.filesSkipped,
3020
- };
3021
- });
3182
+ const jobResult = await startJob(
3183
+ "sync",
3184
+ async (): Promise<SyncResult> => {
3185
+ const result = await syncResidentCollection(
3186
+ ctxHolder,
3187
+ collection,
3188
+ store,
3189
+ withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config),
3190
+ deps?.syncCollection
3191
+ );
3192
+ ctxHolder.scheduler?.notifySyncComplete([plan.relPath]);
3193
+ ctxHolder.eventBus?.emit({
3194
+ type: "document-changed",
3195
+ uri: gnoUri,
3196
+ collection: collection.name,
3197
+ relPath: plan.relPath,
3198
+ origin: "create",
3199
+ changedAt: new Date().toISOString(),
3200
+ });
3201
+ return {
3202
+ collections: [result],
3203
+ totalDurationMs: result.durationMs,
3204
+ totalFilesProcessed: result.filesProcessed,
3205
+ totalFilesAdded: result.filesAdded,
3206
+ totalFilesUpdated: result.filesUpdated,
3207
+ totalFilesErrored: result.filesErrored,
3208
+ totalFilesSkipped: result.filesSkipped,
3209
+ };
3210
+ },
3211
+ ctxHolder.jobManager
3212
+ );
3022
3213
 
3023
3214
  return jsonResponse(
3024
3215
  buildCaptureReceipt({
@@ -3058,7 +3249,10 @@ export async function handleCreateCapture(
3058
3249
  export async function handleCreateDoc(
3059
3250
  ctxHolder: ContextHolder,
3060
3251
  store: SqliteAdapter,
3061
- req: Request
3252
+ req: Request,
3253
+ deps?: {
3254
+ syncCollection?: typeof defaultSyncService.syncCollection;
3255
+ }
3062
3256
  ): Promise<Response> {
3063
3257
  let body: CreateDocRequestBody;
3064
3258
  try {
@@ -3230,34 +3424,40 @@ export async function handleCreateDoc(
3230
3424
 
3231
3425
  // Run sync via job system (non-blocking)
3232
3426
  // Note: embedding handled separately by embed-scheduler (not inline)
3233
- const jobResult = startJob("sync", async (): Promise<SyncResult> => {
3234
- const result = await defaultSyncService.syncCollection(
3235
- collection,
3236
- store,
3237
- withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
3238
- );
3239
- // Notify scheduler after sync completes (use gnoUri as docid placeholder)
3240
- // The sync will create a proper docid, but we don't have it here yet
3241
- // Using normalizedRelPath as identifier since docid is generated during sync
3242
- ctxHolder.scheduler?.notifySyncComplete([normalizedRelPath]);
3243
- ctxHolder.eventBus?.emit({
3244
- type: "document-changed",
3245
- uri: gnoUri,
3246
- collection: collection.name,
3247
- relPath: normalizedRelPath,
3248
- origin: "create",
3249
- changedAt: new Date().toISOString(),
3250
- });
3251
- return {
3252
- collections: [result],
3253
- totalDurationMs: result.durationMs,
3254
- totalFilesProcessed: result.filesProcessed,
3255
- totalFilesAdded: result.filesAdded,
3256
- totalFilesUpdated: result.filesUpdated,
3257
- totalFilesErrored: result.filesErrored,
3258
- totalFilesSkipped: result.filesSkipped,
3259
- };
3260
- });
3427
+ const jobResult = await startJob(
3428
+ "sync",
3429
+ async (): Promise<SyncResult> => {
3430
+ const result = await syncResidentCollection(
3431
+ ctxHolder,
3432
+ collection,
3433
+ store,
3434
+ withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config),
3435
+ deps?.syncCollection
3436
+ );
3437
+ // Notify scheduler after sync completes (use gnoUri as docid placeholder)
3438
+ // The sync will create a proper docid, but we don't have it here yet
3439
+ // Using normalizedRelPath as identifier since docid is generated during sync
3440
+ ctxHolder.scheduler?.notifySyncComplete([normalizedRelPath]);
3441
+ ctxHolder.eventBus?.emit({
3442
+ type: "document-changed",
3443
+ uri: gnoUri,
3444
+ collection: collection.name,
3445
+ relPath: normalizedRelPath,
3446
+ origin: "create",
3447
+ changedAt: new Date().toISOString(),
3448
+ });
3449
+ return {
3450
+ collections: [result],
3451
+ totalDurationMs: result.durationMs,
3452
+ totalFilesProcessed: result.filesProcessed,
3453
+ totalFilesAdded: result.filesAdded,
3454
+ totalFilesUpdated: result.filesUpdated,
3455
+ totalFilesErrored: result.filesErrored,
3456
+ totalFilesSkipped: result.filesSkipped,
3457
+ };
3458
+ },
3459
+ ctxHolder.jobManager
3460
+ );
3261
3461
 
3262
3462
  return jsonResponse(
3263
3463
  {
@@ -3289,9 +3489,13 @@ export async function handleCreateDoc(
3289
3489
  * Returns search results.
3290
3490
  */
3291
3491
  export async function handleSearch(
3292
- store: SqliteAdapter,
3492
+ contextOrStore: ServerContext | SqliteAdapter,
3293
3493
  req: Request
3294
3494
  ): Promise<Response> {
3495
+ const context =
3496
+ "store" in contextOrStore ? contextOrStore : (null as ServerContext | null);
3497
+ const store =
3498
+ "store" in contextOrStore ? contextOrStore.store : contextOrStore;
3295
3499
  let body: SearchRequestBody;
3296
3500
  try {
3297
3501
  body = (await req.json()) as SearchRequestBody;
@@ -3403,13 +3607,30 @@ export async function handleSearch(
3403
3607
  author,
3404
3608
  };
3405
3609
 
3406
- const result = await searchBm25(store, rawQuery, options);
3610
+ const trace = context
3611
+ ? await startRestTrace(context, {
3612
+ query: rawQuery,
3613
+ filters: retrievalTraceFilters(options),
3614
+ pipeline: "bm25",
3615
+ })
3616
+ : { session: null, error: null };
3617
+ if (trace.error) return trace.error;
3618
+
3619
+ const result = await searchBm25(store, rawQuery, {
3620
+ ...options,
3621
+ traceSession: trace.session ?? undefined,
3622
+ });
3407
3623
 
3408
3624
  if (!result.ok) {
3409
- return errorResponse("RUNTIME", result.error.message, 500);
3625
+ return finishRestTrace(
3626
+ req,
3627
+ trace.session,
3628
+ "failed",
3629
+ errorResponse("RUNTIME", result.error.message, 500)
3630
+ );
3410
3631
  }
3411
3632
 
3412
- return jsonResponse(result.value);
3633
+ return withRetrievalTraceHeader(jsonResponse(result.value), trace.session);
3413
3634
  }
3414
3635
 
3415
3636
  /**
@@ -3543,6 +3764,41 @@ export async function handleQuery(
3543
3764
  : undefined;
3544
3765
  const author = body.author?.trim() || undefined;
3545
3766
 
3767
+ const queryOptions = {
3768
+ limit: Math.min(body.limit ?? 20, 50),
3769
+ minScore: body.minScore,
3770
+ collection: body.collection,
3771
+ lang: body.lang,
3772
+ intent: body.intent?.trim() || undefined,
3773
+ candidateLimit:
3774
+ body.candidateLimit !== undefined
3775
+ ? Math.min(body.candidateLimit, 100)
3776
+ : undefined,
3777
+ exclude,
3778
+ queryModes: normalizedQueryModes,
3779
+ noExpand: body.noExpand,
3780
+ noRerank: body.noRerank,
3781
+ graph: body.graph === true,
3782
+ noGraph: body.noGraph,
3783
+ tagsAll,
3784
+ tagsAny,
3785
+ since: body.since,
3786
+ until: body.until,
3787
+ categories,
3788
+ author,
3789
+ };
3790
+ const trace = await startRestTrace(ctx, {
3791
+ query: normalizedQuery,
3792
+ filters: retrievalTraceFilters(queryOptions),
3793
+ pipeline: "hybrid",
3794
+ modelUris: [
3795
+ ctx.embedPort?.modelUri,
3796
+ ctx.expandPort?.modelUri,
3797
+ ctx.rerankPort?.modelUri,
3798
+ ].filter((value): value is string => Boolean(value)),
3799
+ });
3800
+ if (trace.error) return trace.error;
3801
+
3546
3802
  const result = await searchHybrid(
3547
3803
  {
3548
3804
  store: ctx.store,
@@ -3554,35 +3810,21 @@ export async function handleQuery(
3554
3810
  },
3555
3811
  normalizedQuery,
3556
3812
  {
3557
- limit: Math.min(body.limit ?? 20, 50),
3558
- minScore: body.minScore,
3559
- collection: body.collection,
3560
- lang: body.lang,
3561
- intent: body.intent?.trim() || undefined,
3562
- candidateLimit:
3563
- body.candidateLimit !== undefined
3564
- ? Math.min(body.candidateLimit, 100)
3565
- : undefined,
3566
- exclude,
3567
- queryModes: normalizedQueryModes,
3568
- noExpand: body.noExpand,
3569
- noRerank: body.noRerank,
3570
- graph: body.graph === true,
3571
- noGraph: body.noGraph,
3572
- tagsAll,
3573
- tagsAny,
3574
- since: body.since,
3575
- until: body.until,
3576
- categories,
3577
- author,
3813
+ ...queryOptions,
3814
+ traceSession: trace.session ?? undefined,
3578
3815
  }
3579
3816
  );
3580
3817
 
3581
3818
  if (!result.ok) {
3582
- return errorResponse("RUNTIME", result.error.message, 500);
3819
+ return finishRestTrace(
3820
+ req,
3821
+ trace.session,
3822
+ "failed",
3823
+ errorResponse("RUNTIME", result.error.message, 500)
3824
+ );
3583
3825
  }
3584
3826
 
3585
- return jsonResponse(result.value);
3827
+ return withRetrievalTraceHeader(jsonResponse(result.value), trace.session);
3586
3828
  }
3587
3829
 
3588
3830
  /**
@@ -3795,15 +4037,6 @@ export async function handleAsk(
3795
4037
  return errorResponse("VALIDATION", "Query cannot be empty");
3796
4038
  }
3797
4039
 
3798
- // Check if answer generation is available
3799
- if (!ctx.capabilities.answer) {
3800
- return errorResponse(
3801
- "UNAVAILABLE",
3802
- "Answer generation not available. No answer model loaded.",
3803
- 503
3804
- );
3805
- }
3806
-
3807
4040
  // Parse tag filters
3808
4041
  let tagsAll: string[] | undefined;
3809
4042
  let tagsAny: string[] | undefined;
@@ -3890,6 +4123,64 @@ export async function handleAsk(
3890
4123
  const author = body.author?.trim() || undefined;
3891
4124
 
3892
4125
  const limit = Math.min(body.limit ?? 5, 20);
4126
+ const askOptions = {
4127
+ limit,
4128
+ collection: body.collection,
4129
+ lang: body.lang,
4130
+ intent: body.intent?.trim() || undefined,
4131
+ noExpand: body.noExpand,
4132
+ noRerank: body.noRerank,
4133
+ candidateLimit:
4134
+ body.candidateLimit !== undefined
4135
+ ? Math.min(body.candidateLimit, 100)
4136
+ : undefined,
4137
+ exclude,
4138
+ queryModes: normalizedQueryModes,
4139
+ tagsAll,
4140
+ tagsAny,
4141
+ since: body.since,
4142
+ until: body.until,
4143
+ categories,
4144
+ author,
4145
+ };
4146
+ const trace = await startRestTrace(ctx, {
4147
+ query: normalizedQuery,
4148
+ filters: retrievalTraceFilters(askOptions),
4149
+ pipeline: "ask",
4150
+ modelUris: [
4151
+ ctx.embedPort?.modelUri,
4152
+ ctx.expandPort?.modelUri,
4153
+ ctx.answerPort?.modelUri,
4154
+ ctx.rerankPort?.modelUri,
4155
+ ].filter((value): value is string => Boolean(value)),
4156
+ });
4157
+ if (trace.error) return trace.error;
4158
+
4159
+ if (!ctx.capabilities.answer) {
4160
+ const unavailable = await trace.session?.recordCapability(
4161
+ "answer_generation",
4162
+ "unavailable",
4163
+ "model_unavailable"
4164
+ );
4165
+ if (unavailable && !unavailable.ok) {
4166
+ return finishRestTrace(
4167
+ req,
4168
+ trace.session,
4169
+ "failed",
4170
+ errorResponse("RUNTIME", unavailable.error.message, 500)
4171
+ );
4172
+ }
4173
+ return finishRestTrace(
4174
+ req,
4175
+ trace.session,
4176
+ "failed",
4177
+ errorResponse(
4178
+ "UNAVAILABLE",
4179
+ "Answer generation not available. No answer model loaded.",
4180
+ 503
4181
+ )
4182
+ );
4183
+ }
3893
4184
 
3894
4185
  // Run hybrid search first
3895
4186
  const searchResult = await searchHybrid(
@@ -3903,29 +4194,18 @@ export async function handleAsk(
3903
4194
  },
3904
4195
  normalizedQuery,
3905
4196
  {
3906
- limit,
3907
- collection: body.collection,
3908
- lang: body.lang,
3909
- intent: body.intent?.trim() || undefined,
3910
- noExpand: body.noExpand,
3911
- noRerank: body.noRerank,
3912
- candidateLimit:
3913
- body.candidateLimit !== undefined
3914
- ? Math.min(body.candidateLimit, 100)
3915
- : undefined,
3916
- exclude,
3917
- queryModes: normalizedQueryModes,
3918
- tagsAll,
3919
- tagsAny,
3920
- since: body.since,
3921
- until: body.until,
3922
- categories,
3923
- author,
4197
+ ...askOptions,
4198
+ traceSession: trace.session ?? undefined,
3924
4199
  }
3925
4200
  );
3926
4201
 
3927
4202
  if (!searchResult.ok) {
3928
- return errorResponse("RUNTIME", searchResult.error.message, 500);
4203
+ return finishRestTrace(
4204
+ req,
4205
+ trace.session,
4206
+ "failed",
4207
+ errorResponse("RUNTIME", searchResult.error.message, 500)
4208
+ );
3929
4209
  }
3930
4210
 
3931
4211
  const results = searchResult.value.results;
@@ -3937,20 +4217,93 @@ export async function handleAsk(
3937
4217
  let answerGenerated = false;
3938
4218
 
3939
4219
  if (ctx.answerPort) {
3940
- const maxTokens = body.maxAnswerTokens ?? 512;
3941
- const rawResult = await generateGroundedAnswer(
3942
- { genPort: ctx.answerPort, store: ctx.store },
3943
- normalizedQuery,
3944
- results,
3945
- maxTokens
4220
+ const attempted = await trace.session?.recordCapability(
4221
+ "answer_generation",
4222
+ "attempted"
3946
4223
  );
4224
+ if (attempted && !attempted.ok) {
4225
+ return finishRestTrace(
4226
+ req,
4227
+ trace.session,
4228
+ "failed",
4229
+ errorResponse("RUNTIME", attempted.error.message, 500)
4230
+ );
4231
+ }
4232
+ const maxTokens = body.maxAnswerTokens ?? 512;
4233
+ let rawResult: Awaited<ReturnType<typeof generateGroundedAnswer>>;
4234
+ try {
4235
+ rawResult = await generateGroundedAnswer(
4236
+ { genPort: ctx.answerPort, store: ctx.store },
4237
+ normalizedQuery,
4238
+ results,
4239
+ maxTokens
4240
+ );
4241
+ } catch (error) {
4242
+ await trace.session?.recordCapability(
4243
+ "answer_generation",
4244
+ "failed",
4245
+ "generation_failed"
4246
+ );
4247
+ return finishRestTrace(
4248
+ req,
4249
+ trace.session,
4250
+ "failed",
4251
+ errorResponse(
4252
+ "RUNTIME",
4253
+ error instanceof Error ? error.message : String(error),
4254
+ 500
4255
+ )
4256
+ );
4257
+ }
3947
4258
 
3948
4259
  if (rawResult) {
3949
- const processed = processAnswerResult(rawResult);
3950
- answer = processed.answer;
3951
- citations = processed.citations;
3952
- answerContext = processed.answerContext;
3953
- answerGenerated = true;
4260
+ try {
4261
+ const processed = await processAnswerResultWithTrace(
4262
+ rawResult,
4263
+ trace.session ?? undefined
4264
+ );
4265
+ answer = processed.answer;
4266
+ citations = processed.citations;
4267
+ answerContext = processed.answerContext;
4268
+ answerGenerated = true;
4269
+ const used = await trace.session?.recordCapability(
4270
+ "answer_generation",
4271
+ "used"
4272
+ );
4273
+ if (used && !used.ok) {
4274
+ throw new Error(`Trace recording failed: ${used.error.message}`);
4275
+ }
4276
+ } catch (error) {
4277
+ await trace.session?.recordCapability(
4278
+ "answer_generation",
4279
+ "failed",
4280
+ "generation_failed"
4281
+ );
4282
+ return finishRestTrace(
4283
+ req,
4284
+ trace.session,
4285
+ "failed",
4286
+ errorResponse(
4287
+ "RUNTIME",
4288
+ error instanceof Error ? error.message : String(error),
4289
+ 500
4290
+ )
4291
+ );
4292
+ }
4293
+ } else {
4294
+ const failed = await trace.session?.recordCapability(
4295
+ "answer_generation",
4296
+ "failed",
4297
+ "generation_failed"
4298
+ );
4299
+ if (failed && !failed.ok) {
4300
+ return finishRestTrace(
4301
+ req,
4302
+ trace.session,
4303
+ "failed",
4304
+ errorResponse("RUNTIME", failed.error.message, 500)
4305
+ );
4306
+ }
3954
4307
  }
3955
4308
  }
3956
4309
 
@@ -3975,7 +4328,13 @@ export async function handleAsk(
3975
4328
  },
3976
4329
  };
3977
4330
 
3978
- return jsonResponse(askResult);
4331
+ const complete = answerGenerated && (citations?.length ?? 0) > 0;
4332
+ return finishRestTrace(
4333
+ req,
4334
+ trace.session,
4335
+ complete ? "completed" : "partial",
4336
+ jsonResponse(askResult)
4337
+ );
3979
4338
  }
3980
4339
 
3981
4340
  // ─────────────────────────────────────────────────────────────────────────────
@@ -4141,7 +4500,13 @@ export function handleModelStatus(): Response {
4141
4500
  * Start downloading models for current preset.
4142
4501
  * Returns immediately; poll /api/models/status for progress.
4143
4502
  */
4144
- export function handleModelPull(ctxHolder: ContextHolder): Response {
4503
+ export function handleModelPull(
4504
+ ctxHolder: ContextHolder,
4505
+ deps?: {
4506
+ modelsPullFn?: typeof modelsPull;
4507
+ reloadServerContextFn?: typeof reloadServerContext;
4508
+ }
4509
+ ): Response {
4145
4510
  // Don't start if already downloading
4146
4511
  if (downloadState.active) {
4147
4512
  return errorResponse("CONFLICT", "Download already in progress", 409);
@@ -4152,17 +4517,20 @@ export function handleModelPull(ctxHolder: ContextHolder): Response {
4152
4517
  downloadState.active = true;
4153
4518
  downloadState.startedAt = Date.now();
4154
4519
 
4155
- // Run download in background (don't await)
4156
- // Pass current config so it uses the active preset from UI
4157
- modelsPull({
4158
- config: ctxHolder.config,
4159
- all: true,
4160
- onProgress: (type, progress) => {
4161
- downloadState.currentType = type;
4162
- downloadState.progress = progress;
4163
- },
4164
- })
4165
- .then(async (result) => {
4520
+ const operation = async (signal: AbortSignal): Promise<void> => {
4521
+ try {
4522
+ const result = await (deps?.modelsPullFn ?? modelsPull)({
4523
+ config: ctxHolder.config,
4524
+ all: true,
4525
+ signal,
4526
+ onProgress: (type, progress) => {
4527
+ if (signal.aborted) return;
4528
+ downloadState.currentType = type;
4529
+ downloadState.progress = progress;
4530
+ },
4531
+ });
4532
+ if (signal.aborted) return;
4533
+
4166
4534
  // Track results
4167
4535
  for (const r of result.results) {
4168
4536
  if (r.ok) {
@@ -4180,35 +4548,39 @@ export function handleModelPull(ctxHolder: ContextHolder): Response {
4180
4548
  // Reload context to pick up new models
4181
4549
  console.log("Models downloaded, reloading context...");
4182
4550
  try {
4183
- ctxHolder.current = await reloadServerContext(
4184
- ctxHolder.current,
4185
- ctxHolder.config
4186
- );
4551
+ ctxHolder.current = await (
4552
+ deps?.reloadServerContextFn ?? reloadServerContext
4553
+ )(ctxHolder.current, ctxHolder.config);
4554
+ if (signal.aborted) return;
4187
4555
  console.log("Context reloaded");
4188
4556
  if (ctxHolder.scheduler) {
4189
- void ctxHolder.scheduler.triggerNow().catch((error) => {
4190
- console.error(
4191
- "Failed to trigger embedding after model download:",
4192
- error
4193
- );
4194
- });
4557
+ await ctxHolder.scheduler.triggerNow();
4195
4558
  }
4196
4559
  } catch (e) {
4197
4560
  console.error("Failed to reload context:", e);
4198
4561
  }
4199
-
4200
- downloadState.active = false;
4201
- downloadState.currentType = null;
4202
- downloadState.progress = null;
4203
- })
4204
- .catch((e) => {
4562
+ } catch (e) {
4205
4563
  console.error("Model download failed:", e);
4206
- downloadState.active = false;
4207
4564
  downloadState.failed.push({
4208
4565
  type: downloadState.currentType ?? "embed",
4209
4566
  error: e instanceof Error ? e.message : String(e),
4210
4567
  });
4211
- });
4568
+ } finally {
4569
+ downloadState.active = false;
4570
+ downloadState.currentType = null;
4571
+ downloadState.progress = null;
4572
+ }
4573
+ };
4574
+
4575
+ const started = ctxHolder.startBackgroundWork?.(operation) ?? false;
4576
+ if (!started) {
4577
+ resetDownloadState();
4578
+ return errorResponse(
4579
+ "UNAVAILABLE",
4580
+ "Resident runtime is shutting down",
4581
+ 503
4582
+ );
4583
+ }
4212
4584
 
4213
4585
  return jsonResponse({
4214
4586
  started: true,
@@ -4224,8 +4596,8 @@ export function handleModelPull(ctxHolder: ContextHolder): Response {
4224
4596
  * GET /api/jobs/:id
4225
4597
  * Poll job status for async operations.
4226
4598
  */
4227
- export function handleJob(jobId: string): Response {
4228
- const status = getJobStatus(jobId);
4599
+ export function handleJob(jobId: string, jobManager?: JobManager): Response {
4600
+ const status = getJobStatus(jobId, jobManager);
4229
4601
  if (!status) {
4230
4602
  return errorResponse("NOT_FOUND", "Job not found or expired", 404);
4231
4603
  }
@@ -4236,9 +4608,9 @@ export function handleJob(jobId: string): Response {
4236
4608
  * GET /api/jobs/active
4237
4609
  * Returns the current active job, or null when idle.
4238
4610
  */
4239
- export function handleActiveJob(): Response {
4611
+ export function handleActiveJob(jobManager?: JobManager): Response {
4240
4612
  return jsonResponse({
4241
- activeJob: getActiveJob(),
4613
+ activeJob: getActiveJob(jobManager),
4242
4614
  });
4243
4615
  }
4244
4616
 
@@ -4575,7 +4947,7 @@ export async function routeApi(
4575
4947
  }
4576
4948
 
4577
4949
  if (path === "/api/doc") {
4578
- return handleDoc(store, config, url);
4950
+ return handleDoc(store, config, url, req);
4579
4951
  }
4580
4952
 
4581
4953
  if (path === "/api/doc-asset") {
@@ -4583,7 +4955,25 @@ export async function routeApi(
4583
4955
  }
4584
4956
 
4585
4957
  if (path === "/api/search" && req.method === "POST") {
4586
- return handleSearch(store, req);
4958
+ return handleSearch(
4959
+ {
4960
+ store,
4961
+ config,
4962
+ indexName: "default",
4963
+ vectorIndex: null,
4964
+ embedPort: null,
4965
+ expandPort: null,
4966
+ answerPort: null,
4967
+ rerankPort: null,
4968
+ capabilities: {
4969
+ bm25: true,
4970
+ vector: false,
4971
+ hybrid: false,
4972
+ answer: false,
4973
+ },
4974
+ },
4975
+ req
4976
+ );
4587
4977
  }
4588
4978
 
4589
4979
  // Unknown API route