@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.
@@ -9,12 +9,14 @@ import type {
9
9
  ListKnowledgeChangesInput,
10
10
  } from "../../core/knowledge-delta";
11
11
  import type { StorePort } from "../../store/types";
12
+ import type { ChangesFollowLine, FollowChangesResult } from "./changes-follow";
12
13
 
13
14
  import {
14
15
  analyzeKnowledgeImpact,
15
16
  getKnowledgeDiff,
16
17
  listKnowledgeChanges,
17
18
  } from "../../core/knowledge-delta";
19
+ import { followChanges, validateFollowCursor } from "./changes-follow";
18
20
  import { initStore } from "./shared";
19
21
 
20
22
  export interface KnowledgeDeltaCliContext {
@@ -82,6 +84,67 @@ export const impact = (
82
84
  ): Promise<KnowledgeDeltaServiceResult<KnowledgeImpactResult>> =>
83
85
  withStore(context, (store) => impactRead(store, ref, input));
84
86
 
87
+ export interface ChangesFollowInput {
88
+ cursor?: string;
89
+ collection?: string;
90
+ }
91
+
92
+ /** Abort on SIGINT/SIGTERM so the stream ends cleanly after the current line. */
93
+ const abortOnSignals = (): { signal: AbortSignal; dispose: () => void } => {
94
+ const controller = new AbortController();
95
+ const onSignal = (): void => controller.abort();
96
+ process.once("SIGINT", onSignal);
97
+ process.once("SIGTERM", onSignal);
98
+ return {
99
+ signal: controller.signal,
100
+ dispose: (): void => {
101
+ process.off("SIGINT", onSignal);
102
+ process.off("SIGTERM", onSignal);
103
+ },
104
+ };
105
+ };
106
+
107
+ /**
108
+ * `gno changes --follow --jsonl`: stream journal events as JSON lines on
109
+ * stdout until a signal arrives or the resume cursor expires.
110
+ */
111
+ export const changesFollow = async (
112
+ input: ChangesFollowInput,
113
+ context: KnowledgeDeltaCliContext = {},
114
+ emit: (line: ChangesFollowLine) => void = (line) => {
115
+ process.stdout.write(`${JSON.stringify(line)}\n`);
116
+ }
117
+ ): Promise<FollowChangesResult> => {
118
+ if (input.cursor !== undefined) {
119
+ const invalid = validateFollowCursor(input.cursor);
120
+ if (invalid) return { status: "error", error: invalid, isValidation: true };
121
+ }
122
+ const collection = input.collection?.trim();
123
+ if (input.collection !== undefined && !collection) {
124
+ return {
125
+ status: "error",
126
+ error: "collection cannot be empty",
127
+ isValidation: true,
128
+ };
129
+ }
130
+ const signals = abortOnSignals();
131
+ try {
132
+ const result = await withStore(context, async (store) => ({
133
+ success: true as const,
134
+ data: await followChanges(
135
+ store,
136
+ { cursor: input.cursor, collection, signal: signals.signal },
137
+ emit
138
+ ),
139
+ }));
140
+ return result.success
141
+ ? result.data
142
+ : { status: "error", error: result.error, isValidation: false };
143
+ } finally {
144
+ signals.dispose();
145
+ }
146
+ };
147
+
85
148
  const json = (value: unknown): string => JSON.stringify(value, null, 2);
86
149
 
87
150
  export function formatChanges(
@@ -1,6 +1,7 @@
1
1
  import type { CollectionSyncResult } from "../../ingestion";
2
2
  import type { HttpGatewayOverrides } from "../../mcp/http-security";
3
3
  import type { BackgroundRuntimeResult } from "../../serve/background-runtime";
4
+ import type { FindingsPassResult } from "../../serve/findings-pass";
4
5
  import type { ResidentRuntime } from "../../serve/resident-runtime";
5
6
 
6
7
  import {
@@ -43,6 +44,29 @@ type DaemonDeps = {
43
44
  logger?: DaemonLogger;
44
45
  };
45
46
 
47
+ /** Silent when clean: only failures, and non-empty writes when not quiet, reach the log. */
48
+ export function logFindingsPassResult(
49
+ result: FindingsPassResult,
50
+ logger: DaemonLogger,
51
+ options: { quiet?: boolean; verbose?: boolean }
52
+ ): void {
53
+ if (result.outcome === "failed") {
54
+ logger.error(`findings pass failed: ${result.error ?? "unknown error"}`);
55
+ return;
56
+ }
57
+ if (result.outcome === "skipped_lease") {
58
+ if (options.verbose) logger.log(`findings pass skipped: ${result.error}`);
59
+ return;
60
+ }
61
+ const { counts } = result;
62
+ const changed =
63
+ counts.written + counts.reopened + counts.resolved + counts.deleted;
64
+ if (changed === 0 || options.quiet) return;
65
+ logger.log(
66
+ `findings pass: ${counts.written} new, ${counts.reopened} reopened, ${counts.resolved} resolved, ${counts.deleted} expired (${counts.open} open)`
67
+ );
68
+ }
69
+
46
70
  function formatCollectionSyncSummary(result: CollectionSyncResult): string {
47
71
  return `${result.collection}: ${result.filesAdded} added, ${result.filesUpdated} updated, ${result.filesUnchanged} unchanged, ${result.filesErrored} errors`;
48
72
  }
@@ -146,6 +170,11 @@ export async function daemon(
146
170
  index: options.index,
147
171
  requireCollections: true,
148
172
  offline: options.offline,
173
+ onFindingsResult: (result) =>
174
+ logFindingsPassResult(result, logger, {
175
+ quiet: options.quiet,
176
+ verbose: options.verbose,
177
+ }),
149
178
  watchCallbacks: {
150
179
  onSyncStart: ({ collection, relPaths }) => {
151
180
  if (!options.quiet) {
@@ -230,6 +259,12 @@ export async function daemon(
230
259
  logger.error(`watch failed: ${failed.collection}: ${failed.reason}`);
231
260
  }
232
261
  }
262
+ const findings = (runtime as Partial<ResidentRuntime>).findingsScheduler;
263
+ if (findings) {
264
+ logger.log(
265
+ `findings pass: every ${findings.state.cadence} into "${findings.state.collection}" (report-only)`
266
+ );
267
+ }
233
268
  }
234
269
 
235
270
  if (!options.noSyncOnStart) {
@@ -21,6 +21,11 @@ import {
21
21
  loadConfig,
22
22
  } from "../../config";
23
23
  import { isConnectorActivationComplete } from "../../core/activation-connector-health";
24
+ import {
25
+ findingsRunStatePathForIndex,
26
+ readFindingsRunStatus,
27
+ resolveFindingsSchedule,
28
+ } from "../../core/findings-run-state";
24
29
  import { getCodeChunkingStatus } from "../../ingestion/chunker";
25
30
  import { ModelCache } from "../../llm/cache";
26
31
  import { getActivePreset, resolveModelUri } from "../../llm/registry";
@@ -560,6 +565,69 @@ async function checkSqliteExtensions(): Promise<DoctorCheck[]> {
560
565
  return checks;
561
566
  }
562
567
 
568
+ /**
569
+ * Report the daemon's scheduled findings pass from its persisted state so a
570
+ * misconfigured, starved, or failing scheduler is visible without the daemon.
571
+ */
572
+ export async function checkFindingsPass(
573
+ config: Config,
574
+ indexName?: string
575
+ ): Promise<DoctorCheck> {
576
+ const name = "findings-pass";
577
+ const resolution = resolveFindingsSchedule(config);
578
+ if (!resolution.ok) {
579
+ return {
580
+ name,
581
+ status: "error",
582
+ message: "misconfigured",
583
+ details: [resolution.error],
584
+ };
585
+ }
586
+ if (!resolution.enabled) {
587
+ return {
588
+ name,
589
+ status: "ok",
590
+ message: "disabled (opt-in via findings.enabled)",
591
+ };
592
+ }
593
+ const status = await readFindingsRunStatus(
594
+ findingsRunStatePathForIndex(indexName)
595
+ );
596
+ const schedule = `every ${resolution.schedule.cadence} into "${resolution.schedule.collection.name}"`;
597
+ if (!status) {
598
+ return {
599
+ name,
600
+ status: "warn",
601
+ message: `enabled (${schedule}) but no run state recorded`,
602
+ details: ["Start gno daemon; the pass only runs inside the daemon."],
603
+ };
604
+ }
605
+ const details = [
606
+ `last run: ${status.lastRunAt ?? "never"}`,
607
+ `last success: ${status.lastSuccessAt ?? "never"}`,
608
+ `next due: ${status.nextDueAt}`,
609
+ ];
610
+ if (status.counts) {
611
+ details.push(
612
+ `counts: ${status.counts.open} open, ${status.counts.written} new, ${status.counts.resolved} resolved`
613
+ );
614
+ }
615
+ if (status.error) details.push(`error: ${status.error}`);
616
+ const statusOf: Record<typeof status.state, DoctorCheckStatus> = {
617
+ pending: "ok",
618
+ success: "ok",
619
+ skipped_lease: "warn",
620
+ overdue: "warn",
621
+ failed: "error",
622
+ };
623
+ return {
624
+ name,
625
+ status: statusOf[status.state],
626
+ message: `${status.state} (${schedule})`,
627
+ details,
628
+ };
629
+ }
630
+
563
631
  // ─────────────────────────────────────────────────────────────────────────────
564
632
  // Implementation
565
633
  // ─────────────────────────────────────────────────────────────────────────────
@@ -603,6 +671,9 @@ export async function doctor(
603
671
  // Embedding fingerprint freshness
604
672
  checks.push(await checkEmbeddingFingerprints(config, options.indexName));
605
673
 
674
+ // Scheduled findings pass (daemon-only, opt-in)
675
+ checks.push(await checkFindingsPass(config, options.indexName));
676
+
606
677
  const activation = await buildDoctorActivation(config, options);
607
678
  checks.push(checkRetrievalActivation(activation));
608
679
  const connectorActivation = checkConnectorActivation(activation);