@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.
- package/README.md +1 -1
- package/browser-extension/artifacts/{gno-browser-clipper-v2.9.0.zip → gno-browser-clipper-v2.9.2.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.9.2.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +2 -1
- package/spec/cli.md +3 -0
- package/spec/output-schemas/collection-list.schema.json +5 -0
- package/src/cli/commands/daemon.ts +9 -0
- package/src/cli/detach.ts +42 -5
- package/src/cli/program.ts +11 -2
- package/src/config/types.ts +3 -0
- package/src/core/project-profile-apply-state.ts +3 -0
- package/src/core/resident-shutdown-handler.ts +23 -0
- package/src/core/sighup-disposition.ts +66 -0
- package/src/core/time-slice.ts +34 -0
- package/src/ingestion/graph-reconciliation.ts +18 -4
- package/src/ingestion/sync.ts +70 -4
- package/src/serve/parent-lifeline.ts +44 -0
- package/src/serve/resident-runtime.ts +24 -1
- package/src/serve/resident-shutdown.ts +4 -0
- package/src/serve/server.ts +22 -1
- package/src/serve/watch-reconciliation-shared.ts +12 -1
- package/src/serve/watch-reconciliation.ts +136 -1
- package/src/serve/watch-service-events.ts +43 -9
- package/src/serve/watch-service-flush-generation.ts +77 -3
- package/src/serve/watch-service-hosts.ts +12 -2
- package/src/serve/watch-service-lifecycle.ts +8 -0
- package/src/serve/watch-service-run-flush.ts +13 -2
- package/src/serve/watch-service.ts +37 -4
- package/src/sessions/import-child.ts +79 -11
- package/src/store/migrations/035-vector-length-index.ts +30 -0
- package/src/store/migrations/index.ts +2 -0
- package/src/store/sqlite/adapter.ts +4 -1
- package/src/store/vector/freshness.ts +13 -6
- 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 {
|
|
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
|
|
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 (
|
|
180
|
-
|
|
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
|
|
13
|
-
* stdout. The
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 !==
|
|
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
|