@gmickel/gno 2.9.0 → 2.9.2

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 (35) hide show
  1. package/README.md +1 -1
  2. package/browser-extension/artifacts/{gno-browser-clipper-v2.9.0.zip → gno-browser-clipper-v2.9.2.zip} +0 -0
  3. package/browser-extension/artifacts/gno-browser-clipper-v2.9.2.zip.sha256 +1 -0
  4. package/browser-extension/dist/manifest.json +1 -1
  5. package/package.json +2 -1
  6. package/spec/cli.md +3 -0
  7. package/spec/output-schemas/collection-list.schema.json +5 -0
  8. package/src/cli/commands/daemon.ts +9 -0
  9. package/src/cli/detach.ts +42 -5
  10. package/src/cli/program.ts +11 -2
  11. package/src/config/types.ts +3 -0
  12. package/src/core/project-profile-apply-state.ts +3 -0
  13. package/src/core/resident-shutdown-handler.ts +23 -0
  14. package/src/core/sighup-disposition.ts +66 -0
  15. package/src/core/time-slice.ts +34 -0
  16. package/src/ingestion/graph-reconciliation.ts +18 -4
  17. package/src/ingestion/sync.ts +70 -4
  18. package/src/serve/parent-lifeline.ts +44 -0
  19. package/src/serve/resident-runtime.ts +24 -1
  20. package/src/serve/resident-shutdown.ts +4 -0
  21. package/src/serve/server.ts +22 -1
  22. package/src/serve/watch-reconciliation-shared.ts +12 -1
  23. package/src/serve/watch-reconciliation.ts +136 -1
  24. package/src/serve/watch-service-events.ts +43 -9
  25. package/src/serve/watch-service-flush-generation.ts +77 -3
  26. package/src/serve/watch-service-hosts.ts +12 -2
  27. package/src/serve/watch-service-lifecycle.ts +8 -0
  28. package/src/serve/watch-service-run-flush.ts +13 -2
  29. package/src/serve/watch-service.ts +37 -4
  30. package/src/sessions/import-child.ts +79 -11
  31. package/src/store/migrations/035-vector-length-index.ts +30 -0
  32. package/src/store/migrations/index.ts +2 -0
  33. package/src/store/sqlite/adapter.ts +4 -1
  34. package/src/store/vector/freshness.ts +13 -6
  35. package/browser-extension/artifacts/gno-browser-clipper-v2.9.0.zip.sha256 +0 -1
@@ -15,6 +15,7 @@ import type { CollectionSyncResult, SyncOptions } from "../ingestion";
15
15
  import type { SqliteAdapter } from "../store/sqlite/adapter";
16
16
  import type { DocumentEvent, DocumentEventBus } from "./doc-events";
17
17
  import type { EmbedScheduler } from "./embed-scheduler";
18
+ import type { WatcherRetryKind } from "./watch-service-events";
18
19
  import type {
19
20
  WatcherSnapshot,
20
21
  WatcherSnapshotBuildResult,
@@ -48,7 +49,11 @@ import {
48
49
  } from "./watch-service-lifecycle";
49
50
  import { runOwnedCollectionFlush } from "./watch-service-run-flush";
50
51
  import { beginSnapshotInit } from "./watch-service-snapshot";
51
- import { pendingHasWork, type CollectionPending } from "./watch-service-state";
52
+ import {
53
+ emptyPending,
54
+ pendingHasWork,
55
+ type CollectionPending,
56
+ } from "./watch-service-state";
52
57
 
53
58
  export interface CollectionWatchState {
54
59
  expectedCollections: string[];
@@ -110,6 +115,12 @@ interface CollectionWatchServiceOptions {
110
115
  snapshotEntryCeiling?: number;
111
116
  /** Shared writer lease taken (no wait) around each flush's writes. */
112
117
  acquireWriteLease?: () => Promise<(() => Promise<void>) | null>;
118
+ /**
119
+ * Queue a full reconcile of every watched collection at start, so changes
120
+ * made while no resident ran reach the index. Off for the daemon, whose
121
+ * initial sync (unless --no-sync-on-start) covers the same ground.
122
+ */
123
+ reconcileOnStart?: boolean;
113
124
  }
114
125
 
115
126
  export class CollectionWatchService {
@@ -126,7 +137,9 @@ export class CollectionWatchService {
126
137
  readonly #pendingByCollection = new Map<string, CollectionPending>();
127
138
  readonly #timers = new Map<string, ReturnType<typeof setTimeout>>();
128
139
  readonly #flushDeadlineAt = new Map<string, number>();
129
- readonly #retryScheduled = new Set<string>();
140
+ readonly #retryScheduled = new Map<string, WatcherRetryKind>();
141
+ readonly #retryFailures = new Map<string, number>();
142
+ readonly #freshEvents = new Set<string>();
130
143
  readonly #syncing = new Set<string>();
131
144
  readonly #inFlightSyncs = new Set<Promise<void>>();
132
145
  readonly #suppressedPaths = new Map<string, number>();
@@ -149,6 +162,7 @@ export class CollectionWatchService {
149
162
  readonly #snapshotFs: WatcherSnapshotFs | undefined;
150
163
  readonly #snapshotEntryCeiling: number | undefined;
151
164
  readonly #acquireWriteLease: CollectionWatchServiceOptions["acquireWriteLease"];
165
+ readonly #reconcileOnStart: boolean;
152
166
  #nextCollectionGeneration = 0;
153
167
  #disposed = false;
154
168
  #lastEventAt: string | null = null;
@@ -173,11 +187,24 @@ export class CollectionWatchService {
173
187
  this.#snapshotFs = options.snapshotFs;
174
188
  this.#snapshotEntryCeiling = options.snapshotEntryCeiling;
175
189
  this.#acquireWriteLease = options.acquireWriteLease;
190
+ this.#reconcileOnStart = options.reconcileOnStart ?? false;
176
191
  }
177
192
 
178
193
  start(): void {
179
- if (!this.#disposed) {
180
- this.updateCollections(this.#collections);
194
+ if (this.#disposed) {
195
+ return;
196
+ }
197
+ this.updateCollections(this.#collections);
198
+ if (!this.#reconcileOnStart) {
199
+ return;
200
+ }
201
+ // Generation work waits for the snapshot baseline, then runs a full
202
+ // syncCollection through the normal flush path (write lease, failure
203
+ // backoff, source availability). Unwatchable collections queue nothing.
204
+ for (const name of this.#watchers.keys()) {
205
+ const pending = this.#pendingByCollection.get(name) ?? emptyPending();
206
+ pending.generationReconcile = true;
207
+ this.#pendingByCollection.set(name, pending);
181
208
  }
182
209
  }
183
210
 
@@ -216,6 +243,8 @@ export class CollectionWatchService {
216
243
  this.#timers.clear();
217
244
  this.#flushDeadlineAt.clear();
218
245
  this.#retryScheduled.clear();
246
+ this.#retryFailures.clear();
247
+ this.#freshEvents.clear();
219
248
  this.#watchers.clear();
220
249
  this.#watchRoots.clear();
221
250
  this.#collectionGenerations.clear();
@@ -303,6 +332,8 @@ export class CollectionWatchService {
303
332
  flushDeadlineAt: this.#flushDeadlineAt,
304
333
  timers: this.#timers,
305
334
  retryScheduled: this.#retryScheduled,
335
+ retryFailures: this.#retryFailures,
336
+ freshEvents: this.#freshEvents,
306
337
  inFlightSyncs: this.#inFlightSyncs,
307
338
  runFlush: (name) => this.#flushCollection(name),
308
339
  };
@@ -316,6 +347,8 @@ export class CollectionWatchService {
316
347
  this.#snapshotInit.delete(collectionName);
317
348
  this.#flushDeadlineAt.delete(collectionName);
318
349
  this.#retryScheduled.delete(collectionName);
350
+ this.#retryFailures.delete(collectionName);
351
+ this.#freshEvents.delete(collectionName);
319
352
  const timer = this.#timers.get(collectionName);
320
353
  if (timer) {
321
354
  clearTimeout(timer);
@@ -9,8 +9,11 @@
9
9
  * store connection: the archive import lock, receipts and error codes are
10
10
  * unchanged.
11
11
  *
12
- * The child reads one request from stdin and writes one JSON result line to
13
- * stdout. The request carries the caller's loaded config: the child opens
12
+ * The child reads one request line from stdin and writes one JSON result line
13
+ * to stdout. The parent keeps stdin open for the child's lifetime: EOF means
14
+ * the parent died, and the child exits instead of importing on as an orphan.
15
+ * A resident kills its import children when it shuts down, and every import
16
+ * has a timeout. The request carries the caller's loaded config: the child opens
14
17
  * the index without syncing any config into it and imports with exactly the
15
18
  * config the server holds. From source the child runs this module; a
16
19
  * compiled executable re-runs itself with `IMPORT_CHILD_ENV` set, which the
@@ -87,12 +90,24 @@ async function importInThisProcess(
87
90
  }
88
91
  }
89
92
 
93
+ /** Longest a single import may run before its child is killed. */
94
+ export const SESSION_IMPORT_TIMEOUT_MS = 30 * 60_000;
95
+
96
+ /** Import children still running in this process. */
97
+ const liveChildren = new Set<Bun.Subprocess>();
98
+
99
+ /** Kill every running import child (resident shutdown). */
100
+ export function killImportChildren(): void {
101
+ for (const child of liveChildren) child.kill("SIGKILL");
102
+ }
103
+
90
104
  /**
91
105
  * Import a registered source without blocking the caller's event loop.
92
106
  * Throws a `SessionsError` for typed failures and a plain `Error` otherwise.
93
107
  */
94
108
  export async function importInChildProcess(
95
- request: ImportChildRequest
109
+ request: ImportChildRequest,
110
+ options: { timeoutMs?: number } = {}
96
111
  ): Promise<SessionImportReceipt> {
97
112
  // A compiled executable cannot run an external TS entry: it re-runs itself.
98
113
  // Shared check: covers the POSIX `/$bunfs/` and Windows `B:/~BUN/` roots.
@@ -100,15 +115,38 @@ export async function importInChildProcess(
100
115
  const child = Bun.spawn({
101
116
  cmd: compiled ? [process.execPath] : [process.execPath, import.meta.path],
102
117
  env: compiled ? { ...process.env, [IMPORT_CHILD_ENV]: "1" } : process.env,
103
- stdin: new Blob([JSON.stringify(request)]),
118
+ // The request line, then held open as the child's lifeline.
119
+ stdin: "pipe",
104
120
  stdout: "pipe",
105
121
  stderr: "pipe",
106
122
  });
107
- const [stdout, stderr, exitCode] = await Promise.all([
108
- new Response(child.stdout).text(),
109
- new Response(child.stderr).text(),
110
- child.exited,
111
- ]);
123
+ liveChildren.add(child);
124
+ const timeoutMs = options.timeoutMs ?? SESSION_IMPORT_TIMEOUT_MS;
125
+ let timedOut = false;
126
+ const timer = setTimeout(() => {
127
+ timedOut = true;
128
+ child.kill("SIGKILL");
129
+ }, timeoutMs);
130
+ let output: [string, string, number];
131
+ try {
132
+ void child.stdin.write(`${JSON.stringify(request)}\n`);
133
+ void child.stdin.flush();
134
+ output = await Promise.all([
135
+ new Response(child.stdout).text(),
136
+ new Response(child.stderr).text(),
137
+ child.exited,
138
+ ]);
139
+ } finally {
140
+ clearTimeout(timer);
141
+ liveChildren.delete(child);
142
+ void Promise.resolve(child.stdin.end()).catch(() => undefined);
143
+ }
144
+ const [stdout, stderr, exitCode] = output;
145
+ if (timedOut) {
146
+ throw new Error(
147
+ `session import timed out after ${Math.round(timeoutMs / 1000)} s`
148
+ );
149
+ }
112
150
  const line = stdout.trim().split("\n").at(-1) ?? "";
113
151
  let result: ImportChildResult | null = null;
114
152
  try {
@@ -126,15 +164,43 @@ export async function importInChildProcess(
126
164
  throw new Error(result.message);
127
165
  }
128
166
 
129
- /** Child entry: one request on stdin, one JSON result line on stdout. */
167
+ /** Read the request line; afterwards stdin is only the lifeline. */
168
+ async function readRequestLine(
169
+ reader: ReadableStreamDefaultReader<Uint8Array>
170
+ ): Promise<string> {
171
+ const decoder = new TextDecoder();
172
+ let text = "";
173
+ while (!text.includes("\n")) {
174
+ const chunk = await reader.read();
175
+ if (chunk.done) break;
176
+ text += decoder.decode(chunk.value, { stream: true });
177
+ }
178
+ return text.split("\n")[0] ?? "";
179
+ }
180
+
181
+ /** Child entry: one request line on stdin, one JSON result line on stdout. */
130
182
  export async function runImportChild(): Promise<void> {
131
183
  // Nothing this child starts may re-enter child mode.
132
184
  delete process.env[IMPORT_CHILD_ENV];
185
+ const reader = Bun.stdin.stream().getReader();
186
+ let finished = false;
133
187
  let result: ImportChildResult;
134
188
  try {
135
189
  const request = JSON.parse(
136
- await new Response(Bun.stdin.stream()).text()
190
+ await readRequestLine(reader)
137
191
  ) as ImportChildRequest;
192
+ // The parent holds stdin open until it has our result; EOF before that
193
+ // means it died. Noticed between import steps.
194
+ void (async () => {
195
+ try {
196
+ while (!(await reader.read()).done) {
197
+ // nothing is written after the request
198
+ }
199
+ } catch {
200
+ // a broken pipe is the same as EOF
201
+ }
202
+ if (!finished) process.exit(1);
203
+ })();
138
204
  result = { ok: true, receipt: await importInThisProcess(request) };
139
205
  } catch (error) {
140
206
  result =
@@ -146,7 +212,9 @@ export async function runImportChild(): Promise<void> {
146
212
  message: error instanceof Error ? error.message : String(error),
147
213
  };
148
214
  }
215
+ finished = true;
149
216
  process.stdout.write(`${JSON.stringify(result)}\n`);
217
+ void reader.cancel().catch(() => undefined);
150
218
  }
151
219
 
152
220
  if (import.meta.main) await runImportChild();
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Migration: index stored vector byte lengths per model.
3
+ *
4
+ * Status checks the stored embedding dimensions for the active model by
5
+ * comparing the smallest and largest vector in its partition. Without an
6
+ * index that is a scan of every vector on each status build (about 0.6 ms
7
+ * per thousand vectors), run on the resident's main thread. With this
8
+ * covering index each bound is a single index seek.
9
+ *
10
+ * @module src/store/migrations/035-vector-length-index
11
+ */
12
+
13
+ import type { Database } from "bun:sqlite";
14
+
15
+ import type { Migration } from "./runner";
16
+
17
+ export const migration: Migration = {
18
+ version: 35,
19
+ name: "vector_length_index",
20
+
21
+ up(db: Database): void {
22
+ db.exec(
23
+ "CREATE INDEX IF NOT EXISTS idx_vectors_model_bytes ON content_vectors(model, length(embedding))"
24
+ );
25
+ },
26
+
27
+ down(db: Database): void {
28
+ db.exec("DROP INDEX IF EXISTS idx_vectors_model_bytes");
29
+ },
30
+ };
@@ -48,6 +48,7 @@ import { migration as m031 } from "./031-runtime-independent-vectors";
48
48
  import { migration as m032 } from "./032-vector-runtime-callers";
49
49
  import { migration as m033 } from "./033-drop-documents-active-index";
50
50
  import { migration as m034 } from "./034-collection-link-workspace";
51
+ import { migration as m035 } from "./035-vector-length-index";
51
52
 
52
53
  /** All migrations in order */
53
54
  export const migrations = [
@@ -85,4 +86,5 @@ export const migrations = [
85
86
  m032,
86
87
  m033,
87
88
  m034,
89
+ m035,
88
90
  ];
@@ -979,7 +979,10 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
979
979
  }
980
980
  });
981
981
 
982
- transaction();
982
+ // Read-then-write: take the write lock up front. A deferred transaction
983
+ // whose snapshot goes stale (another process commits meanwhile) fails at
984
+ // once with "database is locked" instead of waiting out the busy timeout.
985
+ transaction.immediate();
983
986
  return ok(undefined);
984
987
  } catch (cause) {
985
988
  return err(
@@ -35,14 +35,21 @@ export function getStoredEmbeddingDimensions(
35
35
  }
36
36
  }
37
37
  // Validate the entire model partition: never trust an arbitrary first blob.
38
- const row = db
39
- .prepare(`SELECT MIN(length(embedding)) AS smallest,
40
- MAX(length(embedding)) AS largest FROM content_vectors WHERE model = ?`)
41
- .get(model) as { smallest: number | null; largest: number | null };
42
- const bytes = row.smallest;
38
+ // One aggregate per query so each bound is a seek on idx_vectors_model_bytes
39
+ // (MIN and MAX together would scan every vector), read in one transaction
40
+ // so both bounds see the same snapshot while another process writes.
41
+ const bound = (aggregate: "MIN" | "MAX"): number | null =>
42
+ (
43
+ db
44
+ .prepare(
45
+ `SELECT ${aggregate}(length(embedding)) AS bytes FROM content_vectors WHERE model = ?`
46
+ )
47
+ .get(model) as { bytes: number | null }
48
+ ).bytes;
49
+ const [bytes, largest] = db.transaction(() => [bound("MIN"), bound("MAX")])();
43
50
  if (
44
51
  !bytes ||
45
- bytes !== row.largest ||
52
+ bytes !== largest ||
46
53
  bytes % Float32Array.BYTES_PER_ELEMENT !== 0
47
54
  ) {
48
55
  return undefined;
@@ -1 +0,0 @@
1
- 2d8fa6721fe305e2fd70450405e255900d028b187c884bbf74f29a3a33d27d3c gno-browser-clipper-v2.9.0.zip