@gmickel/gno 1.43.0 → 1.44.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.
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * gno index command implementation.
3
- * Build or update the index end-to-end (update + embed).
3
+ * Build or update the index end-to-end as two separable, resumable stages:
4
+ * `lexical` (sync) then `embed`. Each stage reports its own receipt state and
5
+ * counts; the run exits 0 only when every attempted stage completed (fn-132).
4
6
  *
5
7
  * @module src/cli/commands/indexCmd
6
8
  */
@@ -10,11 +12,22 @@ import {
10
12
  type WriteLeaseContention,
11
13
  withCliWriteLease,
12
14
  } from "../../core/write-lease";
15
+ import {
16
+ clearIndexStage,
17
+ findInterruptedStage,
18
+ formatInterruptedStage,
19
+ type IndexStageState,
20
+ type InterruptedStage,
21
+ markIndexStageFinished,
22
+ markIndexStageRunning,
23
+ readIndexStageState,
24
+ } from "../../embed/stage-state";
13
25
  import {
14
26
  defaultSyncService,
15
27
  type SyncResult,
16
28
  withContentTypeRules,
17
29
  } from "../../ingestion";
30
+ import { type EmbedResult, embedStageOutcome } from "./embed";
18
31
  import { formatSyncResultLines, initStore } from "./shared";
19
32
 
20
33
  /**
@@ -41,22 +54,118 @@ export interface IndexOptions extends CliWriteLeaseOptions {
41
54
  json?: boolean;
42
55
  }
43
56
 
57
+ /** Receipt for the lexical (sync) stage. */
58
+ export interface LexicalStageReceipt {
59
+ state: IndexStageState;
60
+ filesProcessed: number;
61
+ filesAdded: number;
62
+ filesUpdated: number;
63
+ filesErrored: number;
64
+ filesSkipped: number;
65
+ durationMs: number;
66
+ error?: string;
67
+ }
68
+
69
+ /** Receipt for the embed stage. */
70
+ export interface EmbedStageReceipt {
71
+ state: IndexStageState;
72
+ embedded: number;
73
+ errors: number;
74
+ contentionErrors: number;
75
+ durationMs: number;
76
+ /** Why the stage was skipped (`--no-embed`, lexical failure). */
77
+ reason?: string;
78
+ error?: string;
79
+ }
80
+
81
+ export interface IndexStageReceipts {
82
+ lexical: LexicalStageReceipt;
83
+ embed: EmbedStageReceipt;
84
+ }
85
+
86
+ export interface IndexEmbedSummary {
87
+ embedded: number;
88
+ errors: number;
89
+ contentionErrors: number;
90
+ duration: number;
91
+ }
92
+
93
+ /** Receipt fields shared by successful and failed runs that reached a stage. */
94
+ export interface IndexReceipt {
95
+ stages: IndexStageReceipts;
96
+ /** Stage a previous run left `running`; null when the run started clean. */
97
+ resumedFrom: InterruptedStage | null;
98
+ syncResult?: SyncResult;
99
+ embedSkipped: boolean;
100
+ embedResult?: IndexEmbedSummary;
101
+ }
102
+
44
103
  /**
45
- * Result of index command.
104
+ * Result of index command. A failure that reached a stage carries the partial
105
+ * receipt (`stages` present); a failure before any stage (init, lease) does not.
46
106
  */
47
107
  export type IndexResult =
108
+ | ({ success: true; syncResult: SyncResult } & IndexReceipt)
109
+ | ({ success: false; error: string } & IndexReceipt)
48
110
  | {
49
- success: true;
50
- syncResult: SyncResult;
51
- embedSkipped: boolean;
52
- embedResult?: {
53
- embedded: number;
54
- errors: number;
55
- contentionErrors: number;
56
- duration: number;
57
- };
58
- }
59
- | { success: false; error: string; contention?: WriteLeaseContention };
111
+ success: false;
112
+ error: string;
113
+ contention?: WriteLeaseContention;
114
+ stages?: undefined;
115
+ };
116
+
117
+ const EMPTY_LEXICAL_COUNTS = {
118
+ filesProcessed: 0,
119
+ filesAdded: 0,
120
+ filesUpdated: 0,
121
+ filesErrored: 0,
122
+ filesSkipped: 0,
123
+ durationMs: 0,
124
+ } as const;
125
+
126
+ const EMPTY_EMBED_COUNTS = {
127
+ embedded: 0,
128
+ errors: 0,
129
+ contentionErrors: 0,
130
+ durationMs: 0,
131
+ } as const;
132
+
133
+ function lexicalReceipt(syncResult: SyncResult): LexicalStageReceipt {
134
+ return {
135
+ state: "completed",
136
+ filesProcessed: syncResult.totalFilesProcessed,
137
+ filesAdded: syncResult.totalFilesAdded,
138
+ filesUpdated: syncResult.totalFilesUpdated,
139
+ filesErrored: syncResult.totalFilesErrored,
140
+ filesSkipped: syncResult.totalFilesSkipped,
141
+ durationMs: syncResult.totalDurationMs,
142
+ };
143
+ }
144
+
145
+ function skippedEmbedReceipt(reason: string): EmbedStageReceipt {
146
+ return { state: "skipped", ...EMPTY_EMBED_COUNTS, reason };
147
+ }
148
+
149
+ function embedReceipt(result: EmbedResult): EmbedStageReceipt {
150
+ if (!result.success) {
151
+ return { state: "failed", ...EMPTY_EMBED_COUNTS, error: result.error };
152
+ }
153
+ const state = embedStageOutcome(result);
154
+ const error =
155
+ state === "failed"
156
+ ? (result.syncError ??
157
+ result.errorSamples?.[0] ??
158
+ `${result.errors} chunks failed to embed`)
159
+ : undefined;
160
+ return {
161
+ state,
162
+ embedded: result.embedded,
163
+ errors: result.errors,
164
+ contentionErrors: result.contentionErrors,
165
+ durationMs: Math.round(result.duration * 1000),
166
+ ...(error ? { error } : {}),
167
+ };
168
+ }
60
169
 
61
170
  /**
62
171
  * Execute gno index command.
@@ -73,52 +182,103 @@ export async function index(options: IndexOptions = {}): Promise<IndexResult> {
73
182
  }
74
183
 
75
184
  const { store, collections, config } = initResult;
185
+ const db = store.getRawDb();
186
+ const embedSkipped = options.noEmbed ?? false;
76
187
 
77
188
  try {
78
- // Run sync service (update phase)
79
- const syncResult = await defaultSyncService.syncAll(
80
- collections,
81
- store,
82
- withContentTypeRules(
83
- {
84
- gitPull: options.gitPull,
85
- runUpdateCmd: true,
189
+ // Resume preamble (fn-132 R4): report a stage the previous run left
190
+ // `running` before this run overwrites its marker.
191
+ const resumedFrom = findInterruptedStage(readIndexStageState(db));
192
+ if (resumedFrom && !options.json) {
193
+ process.stderr.write(`${formatInterruptedStage(resumedFrom)}\n`);
194
+ }
195
+
196
+ // Lexical stage (sync). Per-file errors are counted, not fatal; only a
197
+ // sync that cannot run at all fails the stage.
198
+ markIndexStageRunning(db, "lexical", { collection: options.collection });
199
+ let syncResult: SyncResult;
200
+ try {
201
+ syncResult = await defaultSyncService.syncAll(
202
+ collections,
203
+ store,
204
+ withContentTypeRules(
205
+ { gitPull: options.gitPull, runUpdateCmd: true },
206
+ config
207
+ )
208
+ );
209
+ } catch (e) {
210
+ const message = e instanceof Error ? e.message : String(e);
211
+ markIndexStageFinished(db, "lexical", "failed");
212
+ return {
213
+ success: false,
214
+ error: `Lexical sync failed: ${message}`,
215
+ stages: {
216
+ lexical: {
217
+ state: "failed",
218
+ ...EMPTY_LEXICAL_COUNTS,
219
+ error: message,
220
+ },
221
+ embed: skippedEmbedReceipt("lexical stage failed"),
86
222
  },
87
- config
88
- )
89
- );
223
+ resumedFrom,
224
+ embedSkipped,
225
+ };
226
+ }
227
+ markIndexStageFinished(db, "lexical", "completed");
228
+ const lexical = lexicalReceipt(syncResult);
90
229
 
91
- // Embedding phase
92
- const embedSkipped = options.noEmbed ?? false;
93
- let embedResult:
94
- | {
95
- embedded: number;
96
- errors: number;
97
- contentionErrors: number;
98
- duration: number;
99
- }
100
- | undefined;
101
-
102
- if (!embedSkipped) {
103
- const { embed } = await import("./embed");
104
- const result = await embed({
105
- configPath: options.configPath,
106
- indexName: options.indexName,
107
- collection: options.collection,
108
- verbose: options.verbose,
109
- skipWriteLease: true,
110
- });
111
- if (result.success) {
112
- embedResult = {
230
+ if (embedSkipped) {
231
+ // A stale `embed: running` marker (killed embed run) was surfaced in
232
+ // this run's preamble; settle it so the next run does not repeat it
233
+ // or mask a real lexical interruption. Embed progress stays on disk.
234
+ if (resumedFrom?.stage === "embed") {
235
+ clearIndexStage(db, "embed");
236
+ }
237
+ return {
238
+ success: true,
239
+ syncResult,
240
+ embedSkipped,
241
+ stages: { lexical, embed: skippedEmbedReceipt("--no-embed") },
242
+ resumedFrom,
243
+ };
244
+ }
245
+
246
+ // Embed stage. embed() owns its own persisted marker.
247
+ const { embed } = await import("./embed");
248
+ const result = await embed({
249
+ configPath: options.configPath,
250
+ indexName: options.indexName,
251
+ collection: options.collection,
252
+ verbose: options.verbose,
253
+ json: options.json,
254
+ skipWriteLease: true,
255
+ resumedFrom,
256
+ });
257
+ const embedStage = embedReceipt(result);
258
+ const embedResult: IndexEmbedSummary | undefined = result.success
259
+ ? {
113
260
  embedded: result.embedded,
114
261
  errors: result.errors,
115
262
  contentionErrors: result.contentionErrors,
116
263
  duration: result.duration,
117
- };
118
- }
119
- }
264
+ }
265
+ : undefined;
266
+ const receipt: IndexReceipt = {
267
+ stages: { lexical, embed: embedStage },
268
+ resumedFrom,
269
+ syncResult,
270
+ embedSkipped,
271
+ ...(embedResult ? { embedResult } : {}),
272
+ };
120
273
 
121
- return { success: true, syncResult, embedSkipped, embedResult };
274
+ if (embedStage.state !== "completed") {
275
+ return {
276
+ success: false,
277
+ error: `Embed stage failed: ${embedStage.error ?? "unknown error"}`,
278
+ ...receipt,
279
+ };
280
+ }
281
+ return { success: true, syncResult, ...receipt };
122
282
  } finally {
123
283
  await store.close();
124
284
  }
@@ -141,14 +301,18 @@ export function formatIndex(
141
301
  return `${mins}m ${secs.toFixed(0)}s`;
142
302
  }
143
303
 
144
- if (!result.success) {
304
+ if (!result.success && !result.stages) {
145
305
  return `Error: ${result.error}`;
146
306
  }
147
307
 
148
308
  if (options.json) {
149
309
  return JSON.stringify(
150
310
  {
151
- syncResult: result.syncResult,
311
+ success: result.success,
312
+ ...(result.success ? {} : { error: result.error }),
313
+ stages: result.stages,
314
+ resumedFrom: result.resumedFrom,
315
+ ...(result.syncResult ? { syncResult: result.syncResult } : {}),
152
316
  embedSkipped: result.embedSkipped,
153
317
  ...(result.embedResult ? { embedResult: result.embedResult } : {}),
154
318
  },
@@ -156,16 +320,28 @@ export function formatIndex(
156
320
  2
157
321
  );
158
322
  }
159
- const { syncResult, embedSkipped } = result;
160
- const lines: string[] = ["Indexing complete.", ""];
161
323
 
162
- lines.push(...formatSyncResultLines(syncResult, options));
324
+ const { stages, embedSkipped } = result;
325
+ const lines: string[] = [
326
+ result.success ? "Indexing complete." : "Indexing failed.",
327
+ "",
328
+ ];
329
+
330
+ if (result.syncResult) {
331
+ lines.push(...formatSyncResultLines(result.syncResult, options));
332
+ } else {
333
+ lines.push(`Lexical stage ${stages.lexical.state}.`);
334
+ }
335
+ if (stages.lexical.error) {
336
+ lines.push(`Lexical error: ${stages.lexical.error}`);
337
+ }
163
338
 
339
+ lines.push("");
164
340
  if (embedSkipped) {
165
- lines.push("");
166
341
  lines.push("Embedding skipped (--no-embed)");
342
+ } else if (stages.embed.state === "skipped") {
343
+ lines.push(`Embedding skipped (${stages.embed.reason ?? "not attempted"})`);
167
344
  } else if (result.embedResult) {
168
- lines.push("");
169
345
  const { embedded, errors, contentionErrors, duration } = result.embedResult;
170
346
  lines.push(
171
347
  `Embedded ${embedded.toLocaleString()} chunks in ${formatDuration(duration)}`
@@ -179,6 +355,11 @@ export function formatIndex(
179
355
  );
180
356
  }
181
357
  }
358
+ if (stages.embed.state === "failed") {
359
+ lines.push(
360
+ `Embed stage failed: ${stages.embed.error ?? "unknown error"}. Lexical index is intact; rerun \`gno embed\` to resume from persisted progress.`
361
+ );
362
+ }
182
363
 
183
364
  return lines.join("\n");
184
365
  }
@@ -20,6 +20,11 @@ import {
20
20
  } from "../app/constants";
21
21
  import { INDEX_NAME_REQUIREMENTS, isValidIndexName } from "../app/index-name";
22
22
  import { resolveDepthPolicy } from "../core/depth-policy";
23
+ import {
24
+ findingsRunStatePathForIndex,
25
+ formatFindingsRunStatusLine,
26
+ readFindingsRunStatus,
27
+ } from "../core/findings-run-state";
23
28
  import { parseAndValidateTagFilter } from "../core/tags";
24
29
  import {
25
30
  formatWriteLeaseBusyJson,
@@ -1630,7 +1635,14 @@ function wireOnboardingCommands(program: Command): void {
1630
1635
 
1631
1636
  if (!result.success) {
1632
1637
  throwIfWriteLeaseBusy(result, opts.json);
1633
- throw new CliError("RUNTIME", result.error ?? "Index failed");
1638
+ if (!result.stages) {
1639
+ throw new CliError("RUNTIME", result.error ?? "Index failed");
1640
+ }
1641
+ // A stage failed after the run started: emit the partial per-stage
1642
+ // receipt, then exit non-zero (fn-132 R4 - never exit 0 on a failed
1643
+ // embed stage).
1644
+ process.stdout.write(`${formatIndex(result, opts)}\n`);
1645
+ throw new CliError("RUNTIME", result.error, { silent: true });
1634
1646
  }
1635
1647
  process.stdout.write(`${formatIndex(result, opts)}\n`);
1636
1648
  if ((result.embedResult?.contentionErrors ?? 0) > 0) {
@@ -4094,25 +4106,89 @@ function wireGraphCommand(program: Command): void {
4094
4106
  );
4095
4107
  }
4096
4108
 
4109
+ /**
4110
+ * `gno changes --follow --jsonl`: the follow flags form one mode that
4111
+ * excludes the one-shot listing flags. The cursor-expiry record is the
4112
+ * stdout envelope, so its non-zero exit is silent on stderr.
4113
+ */
4114
+ async function runChangesFollow(
4115
+ cmdOpts: Record<string, unknown>,
4116
+ format: string,
4117
+ globals: GlobalOptions
4118
+ ): Promise<void> {
4119
+ if (!cmdOpts.follow || !cmdOpts.jsonl) {
4120
+ throw new CliError(
4121
+ "VALIDATION",
4122
+ "--follow and --jsonl must be used together (--cursor requires both)"
4123
+ );
4124
+ }
4125
+ if (format === "json" || cmdOpts.since !== undefined) {
4126
+ throw new CliError(
4127
+ "VALIDATION",
4128
+ "--follow cannot be combined with --json or --since; use --cursor to resume"
4129
+ );
4130
+ }
4131
+ if (cmdOpts.limit !== undefined) {
4132
+ throw new CliError(
4133
+ "VALIDATION",
4134
+ "--follow cannot be combined with --limit"
4135
+ );
4136
+ }
4137
+ const { changesFollow } = await import("./commands/changes");
4138
+ const result = await changesFollow(
4139
+ {
4140
+ cursor: cmdOpts.cursor as string | undefined,
4141
+ collection: cmdOpts.collection as string | undefined,
4142
+ },
4143
+ { configPath: globals.config, indexName: globals.index }
4144
+ );
4145
+ if (result.status === "error") {
4146
+ throw new CliError(
4147
+ result.isValidation ? "VALIDATION" : "RUNTIME",
4148
+ result.error
4149
+ );
4150
+ }
4151
+ if (result.status === "expired") {
4152
+ throw new CliError(
4153
+ "RUNTIME",
4154
+ `Follow cursor expired; earliest retained cursor is ${result.earliestCursor}`,
4155
+ { silent: true }
4156
+ );
4157
+ }
4158
+ }
4159
+
4097
4160
  function wireKnowledgeDeltaCommands(program: Command): void {
4098
4161
  program
4099
4162
  .command("changes")
4100
4163
  .description("List retained metadata-only document changes")
4101
4164
  .option("--since <time-or-cursor>", "ISO-8601 time or opaque cursor")
4102
4165
  .option("-c, --collection <name>", "filter by collection")
4103
- .option("-n, --limit <num>", "maximum changes", "100")
4166
+ .option("-n, --limit <num>", "maximum changes (default 100)")
4104
4167
  .option("--json", "JSON output")
4168
+ .option(
4169
+ "--follow",
4170
+ "stream new changes as they land (requires --jsonl; SIGINT exits 0)"
4171
+ )
4172
+ .option("--jsonl", "one JSON object per line (only with --follow)")
4173
+ .option(
4174
+ "--cursor <cursor>",
4175
+ "resume a --follow stream from a persisted postCursor"
4176
+ )
4105
4177
  .action(async (cmdOpts: Record<string, unknown>) => {
4106
4178
  const format = getFormat(cmdOpts);
4107
4179
  assertFormatSupported(CMD.changes, format);
4108
4180
  const deltaFormat = format === "json" ? "json" : "terminal";
4109
4181
  const globals = getGlobals();
4182
+ if (cmdOpts.follow || cmdOpts.jsonl || cmdOpts.cursor !== undefined) {
4183
+ await runChangesFollow(cmdOpts, format, globals);
4184
+ return;
4185
+ }
4110
4186
  const { changes, formatChanges } = await import("./commands/changes");
4111
4187
  const result = await changes(
4112
4188
  {
4113
4189
  since: cmdOpts.since as string | undefined,
4114
4190
  collection: cmdOpts.collection as string | undefined,
4115
- limit: parsePositiveInt("limit", cmdOpts.limit),
4191
+ limit: parsePositiveInt("limit", cmdOpts.limit ?? "100"),
4116
4192
  },
4117
4193
  { configPath: globals.config, indexName: globals.index }
4118
4194
  );
@@ -4345,6 +4421,7 @@ async function handleDaemonAction(
4345
4421
  await runDaemonStatus({
4346
4422
  paths,
4347
4423
  json,
4424
+ indexName: globals.index,
4348
4425
  statusProcess,
4349
4426
  inspectForeignLive,
4350
4427
  });
@@ -4412,6 +4489,7 @@ async function handleDaemonAction(
4412
4489
  interface DaemonStatusDeps {
4413
4490
  paths: { pidFile: string; logFile: string };
4414
4491
  json: boolean;
4492
+ indexName?: string;
4415
4493
  statusProcess: typeof import("./detach.js").statusProcess;
4416
4494
  inspectForeignLive: typeof import("./detach.js").inspectForeignLive;
4417
4495
  }
@@ -4426,9 +4504,16 @@ async function runDaemonStatus(deps: DaemonStatusDeps): Promise<void> {
4426
4504
  kind: "daemon",
4427
4505
  pidFile: deps.paths.pidFile,
4428
4506
  });
4507
+ // Persisted by the daemon after every scheduled findings attempt; absent
4508
+ // (null) when the pass is not configured. Read from disk, never live.
4509
+ const findings = await readFindingsRunStatus(
4510
+ findingsRunStatePathForIndex(deps.indexName)
4511
+ );
4429
4512
 
4430
4513
  if (deps.json) {
4431
- process.stdout.write(`${JSON.stringify(status, null, 2)}\n`);
4514
+ process.stdout.write(
4515
+ `${JSON.stringify({ ...status, findings }, null, 2)}\n`
4516
+ );
4432
4517
  // In JSON mode, foreign-live metadata flows into the NOT_RUNNING
4433
4518
  // envelope's `details` payload below so stderr stays a single JSON
4434
4519
  // object that machine clients can parse deterministically.
@@ -4457,6 +4542,11 @@ async function runDaemonStatus(deps: DaemonStatusDeps): Promise<void> {
4457
4542
  } else {
4458
4543
  process.stdout.write(` (${status.log_size_bytes} bytes)\n`);
4459
4544
  }
4545
+ if (findings) {
4546
+ process.stdout.write(
4547
+ ` findings ${formatFindingsRunStatusLine(findings)}\n`
4548
+ );
4549
+ }
4460
4550
 
4461
4551
  if (foreign) {
4462
4552
  // Terminal mode: emit the operator-facing warning on stderr. JSON
@@ -472,6 +472,51 @@ export const HttpGatewayConfigSchema = z.object({
472
472
 
473
473
  export type HttpGatewayConfig = z.infer<typeof HttpGatewayConfigSchema>;
474
474
 
475
+ // ────────────────────────────────────────────────────────────────────────────
476
+ // Scheduled findings pass (daemon-only, opt-in, report-only)
477
+ // ────────────────────────────────────────────────────────────────────────────
478
+
479
+ /** `<integer><unit>` with unit s|m|h|d, e.g. `30m`, `6h`, `1d`. */
480
+ export const FINDINGS_CADENCE_PATTERN =
481
+ /^(?<value>[1-9]\d{0,5})(?<unit>[smhd])$/;
482
+ export const DEFAULT_FINDINGS_CADENCE = "6h";
483
+ export const MIN_FINDINGS_CADENCE_MS = 10_000;
484
+ export const MAX_FINDINGS_CADENCE_MS = 30 * 24 * 60 * 60 * 1000;
485
+
486
+ const CADENCE_UNIT_MS: Record<string, number> = {
487
+ s: 1_000,
488
+ m: 60_000,
489
+ h: 3_600_000,
490
+ d: 86_400_000,
491
+ };
492
+
493
+ /** Parse a findings cadence into milliseconds; null when malformed or out of range. */
494
+ export function parseFindingsCadenceMs(raw: string): number | null {
495
+ const match = FINDINGS_CADENCE_PATTERN.exec(raw.trim());
496
+ const value = match?.groups?.value;
497
+ const unit = match?.groups?.unit;
498
+ if (!value || !unit) return null;
499
+ const ms = Number(value) * (CADENCE_UNIT_MS[unit] ?? 0);
500
+ if (ms < MIN_FINDINGS_CADENCE_MS || ms > MAX_FINDINGS_CADENCE_MS) return null;
501
+ return ms;
502
+ }
503
+
504
+ export const FindingsConfigSchema = z.object({
505
+ /** Off by default; the daemon never audits on cadence unless asked. */
506
+ enabled: z.boolean().default(false),
507
+ /** Run interval (`10s`..`30d`). Evaluated only when enabled. */
508
+ cadence: z
509
+ .string()
510
+ .refine((value) => parseFindingsCadenceMs(value) !== null, {
511
+ message: "cadence must be <n>s|m|h|d between 10s and 30d (e.g. 6h)",
512
+ })
513
+ .default(DEFAULT_FINDINGS_CADENCE),
514
+ /** Name of an already-configured collection that receives findings records. */
515
+ collection: z.string().regex(COLLECTION_NAME_REGEX).optional(),
516
+ });
517
+
518
+ export type FindingsConfig = z.infer<typeof FindingsConfigSchema>;
519
+
475
520
  // ─────────────────────────────────────────────────────────────────────────────
476
521
  // Content Type Schema
477
522
  // ─────────────────────────────────────────────────────────────────────────────
@@ -556,6 +601,9 @@ export const ConfigSchema = z.object({
556
601
  /** Resident Streamable HTTP MCP gateway configuration */
557
602
  gateway: HttpGatewayConfigSchema.optional(),
558
603
 
604
+ /** Daemon-only scheduled read-only audit writing findings records. Absent means off. */
605
+ findings: FindingsConfigSchema.optional(),
606
+
559
607
  /** Private local retrieval trace recording. Absent means recording off. */
560
608
  retrievalTraces: RetrievalTraceConfigSchema.optional(),
561
609