@gmickel/gno 2.6.0 → 2.7.1
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 +25 -30
- package/assets/skill/SKILL.md +5 -3
- package/assets/skill/cli-reference.md +9 -2
- package/assets/skill/mcp-reference.md +2 -1
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.6.0.zip → gno-browser-clipper-v2.7.1.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.7.1.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +2 -1
- package/spec/cli.md +109 -18
- package/spec/mcp.md +36 -2
- package/spec/output-schemas/ask.schema.json +1 -1
- package/spec/output-schemas/capture-receipt.schema.json +1 -1
- package/spec/output-schemas/collection-list.schema.json +2 -2
- package/spec/output-schemas/doctor.schema.json +88 -0
- package/spec/output-schemas/error.schema.json +11 -2
- package/spec/output-schemas/get.schema.json +1 -1
- package/spec/output-schemas/mcp-capture-result.schema.json +1 -2
- package/spec/output-schemas/memory-remember.schema.json +2 -2
- package/spec/output-schemas/multi-get.schema.json +4 -1
- package/spec/output-schemas/peek.schema.json +2 -9
- package/spec/output-schemas/resident-status.schema.json +22 -0
- package/spec/output-schemas/search-result.schema.json +1 -1
- package/spec/output-schemas/search-results.schema.json +1 -1
- package/spec/output-schemas/status.schema.json +110 -16
- package/src/cli/commands/ask.ts +31 -12
- package/src/cli/commands/doctor.ts +54 -20
- package/src/cli/commands/embed.ts +41 -3
- package/src/cli/commands/ls.ts +6 -1
- package/src/cli/commands/query.ts +5 -0
- package/src/cli/commands/status.ts +63 -5
- package/src/cli/commands/vec.ts +54 -0
- package/src/cli/detach.ts +29 -1
- package/src/cli/errors.ts +13 -9
- package/src/cli/program.ts +53 -1
- package/src/core/capture-sync.ts +9 -2
- package/src/core/host-paths.ts +49 -0
- package/src/core/memory-remember.ts +4 -3
- package/src/core/request-receipts.ts +63 -9
- package/src/core/shutdown-budget.ts +6 -0
- package/src/core/vector-partition-status.ts +52 -0
- package/src/core/windows-private-path.ts +136 -1
- package/src/embed/backlog.ts +145 -27
- package/src/embed/fingerprint.ts +6 -3
- package/src/embed/retry.ts +66 -27
- package/src/embed/variant-backlog.ts +48 -18
- package/src/embed/variant-retry.ts +31 -22
- package/src/index.ts +21 -2
- package/src/llm/inference-scope.ts +18 -0
- package/src/llm/native-worker/dispatcher.ts +2 -0
- package/src/llm/native-worker/embedding-identity.ts +42 -0
- package/src/llm/native-worker/protocol.ts +1 -0
- package/src/llm/types.ts +3 -0
- package/src/mcp/context.ts +9 -0
- package/src/mcp/resources/index.ts +6 -5
- package/src/mcp/tool-descriptions-core.ts +1 -1
- package/src/mcp/tools/capture.ts +1 -3
- package/src/mcp/tools/index.ts +11 -4
- package/src/mcp/tools/memory-remember.ts +1 -1
- package/src/mcp/tools/status.ts +23 -6
- package/src/pipeline/hybrid.ts +37 -7
- package/src/pipeline/vsearch.ts +14 -2
- package/src/serve/embed-scheduler.ts +133 -19
- package/src/serve/host-path-redaction.ts +116 -0
- package/src/serve/public/components/BootstrapStatus.tsx +5 -3
- package/src/serve/public/components/CaptureModal.tsx +1 -1
- package/src/serve/public/components/CollectionModelDialog.tsx +16 -13
- package/src/serve/public/components/CollectionsEmptyState.tsx +5 -3
- package/src/serve/public/components/FirstRunWizard.tsx +4 -2
- package/src/serve/public/components/sessions/SessionSearch.tsx +2 -2
- package/src/serve/public/components/sessions/SourcesPanel.tsx +77 -60
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/hooks/use-api.ts +17 -2
- package/src/serve/public/lib/request-intent.ts +8 -0
- package/src/serve/public/{components/sessions → lib}/snippet.tsx +2 -3
- package/src/serve/public/pages/Collections.tsx +16 -13
- package/src/serve/public/pages/Connectors.tsx +7 -4
- package/src/serve/public/pages/Dashboard.tsx +16 -11
- package/src/serve/public/pages/DocView.tsx +10 -5
- package/src/serve/public/pages/DocumentEditor.tsx +91 -14
- package/src/serve/public/pages/Search.tsx +1 -41
- package/src/serve/resident-runtime.ts +33 -1
- package/src/serve/resident-status.ts +13 -1
- package/src/serve/routes/sessions.ts +61 -5
- package/src/serve/server.ts +15 -12
- package/src/serve/status-model.ts +26 -6
- package/src/serve/status.ts +4 -7
- package/src/serve/watch-reconciliation-shared.ts +3 -0
- package/src/serve/watch-service-events.ts +3 -2
- package/src/serve/watch-service-run-flush.ts +35 -2
- package/src/serve/watch-service.ts +5 -0
- package/src/store/migrations/031-runtime-independent-vectors.ts +29 -0
- package/src/store/migrations/032-vector-runtime-callers.ts +17 -0
- package/src/store/migrations/index.ts +4 -0
- package/src/store/sqlite/adapter.ts +24 -3
- package/src/store/sqlite/change-journal-store.ts +1 -1
- package/src/store/sqlite/legacy-vector-ownership.ts +2 -1
- package/src/store/types.ts +11 -1
- package/src/store/vector/lazy.ts +46 -43
- package/src/store/vector/runtime-compat.ts +651 -0
- package/src/store/vector/sqlite-vec.ts +20 -2
- package/src/store/vector/status.ts +276 -35
- package/src/store/vector/types.ts +2 -0
- package/src/store/vector/variant-search.ts +71 -23
- package/src/store/vector/variants.ts +49 -14
- package/browser-extension/artifacts/gno-browser-clipper-v2.6.0.zip.sha256 +0 -1
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/** Windows owner-only evidence storage. Paths are data, never interpolated script.
|
|
2
2
|
* Fresh Windows objects may use the token default Owner instead of its User.
|
|
3
3
|
* Only those exact owner SIDs are accepted; allowed DACL entries remain User-only. */
|
|
4
|
+
// bun:ffi — reading a security descriptor in-process needs advapi32; Bun has no ACL API
|
|
5
|
+
import { dlopen, FFIType } from "bun:ffi";
|
|
6
|
+
|
|
4
7
|
// Use framework APIs directly: module auto-discovery depends on profile paths
|
|
5
8
|
// deliberately absent from isolated native workers.
|
|
6
9
|
const ACL = `
|
|
@@ -65,7 +68,8 @@ export async function windowsPrivatePath(
|
|
|
65
68
|
// ACL checks return no data; do not allocate an unused stdout pipe.
|
|
66
69
|
stdout: "ignore",
|
|
67
70
|
stderr: "pipe",
|
|
68
|
-
|
|
71
|
+
// A cold PowerShell start on a loaded host can take over 10s.
|
|
72
|
+
timeout: 30000,
|
|
69
73
|
}
|
|
70
74
|
);
|
|
71
75
|
const reader = child.stderr.getReader();
|
|
@@ -94,3 +98,134 @@ export async function windowsPrivatePath(
|
|
|
94
98
|
await child.exited;
|
|
95
99
|
}
|
|
96
100
|
}
|
|
101
|
+
|
|
102
|
+
const OWNER_AND_DACL = 0x1 | 0x4; // OWNER_ | DACL_SECURITY_INFORMATION
|
|
103
|
+
const FILE_ATTRIBUTE_DIRECTORY = 0x10;
|
|
104
|
+
const FILE_ATTRIBUTE_REPARSE_POINT = 0x4_00;
|
|
105
|
+
const INVALID_FILE_ATTRIBUTES = 0xff_ff_ff_ff;
|
|
106
|
+
const SECURITY_DESCRIPTOR_MAX_BYTES = 65_536;
|
|
107
|
+
|
|
108
|
+
interface Win32Security {
|
|
109
|
+
getFileAttributes: (path: Uint8Array) => number;
|
|
110
|
+
getFileSecurity: (
|
|
111
|
+
path: Uint8Array,
|
|
112
|
+
info: number,
|
|
113
|
+
descriptor: Uint8Array,
|
|
114
|
+
length: number,
|
|
115
|
+
needed: Uint32Array
|
|
116
|
+
) => number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let win32Security: Win32Security | null | undefined;
|
|
120
|
+
|
|
121
|
+
function loadWin32Security(): Win32Security | null {
|
|
122
|
+
if (win32Security !== undefined) return win32Security;
|
|
123
|
+
try {
|
|
124
|
+
const kernel32 = dlopen("kernel32.dll", {
|
|
125
|
+
GetFileAttributesW: { args: [FFIType.ptr], returns: FFIType.u32 },
|
|
126
|
+
});
|
|
127
|
+
const advapi32 = dlopen("advapi32.dll", {
|
|
128
|
+
GetFileSecurityW: {
|
|
129
|
+
args: [FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr],
|
|
130
|
+
returns: FFIType.i32,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
win32Security = {
|
|
134
|
+
getFileAttributes: kernel32.symbols.GetFileAttributesW,
|
|
135
|
+
getFileSecurity: advapi32.symbols.GetFileSecurityW,
|
|
136
|
+
};
|
|
137
|
+
} catch {
|
|
138
|
+
win32Security = null;
|
|
139
|
+
}
|
|
140
|
+
return win32Security;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Owner and DACL of a directory that is not a reparse point, read in-process
|
|
145
|
+
* (no PowerShell). Null when unavailable: callers treat that as unverified.
|
|
146
|
+
*/
|
|
147
|
+
export function windowsDirectoryDescriptor(path: string): Uint8Array | null {
|
|
148
|
+
if (process.platform !== "win32") return null;
|
|
149
|
+
const win32 = loadWin32Security();
|
|
150
|
+
if (!win32) return null;
|
|
151
|
+
const widePath = Buffer.from(`${path}\0`, "utf16le");
|
|
152
|
+
const attributes = win32.getFileAttributes(widePath);
|
|
153
|
+
if (
|
|
154
|
+
attributes === INVALID_FILE_ATTRIBUTES ||
|
|
155
|
+
(attributes & FILE_ATTRIBUTE_DIRECTORY) === 0 ||
|
|
156
|
+
(attributes & FILE_ATTRIBUTE_REPARSE_POINT) !== 0
|
|
157
|
+
) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
const descriptor = new Uint8Array(SECURITY_DESCRIPTOR_MAX_BYTES);
|
|
161
|
+
const needed = new Uint32Array(1);
|
|
162
|
+
const ok = win32.getFileSecurity(
|
|
163
|
+
widePath,
|
|
164
|
+
OWNER_AND_DACL,
|
|
165
|
+
descriptor,
|
|
166
|
+
descriptor.length,
|
|
167
|
+
needed
|
|
168
|
+
);
|
|
169
|
+
const length = needed[0] ?? 0;
|
|
170
|
+
if (ok === 0 || length === 0 || length > descriptor.length) return null;
|
|
171
|
+
return descriptor.slice(0, length);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const SE_DACL_PRESENT = 0x4;
|
|
175
|
+
const SE_SELF_RELATIVE = 0x80_00;
|
|
176
|
+
const ACCESS_ALLOWED_ACE_TYPE = 0;
|
|
177
|
+
const ACCESS_DENIED_ACE_TYPE = 1;
|
|
178
|
+
const FILE_ALL_ACCESS = 0x1f_01_ff;
|
|
179
|
+
|
|
180
|
+
function sidAt(view: DataView, offset: number): Uint8Array | null {
|
|
181
|
+
if (offset === 0 || offset + 8 > view.byteLength) return null;
|
|
182
|
+
const end = offset + 8 + 4 * view.getUint8(offset + 1);
|
|
183
|
+
if (end > view.byteLength) return null;
|
|
184
|
+
return new Uint8Array(view.buffer, view.byteOffset + offset, end - offset);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
|
|
188
|
+
return left.length === right.length && left.every((b, i) => b === right[i]);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Self-relative descriptor whose owner is the only principal any allow entry
|
|
193
|
+
* names, with full control among them. Stricter than the PowerShell policy
|
|
194
|
+
* (which also accepts a token default owner): a false here only means the
|
|
195
|
+
* authoritative check runs.
|
|
196
|
+
*/
|
|
197
|
+
export function isOwnerOnlyDescriptor(descriptor: Uint8Array): boolean {
|
|
198
|
+
const view = new DataView(
|
|
199
|
+
descriptor.buffer,
|
|
200
|
+
descriptor.byteOffset,
|
|
201
|
+
descriptor.byteLength
|
|
202
|
+
);
|
|
203
|
+
if (view.byteLength < 20 || view.getUint8(0) !== 1) return false;
|
|
204
|
+
const control = view.getUint16(2, true);
|
|
205
|
+
if ((control & SE_SELF_RELATIVE) === 0 || (control & SE_DACL_PRESENT) === 0)
|
|
206
|
+
return false;
|
|
207
|
+
const owner = sidAt(view, view.getUint32(4, true));
|
|
208
|
+
const dacl = view.getUint32(16, true);
|
|
209
|
+
if (!owner || dacl === 0 || dacl + 8 > view.byteLength) return false;
|
|
210
|
+
const aclEnd = dacl + view.getUint16(dacl + 2, true);
|
|
211
|
+
if (aclEnd > view.byteLength) return false;
|
|
212
|
+
let offset = dacl + 8;
|
|
213
|
+
let fullControl = false;
|
|
214
|
+
for (let ace = view.getUint16(dacl + 4, true); ace > 0; ace--) {
|
|
215
|
+
if (offset + 8 > aclEnd) return false;
|
|
216
|
+
const type = view.getUint8(offset);
|
|
217
|
+
const size = view.getUint16(offset + 2, true);
|
|
218
|
+
if (size < 8 || offset + size > aclEnd) return false;
|
|
219
|
+
if (type === ACCESS_ALLOWED_ACE_TYPE) {
|
|
220
|
+
const sid = sidAt(view, offset + 8);
|
|
221
|
+
if (!sid || offset + 8 + sid.length > offset + size) return false;
|
|
222
|
+
if (!sameBytes(sid, owner)) return false;
|
|
223
|
+
const mask = view.getUint32(offset + 4, true);
|
|
224
|
+
if ((mask & FILE_ALL_ACCESS) === FILE_ALL_ACCESS) fullControl = true;
|
|
225
|
+
} else if (type !== ACCESS_DENIED_ACE_TYPE) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
offset += size;
|
|
229
|
+
}
|
|
230
|
+
return fullControl;
|
|
231
|
+
}
|
package/src/embed/backlog.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
|
|
1
3
|
import type { EmbeddingPort } from "../llm/types";
|
|
2
4
|
/**
|
|
3
5
|
* Shared embedding backlog processor.
|
|
@@ -12,21 +14,27 @@ import type {
|
|
|
12
14
|
VectorStatsPort,
|
|
13
15
|
} from "../store/vector";
|
|
14
16
|
import type { VectorVariantStore } from "../store/vector/variants";
|
|
17
|
+
import type { AcquireWriteTurn } from "./retry";
|
|
15
18
|
|
|
16
19
|
import {
|
|
17
20
|
assertInferenceActive,
|
|
18
21
|
isBackgroundInference,
|
|
22
|
+
withInferencePage,
|
|
19
23
|
} from "../llm/inference-scope";
|
|
24
|
+
import { formatDocForEmbedding } from "../pipeline/contextual";
|
|
20
25
|
import { err, ok } from "../store/types";
|
|
26
|
+
import {
|
|
27
|
+
embeddingPartitionIdentity,
|
|
28
|
+
recordReferenceRuntime,
|
|
29
|
+
resolveRuntimePartition,
|
|
30
|
+
} from "../store/vector/runtime-compat";
|
|
21
31
|
import { getVectorStatsDatabase } from "../store/vector/stats";
|
|
22
32
|
import { createVectorVariantStore } from "../store/vector/variants";
|
|
23
|
-
import {
|
|
24
|
-
getEmbeddingFingerprint,
|
|
25
|
-
getVariantModelFingerprint,
|
|
26
|
-
} from "./fingerprint";
|
|
33
|
+
import { getEmbeddingFingerprint } from "./fingerprint";
|
|
27
34
|
import {
|
|
28
35
|
chunkRetryKey,
|
|
29
36
|
embedAndStoreBatch,
|
|
37
|
+
inWriteTurn,
|
|
30
38
|
MAX_EMBED_CHUNK_ATTEMPTS,
|
|
31
39
|
} from "./retry";
|
|
32
40
|
import { embedVariantBacklog } from "./variant-backlog";
|
|
@@ -47,6 +55,14 @@ export interface EmbedBacklogDeps {
|
|
|
47
55
|
variantStore?: VectorVariantStore;
|
|
48
56
|
/** Recheck the effective runtime identity after asynchronous inference. */
|
|
49
57
|
identityStillCurrent?: () => boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Write gate for callers that do not already hold the shared writer lease
|
|
60
|
+
* (the resident scheduler): taken around each page's writes and released
|
|
61
|
+
* after. null means another writer holds it; the pass stops as deferred.
|
|
62
|
+
*/
|
|
63
|
+
acquireWriteTurn?: AcquireWriteTurn;
|
|
64
|
+
/** Explicit confirmation to build a separate vector partition (never implied by --yes). */
|
|
65
|
+
allowNewPartition?: boolean;
|
|
50
66
|
}
|
|
51
67
|
|
|
52
68
|
export interface EmbedBacklogResult {
|
|
@@ -59,6 +75,8 @@ export interface EmbedBacklogResult {
|
|
|
59
75
|
contentionErrors?: number;
|
|
60
76
|
/** Error message if vec index sync failed (embeddings stored, but search may be stale) */
|
|
61
77
|
syncError?: string;
|
|
78
|
+
/** The pass stopped early because another writer held the write gate. */
|
|
79
|
+
deferred?: boolean;
|
|
62
80
|
}
|
|
63
81
|
|
|
64
82
|
interface Cursor {
|
|
@@ -78,7 +96,18 @@ export async function embedBacklog(
|
|
|
78
96
|
deps: EmbedBacklogDeps
|
|
79
97
|
): Promise<StoreResult<EmbedBacklogResult>> {
|
|
80
98
|
assertInferenceActive();
|
|
81
|
-
|
|
99
|
+
if (deps.acquireWriteTurn && !deps.variantStore) {
|
|
100
|
+
// Model loading stays outside the write turn; preparation's partition
|
|
101
|
+
// writes below take one like every other background write.
|
|
102
|
+
const initialized = await deps.embedPort.init();
|
|
103
|
+
if (!initialized.ok) return err("INTERNAL", initialized.error.message);
|
|
104
|
+
}
|
|
105
|
+
const turn = await inWriteTurn(deps.acquireWriteTurn, () =>
|
|
106
|
+
prepareEmbeddingBacklog(deps)
|
|
107
|
+
);
|
|
108
|
+
if (turn.deferred)
|
|
109
|
+
return ok({ embedded: 0, errors: 0, contentionErrors: 0, deferred: true });
|
|
110
|
+
const prepared = turn.value;
|
|
82
111
|
if (!prepared.ok) return prepared;
|
|
83
112
|
deps = prepared.value;
|
|
84
113
|
if (deps.variantStore) return embedVariantBacklog(deps, deps.variantStore);
|
|
@@ -193,15 +222,29 @@ export async function embedBacklog(
|
|
|
193
222
|
}
|
|
194
223
|
|
|
195
224
|
const beforeEmbedded = embedded;
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
225
|
+
const storePage = () =>
|
|
226
|
+
embedAndStoreBatch({
|
|
227
|
+
embedPort,
|
|
228
|
+
vectorIndex,
|
|
229
|
+
items: batch,
|
|
230
|
+
modelUri,
|
|
231
|
+
embedFingerprint,
|
|
232
|
+
identityStillCurrent: deps.identityStillCurrent,
|
|
233
|
+
statsPort,
|
|
234
|
+
acquireWriteTurn: deps.acquireWriteTurn,
|
|
235
|
+
});
|
|
236
|
+
const batchStoreResult = background
|
|
237
|
+
? await withInferencePage(storePage)
|
|
238
|
+
: await storePage();
|
|
239
|
+
if (!batchStoreResult) {
|
|
240
|
+
// Past its deadline: this page stays pending for the next pass.
|
|
241
|
+
errors += batch.length;
|
|
242
|
+
deps.onProgress?.(embedded, errors);
|
|
243
|
+
await Bun.sleep(0);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (batchStoreResult.deferred)
|
|
247
|
+
return ok({ embedded, errors, contentionErrors, deferred: true });
|
|
205
248
|
embedded += batchStoreResult.embedded;
|
|
206
249
|
errors += batchStoreResult.errors;
|
|
207
250
|
contentionErrors += batchStoreResult.contentionErrors;
|
|
@@ -225,7 +268,12 @@ export async function embedBacklog(
|
|
|
225
268
|
// Sync vec index once at end if any vec0 writes failed
|
|
226
269
|
let syncError: string | undefined;
|
|
227
270
|
if (vectorIndex.vecDirty) {
|
|
228
|
-
const
|
|
271
|
+
const turn = await inWriteTurn(deps.acquireWriteTurn, () =>
|
|
272
|
+
vectorIndex.syncVecIndex()
|
|
273
|
+
);
|
|
274
|
+
if (turn.deferred)
|
|
275
|
+
return ok({ embedded, errors, contentionErrors, deferred: true });
|
|
276
|
+
const syncResult = turn.value;
|
|
229
277
|
if (syncResult.ok) {
|
|
230
278
|
const { added, removed } = syncResult.value;
|
|
231
279
|
if (added > 0 || removed > 0) {
|
|
@@ -248,6 +296,59 @@ export async function embedBacklog(
|
|
|
248
296
|
}
|
|
249
297
|
}
|
|
250
298
|
|
|
299
|
+
function formatEstimate(ms: number): string {
|
|
300
|
+
const seconds = Math.max(1, Math.round(ms / 1000));
|
|
301
|
+
if (seconds < 90) return `about ${seconds} s`;
|
|
302
|
+
const minutes = Math.round(seconds / 60);
|
|
303
|
+
return minutes < 90
|
|
304
|
+
? `about ${minutes} min`
|
|
305
|
+
: `about ${(minutes / 60).toFixed(1)} h`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Time this port on a few current chunks when no compatibility sample ran. */
|
|
309
|
+
async function measureEmbedRate(
|
|
310
|
+
db: Database,
|
|
311
|
+
port: EmbeddingPort
|
|
312
|
+
): Promise<number | undefined> {
|
|
313
|
+
const inputs = db
|
|
314
|
+
.query<{ text: string; title: string | null }, []>(`
|
|
315
|
+
SELECT c.text, d.title FROM documents d
|
|
316
|
+
JOIN content_chunks c ON c.mirror_hash = d.mirror_hash
|
|
317
|
+
WHERE d.active = 1 ORDER BY d.id, c.seq LIMIT 8
|
|
318
|
+
`)
|
|
319
|
+
.all()
|
|
320
|
+
.map((row) =>
|
|
321
|
+
formatDocForEmbedding(row.text, row.title ?? undefined, port.modelUri)
|
|
322
|
+
);
|
|
323
|
+
if (!inputs.length) return undefined;
|
|
324
|
+
const startedAt = performance.now();
|
|
325
|
+
const result = await port.embedBatch(inputs);
|
|
326
|
+
return result.ok
|
|
327
|
+
? (performance.now() - startedAt) / inputs.length
|
|
328
|
+
: undefined;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** R3: a fork names itself, its full size and a measured estimate before it runs. */
|
|
332
|
+
async function separatePartitionMessage(
|
|
333
|
+
db: Database,
|
|
334
|
+
port: EmbeddingPort,
|
|
335
|
+
reason: string,
|
|
336
|
+
msPerChunk: number | undefined
|
|
337
|
+
): Promise<string> {
|
|
338
|
+
const chunks = db
|
|
339
|
+
.query<{ count: number }, []>(`
|
|
340
|
+
SELECT count(*) AS count FROM documents d
|
|
341
|
+
JOIN content_chunks c ON c.mirror_hash = d.mirror_hash WHERE d.active = 1
|
|
342
|
+
`)
|
|
343
|
+
.get()!.count;
|
|
344
|
+
const rate = msPerChunk ?? (await measureEmbedRate(db, port));
|
|
345
|
+
const estimate =
|
|
346
|
+
rate === undefined
|
|
347
|
+
? "no estimate: embedding could not be timed"
|
|
348
|
+
: `estimated ${formatEstimate(rate * chunks)} at the measured ${Math.round(rate)} ms per chunk`;
|
|
349
|
+
return `Embedding would build a separate vector partition (${reason}). It re-embeds all ${chunks} chunks (${estimate}). Confirm with \`gno embed --new-partition\`; --yes alone does not confirm.`;
|
|
350
|
+
}
|
|
351
|
+
|
|
251
352
|
/** Resolve authority before counts, dry runs, forced work, or early returns. */
|
|
252
353
|
export async function prepareEmbeddingBacklog(
|
|
253
354
|
deps: EmbedBacklogDeps
|
|
@@ -259,19 +360,36 @@ export async function prepareEmbeddingBacklog(
|
|
|
259
360
|
const initialized = await deps.embedPort.init();
|
|
260
361
|
if (!initialized.ok) return err("INTERNAL", initialized.error.message);
|
|
261
362
|
const identity = deps.embedPort.getIdentity?.();
|
|
262
|
-
|
|
363
|
+
const primary = embeddingPartitionIdentity(deps.embedPort);
|
|
364
|
+
if (identity && primary) {
|
|
263
365
|
const identitySnapshot = JSON.stringify(identity);
|
|
264
|
-
const dimensions =
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
366
|
+
const dimensions = primary.dimensions;
|
|
367
|
+
const resolved = await resolveRuntimePartition(
|
|
368
|
+
db,
|
|
369
|
+
deps.embedPort,
|
|
370
|
+
primary
|
|
371
|
+
);
|
|
372
|
+
if (resolved.blocked && !deps.allowNewPartition)
|
|
373
|
+
return err(
|
|
374
|
+
"VECTOR_PARTITION_FORK",
|
|
375
|
+
await separatePartitionMessage(
|
|
376
|
+
db,
|
|
377
|
+
deps.embedPort,
|
|
378
|
+
resolved.blocked.reason,
|
|
379
|
+
resolved.msPerChunk
|
|
380
|
+
)
|
|
381
|
+
);
|
|
382
|
+
const variantStore = await createVectorVariantStore(
|
|
383
|
+
db,
|
|
384
|
+
resolved.blocked?.separate ?? resolved.identity,
|
|
385
|
+
identity.runtimeLabel
|
|
386
|
+
);
|
|
387
|
+
if (resolved.blocked || resolved.verdict === "unverified") {
|
|
388
|
+
// Vectors without a current owner cannot be measured; they must not
|
|
389
|
+
// survive to be reused by the runtime that becomes the reference.
|
|
390
|
+
variantStore.collectGarbage();
|
|
391
|
+
recordReferenceRuntime(db, variantStore.partitionId, identity);
|
|
392
|
+
}
|
|
275
393
|
variantStore.selectForEmbedding();
|
|
276
394
|
return ok({
|
|
277
395
|
...deps,
|
package/src/embed/fingerprint.ts
CHANGED
|
@@ -36,16 +36,19 @@ export function getEmbeddingFingerprint(
|
|
|
36
36
|
.digest("hex");
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Partition identity: actual weights plus the formatter policy. Runtime details
|
|
41
|
+
* (Bun, native binding, backend, threads) are provenance, never identity; the
|
|
42
|
+
* measured compatibility check decides whether a runtime may share vectors.
|
|
43
|
+
*/
|
|
40
44
|
export function getVariantModelFingerprint(
|
|
41
45
|
input: EmbeddingFingerprintInput,
|
|
42
|
-
identity: { modelFingerprint: string
|
|
46
|
+
identity: { modelFingerprint: string }
|
|
43
47
|
): string {
|
|
44
48
|
return new Bun.CryptoHasher("sha256")
|
|
45
49
|
.update(
|
|
46
50
|
JSON.stringify([
|
|
47
51
|
identity.modelFingerprint,
|
|
48
|
-
identity.runtimeFingerprint,
|
|
49
52
|
getEmbeddingFingerprint(input),
|
|
50
53
|
])
|
|
51
54
|
)
|
package/src/embed/retry.ts
CHANGED
|
@@ -15,6 +15,28 @@ import { getVectorStatsDatabase } from "../store/vector/stats";
|
|
|
15
15
|
import { embedTextsWithRecovery } from "./batch";
|
|
16
16
|
|
|
17
17
|
export const MAX_EMBED_CHUNK_ATTEMPTS = 2;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Write gate for callers that do not already hold the shared writer lease
|
|
21
|
+
* (the resident scheduler). Returns a release, or null when another writer
|
|
22
|
+
* holds the lease.
|
|
23
|
+
*/
|
|
24
|
+
export type AcquireWriteTurn = () => Promise<(() => Promise<void>) | null>;
|
|
25
|
+
|
|
26
|
+
/** Run `write` inside one write turn, or report that the gate is held elsewhere. */
|
|
27
|
+
export async function inWriteTurn<T>(
|
|
28
|
+
acquire: AcquireWriteTurn | undefined,
|
|
29
|
+
write: () => Promise<T>
|
|
30
|
+
): Promise<{ deferred: true } | { deferred: false; value: T }> {
|
|
31
|
+
if (!acquire) return { deferred: false, value: await write() };
|
|
32
|
+
const release = await acquire();
|
|
33
|
+
if (!release) return { deferred: true };
|
|
34
|
+
try {
|
|
35
|
+
return { deferred: false, value: await write() };
|
|
36
|
+
} finally {
|
|
37
|
+
await release();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
18
40
|
export const MAX_EMBED_FAILURE_SAMPLES = 5;
|
|
19
41
|
|
|
20
42
|
/** Total upsert attempts (initial + retries) when persistence hits SQLITE_BUSY/LOCKED. */
|
|
@@ -44,6 +66,8 @@ export interface EmbedStoreBatchResult {
|
|
|
44
66
|
suggestion?: string;
|
|
45
67
|
batchFailed: boolean;
|
|
46
68
|
batchError?: string;
|
|
69
|
+
/** The write gate was held elsewhere; nothing was persisted. */
|
|
70
|
+
deferred?: boolean;
|
|
47
71
|
}
|
|
48
72
|
|
|
49
73
|
// fn-127 integration: CLI consumers (src/cli/commands/embed.ts,
|
|
@@ -173,6 +197,8 @@ export async function embedAndStoreBatch(params: {
|
|
|
173
197
|
identityStillCurrent?: () => boolean;
|
|
174
198
|
/** Test seam: override contention-retry delays in milliseconds. */
|
|
175
199
|
delays?: number[];
|
|
200
|
+
/** Gate held only around persistence, never around inference. */
|
|
201
|
+
acquireWriteTurn?: AcquireWriteTurn;
|
|
176
202
|
}): Promise<EmbedStoreBatchResult> {
|
|
177
203
|
const { embedPort, vectorIndex, items, modelUri, embedFingerprint } = params;
|
|
178
204
|
const db = params.statsPort && getVectorStatsDatabase(params.statsPort);
|
|
@@ -282,35 +308,48 @@ export async function embedAndStoreBatch(params: {
|
|
|
282
308
|
);
|
|
283
309
|
});
|
|
284
310
|
};
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
311
|
+
const turn = await inWriteTurn(params.acquireWriteTurn, () =>
|
|
312
|
+
upsertVectorsWithContentionRetry(
|
|
313
|
+
{
|
|
314
|
+
upsertVectors: async (rows) => {
|
|
315
|
+
if (vectorIndex.upsertVectorsChecked) {
|
|
316
|
+
const result = await vectorIndex.upsertVectorsChecked(
|
|
317
|
+
rows,
|
|
318
|
+
(candidates) => {
|
|
319
|
+
committedRows = checkpoint(candidates);
|
|
320
|
+
return committedRows;
|
|
321
|
+
}
|
|
322
|
+
);
|
|
323
|
+
if (!result.ok) return result;
|
|
324
|
+
written = result.value;
|
|
325
|
+
return ok(undefined);
|
|
326
|
+
}
|
|
327
|
+
if (db)
|
|
328
|
+
return err("INVALID_INPUT", "Atomic vector checkpoint unavailable");
|
|
329
|
+
const valid = checkpoint(rows);
|
|
330
|
+
const result = await vectorIndex.upsertVectors(valid);
|
|
331
|
+
if (result.ok) {
|
|
332
|
+
written = valid.length;
|
|
333
|
+
committedRows = valid;
|
|
334
|
+
}
|
|
335
|
+
return result;
|
|
336
|
+
},
|
|
309
337
|
},
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
338
|
+
vectors,
|
|
339
|
+
params.delays
|
|
340
|
+
)
|
|
313
341
|
);
|
|
342
|
+
if (turn.deferred)
|
|
343
|
+
return {
|
|
344
|
+
embedded: 0,
|
|
345
|
+
errors: 0,
|
|
346
|
+
contentionErrors: 0,
|
|
347
|
+
retryItems: [],
|
|
348
|
+
errorSamples: [],
|
|
349
|
+
batchFailed: false,
|
|
350
|
+
deferred: true,
|
|
351
|
+
};
|
|
352
|
+
const storeResult = turn.value;
|
|
314
353
|
if (!storeResult.ok) {
|
|
315
354
|
if (isUpsertLockContention(storeResult.error)) {
|
|
316
355
|
return {
|
|
@@ -5,9 +5,11 @@ import type { EmbedBacklogDeps, EmbedBacklogResult } from "./backlog";
|
|
|
5
5
|
import {
|
|
6
6
|
assertInferenceActive,
|
|
7
7
|
isBackgroundInference,
|
|
8
|
+
withInferencePage,
|
|
8
9
|
} from "../llm/inference-scope";
|
|
9
10
|
import { err, ok } from "../store/types";
|
|
10
11
|
import { getVectorStatsDatabase } from "../store/vector/stats";
|
|
12
|
+
import { inWriteTurn } from "./retry";
|
|
11
13
|
import { variantBacklogPage } from "./variant-plan";
|
|
12
14
|
import { embedVariantBatch } from "./variant-retry";
|
|
13
15
|
|
|
@@ -37,7 +39,20 @@ export async function embedVariantBacklog(
|
|
|
37
39
|
const total = { embedded: 0, errors: 0, contentionErrors: 0 };
|
|
38
40
|
let after: { documentId: number; seq: number } | undefined;
|
|
39
41
|
try {
|
|
40
|
-
while (
|
|
42
|
+
while (true) {
|
|
43
|
+
// A failed request drops the port's cached identity (a timed-out native
|
|
44
|
+
// worker is retired); reload it so one failed page cannot end the pass.
|
|
45
|
+
if (background) {
|
|
46
|
+
const reloaded = await withInferencePage(() => deps.embedPort.init());
|
|
47
|
+
// Without an identity the pass cannot continue. Failing it schedules
|
|
48
|
+
// the scheduler's bounded retry instead of ending with pages pending.
|
|
49
|
+
if (!reloaded?.ok)
|
|
50
|
+
throw new Error(
|
|
51
|
+
reloaded?.error.message ??
|
|
52
|
+
"Embedding model reload exceeded its inference deadline"
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (!identityStillCurrent()) break;
|
|
41
56
|
assertInferenceActive();
|
|
42
57
|
const pending = variantBacklogPage(deps, store, batchSize, after);
|
|
43
58
|
if (!pending.length) break;
|
|
@@ -53,13 +68,26 @@ export async function embedVariantBacklog(
|
|
|
53
68
|
.get(owner.documentId, deps.collection!)
|
|
54
69
|
)
|
|
55
70
|
: pending;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
71
|
+
const embedPage = () =>
|
|
72
|
+
embedVariantBatch({
|
|
73
|
+
store,
|
|
74
|
+
embedPort: deps.embedPort,
|
|
75
|
+
owners,
|
|
76
|
+
identityStillCurrent,
|
|
77
|
+
force: deps.force,
|
|
78
|
+
acquireWriteTurn: deps.acquireWriteTurn,
|
|
79
|
+
});
|
|
80
|
+
let result = background
|
|
81
|
+
? await withInferencePage(embedPage)
|
|
82
|
+
: await embedPage();
|
|
83
|
+
if (!result) {
|
|
84
|
+
// Past its deadline: this page stays pending for the next pass.
|
|
85
|
+
total.errors += owners.length;
|
|
86
|
+
deps.onProgress?.(total.embedded, total.errors);
|
|
87
|
+
await Bun.sleep(0);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (result.deferred) return ok({ ...total, deferred: true });
|
|
63
91
|
total.embedded += result.embedded;
|
|
64
92
|
total.errors += result.errors;
|
|
65
93
|
total.contentionErrors += result.contentionErrors;
|
|
@@ -83,16 +111,18 @@ export async function embedVariantBacklog(
|
|
|
83
111
|
// Capture the epoch before checking completeness; activate rechecks under write lock.
|
|
84
112
|
const epoch = store.epoch();
|
|
85
113
|
if (identityStillCurrent() && !store.pending({ limit: 1 }).length) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
114
|
+
const turn = await inWriteTurn(deps.acquireWriteTurn, async () => {
|
|
115
|
+
try {
|
|
116
|
+
if (!store.isActive()) store.syncIndex();
|
|
117
|
+
if (identityStillCurrent()) store.activate(epoch);
|
|
118
|
+
return undefined;
|
|
119
|
+
} catch (cause) {
|
|
120
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
if (turn.deferred) return ok({ ...total, deferred: true });
|
|
124
|
+
if (turn.value !== undefined)
|
|
125
|
+
return ok({ ...total, syncError: turn.value });
|
|
96
126
|
}
|
|
97
127
|
assertInferenceActive();
|
|
98
128
|
return ok(total);
|