onbuzz 5.6.1 → 5.6.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.
@@ -0,0 +1,105 @@
1
+ {
2
+ "system": {
3
+ "maxAgentsPerProject": 10,
4
+ "qualityInspectorInterval": 10,
5
+ "defaultModel": "anthropic-sonnet",
6
+ "stateDirectory": ".loxia-state",
7
+ "maxPauseDuration": 300
8
+ },
9
+
10
+ "auth": {
11
+ "sessionTtlHours": 168
12
+ },
13
+
14
+ "context": {
15
+ "maxSize": 50000,
16
+ "maxReferences": 10,
17
+ "autoValidation": true,
18
+ "cacheExpiry": 3600
19
+ },
20
+
21
+ "models": {
22
+ "routingTable": {
23
+ "coding": ["anthropic-sonnet", "gpt-4", "gpt-5.1-codex-mini", "deepseek-r1"],
24
+ "analysis": ["gpt-4", "phi-4", "phi-4-reasoning"],
25
+ "quick-tasks": ["anthropic-haiku", "gpt-4-mini", "phi-4"],
26
+ "creative": ["gpt-4", "gpt-5.1-codex-mini"],
27
+ "fallback": ["anthropic-sonnet"]
28
+ }
29
+ },
30
+
31
+ "tools": {
32
+ "terminal": {
33
+ "timeout": 30000,
34
+ "enabled": true
35
+ },
36
+ "filesystem": {
37
+ "maxFileSize": 10485760,
38
+ "enabled": true
39
+ },
40
+ "browser": {
41
+ "timeout": 60000,
42
+ "enabled": true
43
+ },
44
+ "agentdelay": {
45
+ "maxDuration": 300,
46
+ "minDuration": 1,
47
+ "enabled": true
48
+ }
49
+ },
50
+
51
+ "backend": {
52
+ "baseUrl": "https://loxia-api-g7hrb8bxdae8a2h7.z02.azurefd.net",
53
+ "timeout": 270000
54
+ },
55
+
56
+ "relay": {
57
+ "wsUrl": "wss://loxia-relay.redriver-dc902718.northeurope.azurecontainerapps.io/ws",
58
+ "pwaBaseUrl": "https://delightful-tree-06bb8f903.7.azurestaticapps.net"
59
+ },
60
+
61
+ "budget": {
62
+ "limit": 100.00,
63
+ "alertThreshold": 0.8,
64
+ "trackUsage": true
65
+ },
66
+
67
+ "logging": {
68
+ "level": "info",
69
+ "outputs": ["console"],
70
+ "colors": true,
71
+ "timestamp": true,
72
+ "logFile": "logs/loxia.log",
73
+ "maxFileSize": 10485760,
74
+ "maxFiles": 5
75
+ },
76
+
77
+ "interfaces": {
78
+ "cli": {
79
+ "enabled": true,
80
+ "historySize": 1000
81
+ },
82
+ "web": {
83
+ "enabled": true,
84
+ "port": 8080,
85
+ "host": "0.0.0.0"
86
+ },
87
+ "vscode": {
88
+ "enabled": true,
89
+ "contextMenus": true,
90
+ "statusBar": true
91
+ }
92
+ },
93
+
94
+ "visualEditor": {
95
+ "enabled": true,
96
+ "defaultViewMode": "embedded",
97
+ "serverUrl": "http://localhost:4000",
98
+ "serverPort": 4000,
99
+ "maxInstances": 3,
100
+ "idleTimeoutMs": 600000,
101
+ "retryAttempts": 3,
102
+ "retryDelayMs": 1000,
103
+ "connectionTimeoutMs": 10000
104
+ }
105
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "onbuzz",
3
- "version": "5.6.1",
3
+ "version": "5.6.2",
4
4
  "description": "Loxia OnBuzz - Your AI Fleet",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -50,6 +50,7 @@
50
50
  "files": [
51
51
  "bin/",
52
52
  "src/",
53
+ "config/default.json",
53
54
  "web-ui/build/",
54
55
  "web-ui/dist/",
55
56
  "scripts/",
@@ -144,6 +145,7 @@
144
145
  },
145
146
  "files": [
146
147
  "src/**/*",
148
+ "config/default.json",
147
149
  "web-ui/build/**/*",
148
150
  "electron/**/*",
149
151
  "bin/**/*",
@@ -12,8 +12,8 @@
12
12
  * crash-looping). Any build that reads a swapped package.json must inherit
13
13
  * brand-correct artifact names.
14
14
  *
15
- * Usage: node scripts/swap-brand.js onbuzz
16
- * (Restore is a plain copy: package.autopilot.json package.json.)
15
+ * Usage: node scripts/swap-brand.js onbuzz — swap (refuses if already swapped)
16
+ * node scripts/swap-brand.js restore — copy the snapshot back + delete it
17
17
  */
18
18
 
19
19
  import fs from 'fs';
@@ -25,8 +25,33 @@ const pkgPath = path.join(ROOT, 'package.json');
25
25
  const backupPath = path.join(ROOT, 'package.autopilot.json');
26
26
 
27
27
  const target = process.argv[2];
28
- if (target !== 'onbuzz') {
29
- console.error('Usage: node scripts/swap-brand.js onbuzz');
28
+ if (target !== 'onbuzz' && target !== 'restore') {
29
+ console.error('Usage: node scripts/swap-brand.js onbuzz | restore');
30
+ process.exit(1);
31
+ }
32
+
33
+ if (target === 'restore') {
34
+ // Symmetric restore: byte-exact copy back, then DELETE the snapshot so a
35
+ // stale package.autopilot.json can never be restored over later edits or
36
+ // get committed. Idempotent: nothing to do when no snapshot exists.
37
+ if (!fs.existsSync(backupPath)) {
38
+ console.log('swap-brand: no package.autopilot.json snapshot — nothing to restore.');
39
+ process.exit(0);
40
+ }
41
+ fs.copyFileSync(backupPath, pkgPath);
42
+ fs.unlinkSync(backupPath);
43
+ console.log('swap-brand: restored Autopilot package.json (snapshot removed).');
44
+ process.exit(0);
45
+ }
46
+
47
+ const current = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
48
+ if (current.name === 'onbuzz') {
49
+ // REFUSE a double swap: snapshotting an already-swapped file would make
50
+ // "restore" restore the ONBUZZ identity — exactly how a swapped
51
+ // package.json survives to get committed (the v5.6.1 pre-release commit
52
+ // captured the onbuzz state). Run restore first.
53
+ console.error('swap-brand: package.json is ALREADY the onbuzz identity — refusing to snapshot it.');
54
+ console.error('Run `node scripts/swap-brand.js restore` first.');
30
55
  process.exit(1);
31
56
  }
32
57
 
@@ -35,7 +60,7 @@ if (target !== 'onbuzz') {
35
60
  // diffs after the restore).
36
61
  fs.copyFileSync(pkgPath, backupPath);
37
62
 
38
- const p = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
63
+ const p = current;
39
64
 
40
65
  // npm identity
41
66
  p.name = 'onbuzz';
@@ -50,6 +50,47 @@ beforeEach(() => {
50
50
  mockUnlink.mockResolvedValue(undefined);
51
51
  });
52
52
 
53
+ describe('saveJSON — serialization cost (mac event-loop stall incident)', () => {
54
+ test('small payloads stay pretty-printed (human-inspectable)', async () => {
55
+ const sm = makeSM();
56
+ await sm.saveJSON('/mock/userdata/state/small.json', { a: 1, nested: { b: 2 } });
57
+ const [, contents] = mockWriteFile.mock.calls[0];
58
+ expect(contents).toContain('\n '); // indented
59
+ });
60
+
61
+ test('payloads over PRETTY_PRINT_LIMIT are written COMPACT (faster stringify, smaller file)', async () => {
62
+ const sm = makeSM();
63
+ const big = { blob: 'x'.repeat(StateManager.PRETTY_PRINT_LIMIT + 10) };
64
+ await sm.saveJSON('/mock/userdata/state/big.json', big);
65
+ const [, contents] = mockWriteFile.mock.calls[0];
66
+ expect(contents).not.toContain('\n');
67
+ expect(JSON.parse(contents)).toEqual(big); // same data, different formatting
68
+ });
69
+
70
+ test('slow serialization warns ONCE per file per minute with path/bytes/ms', async () => {
71
+ const sm = makeSM();
72
+ sm._slowSaveMs = 0; // every save counts as slow
73
+ await sm.saveJSON('/mock/userdata/state/slow.json', { a: 1 });
74
+ await sm.saveJSON('/mock/userdata/state/slow.json', { a: 2 }); // rate-limited
75
+ await sm.saveJSON('/mock/userdata/state/other.json', { a: 3 }); // separate file → own warn
76
+ const slow = sm.logger.warn.mock.calls.filter(([m]) => /SLOW state serialization/.test(m));
77
+ expect(slow).toHaveLength(2);
78
+ expect(slow[0][1]).toMatchObject({
79
+ filePath: '/mock/userdata/state/slow.json',
80
+ bytes: expect.any(Number),
81
+ stringifyMs: expect.any(Number),
82
+ });
83
+ });
84
+
85
+ test('fast saves never warn (threshold respected)', async () => {
86
+ const sm = makeSM();
87
+ sm._slowSaveMs = 60_000; // nothing is that slow
88
+ await sm.saveJSON('/mock/userdata/state/fast.json', { a: 1 });
89
+ const slow = sm.logger.warn.mock.calls.filter(([m]) => /SLOW state serialization/.test(m));
90
+ expect(slow).toHaveLength(0);
91
+ });
92
+ });
93
+
53
94
  describe('saveJSON — atomic write', () => {
54
95
  test('writes to a temp file then renames it over the target', async () => {
55
96
  const sm = makeSM();
@@ -69,6 +69,14 @@ export async function runWithConcurrency(taskFns, limit) {
69
69
  }
70
70
 
71
71
  class StateManager {
72
+ /**
73
+ * Above this many compact-JSON bytes, state files are written compact
74
+ * instead of pretty-printed. Pretty-printing is for human inspection;
75
+ * at multi-MB scale it only adds stringify time (a synchronous
76
+ * event-loop stall) and disk bytes.
77
+ */
78
+ static PRETTY_PRINT_LIMIT = 2 * 1024 * 1024;
79
+
72
80
  constructor(config, logger) {
73
81
  this.config = config;
74
82
  this.logger = logger;
@@ -91,6 +99,11 @@ class StateManager {
91
99
  // new write sitting on top of a longer old one — real corruption we've seen.
92
100
  this._writeChains = new Map();
93
101
  this._tmpWriteSeq = 0;
102
+ // Serialization stall attribution (mac incident: event-loop p99 in the
103
+ // SECONDS with 38–78MB conversation files). Saves slower than this warn,
104
+ // rate-limited per file, so production logs NAME the stalling file.
105
+ this._slowSaveMs = 250;
106
+ this._slowSaveWarnAt = new Map();
94
107
 
95
108
  // State file paths
96
109
  this.stateFiles = {
@@ -1241,7 +1254,28 @@ class StateManager {
1241
1254
  // restart) now only ever damages a throwaway temp file — the live file is
1242
1255
  // swapped in by an atomic rename or not at all; and two concurrent saves of
1243
1256
  // the same file can no longer interleave into a half-old/half-new mess.
1244
- const jsonData = JSON.stringify(data, null, 2);
1257
+ // Stringify COMPACT first: pretty-printing is ~40% slower and ~35%
1258
+ // bigger, and it happens SYNCHRONOUSLY on the event loop — on a 78MB
1259
+ // conversation file that alone is seconds of stall (the mac
1260
+ // "engine unresponsive" incident). Small files stay pretty-printed for
1261
+ // human inspection; past the limit nobody reads them anyway.
1262
+ const t0 = Date.now();
1263
+ const compact = JSON.stringify(data);
1264
+ const jsonData = compact.length <= StateManager.PRETTY_PRINT_LIMIT
1265
+ ? JSON.stringify(data, null, 2)
1266
+ : compact;
1267
+ const stringifyMs = Date.now() - t0;
1268
+ if (stringifyMs >= this._slowSaveMs) {
1269
+ const now = Date.now();
1270
+ const prev = this._slowSaveWarnAt.get(filePath) || 0;
1271
+ if (now - prev >= 60_000) {
1272
+ this._slowSaveWarnAt.set(filePath, now);
1273
+ this.logger?.warn?.('[stateManager] SLOW state serialization — event-loop stall', {
1274
+ filePath, bytes: jsonData.length, stringifyMs,
1275
+ hint: 'this file is the stall culprit; consider splitting/compacting it',
1276
+ });
1277
+ }
1278
+ }
1245
1279
  const key = path.resolve(filePath);
1246
1280
 
1247
1281
  const prior = this._writeChains.get(key) || Promise.resolve();
@@ -7329,12 +7329,26 @@ h2{color:#16a34a;margin-bottom:.5rem;} p{color:#666;}</style></head>
7329
7329
  let allConnections = [];
7330
7330
  if (sessionConnections.length === 0) {
7331
7331
  allConnections = Array.from(this.connections.values());
7332
-
7333
- this.logger?.warn('🔄 No connections for session, trying all connections:', {
7334
- targetSessionId: sessionId,
7335
- totalConnections: this.connections.size,
7336
- allSessionIds: Array.from(this.connections.values()).map(c => c.sessionId).filter(Boolean)
7337
- });
7332
+
7333
+ // Rate-limited: agents running with the UI closed (or its socket
7334
+ // dropped) hit this on EVERY broadcast — a real mac log showed
7335
+ // hundreds of these warns in minutes, drowning the signal. Warn at
7336
+ // most once per session per minute; the count says what was elided.
7337
+ const now = Date.now();
7338
+ this._noConnWarnAt = this._noConnWarnAt || new Map();
7339
+ const key = sessionId || '(none)';
7340
+ const prev = this._noConnWarnAt.get(key);
7341
+ if (!prev || now - prev.at >= 60_000) {
7342
+ this.logger?.warn('🔄 No connections for session, trying all connections:', {
7343
+ targetSessionId: sessionId,
7344
+ totalConnections: this.connections.size,
7345
+ allSessionIds: Array.from(this.connections.values()).map(c => c.sessionId).filter(Boolean),
7346
+ elidedSinceLastWarn: prev?.elided ?? 0
7347
+ });
7348
+ this._noConnWarnAt.set(key, { at: now, elided: 0 });
7349
+ } else {
7350
+ prev.elided += 1;
7351
+ }
7338
7352
  }
7339
7353
 
7340
7354
  const targetConnections = sessionConnections.length > 0 ? sessionConnections : allConnections;
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Flush serialization — regression for the mac ENOENT incident.
3
+ *
4
+ * Concurrent mutators used to race the SAME `<file>.tmp`: both wrote it,
5
+ * the first rename consumed it, the second rename failed
6
+ * `ENOENT: rename reminisce.vec.json.tmp -> reminisce.vec.json`
7
+ * (repeating on every reminisceIndexer tick in the mac production log).
8
+ * _flush() now chains every write behind the previous one; these tests pin
9
+ * the storm, the failure isolation, and caller-sees-own-error semantics.
10
+ */
11
+ import { describe, test, expect, beforeEach, afterEach, jest } from '@jest/globals';
12
+ import fs from 'node:fs/promises';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { InMemoryJsonStore } from '../inMemoryJsonStore.js';
16
+
17
+ let dir;
18
+ const FP = 'test-model@1';
19
+ const vec = (seed) => Float32Array.from([seed, seed + 1, seed + 2]);
20
+
21
+ async function makeStore(file = 'store.vec.json') {
22
+ const store = new InMemoryJsonStore({
23
+ filePath: path.join(dir, file), dimensions: 3, modelFingerprint: FP,
24
+ });
25
+ await store.load();
26
+ return store;
27
+ }
28
+
29
+ beforeEach(async () => {
30
+ dir = await fs.mkdtemp(path.join(os.tmpdir(), 'vecstore-race-'));
31
+ });
32
+ afterEach(async () => {
33
+ await fs.rm(dir, { recursive: true, force: true });
34
+ });
35
+
36
+ describe('concurrent flush storm (the production race)', () => {
37
+ test('50 parallel upserts: no ENOENT, every row lands, file is valid JSON', async () => {
38
+ const store = await makeStore();
39
+ await Promise.all(Array.from({ length: 50 }, (_, i) =>
40
+ store.upsert(`row-${i}`, vec(i), { i })));
41
+
42
+ const onDisk = JSON.parse(await fs.readFile(path.join(dir, 'store.vec.json'), 'utf8'));
43
+ expect(onDisk.rows).toHaveLength(50);
44
+
45
+ // A fresh instance reloads all 50 (proves the last write held full state)
46
+ const reread = await makeStore();
47
+ expect((await reread.stats()).count).toBe(50);
48
+ });
49
+
50
+ test('interleaved upsert/delete/deleteWhere storm settles consistent', async () => {
51
+ const store = await makeStore('mixed.vec.json');
52
+ await Promise.all([
53
+ ...Array.from({ length: 20 }, (_, i) => store.upsert(`keep-${i}`, vec(i), { kind: 'keep' })),
54
+ ...Array.from({ length: 20 }, (_, i) => store.upsert(`drop-${i}`, vec(i + 100), { kind: 'drop' })),
55
+ ]);
56
+ await Promise.all([
57
+ store.deleteWhere((m) => m.kind === 'drop'),
58
+ store.upsert('late', vec(7), { kind: 'keep' }),
59
+ ]);
60
+ const reread = await makeStore('mixed.vec.json');
61
+ const stats = await reread.stats();
62
+ expect(stats.count).toBe(21); // 20 keeps + late, 20 drops gone
63
+ });
64
+ });
65
+
66
+ describe('flush coalescing + stall attribution', () => {
67
+ test('a 50-mutation burst costs at most 2 snapshot writes (not 50)', async () => {
68
+ const store = await makeStore('coalesce.vec.json');
69
+ const real = store._writeSnapshot.bind(store);
70
+ const spy = jest.fn(real);
71
+ store._writeSnapshot = spy;
72
+ await Promise.all(Array.from({ length: 50 }, (_, i) =>
73
+ store.upsert(`burst-${i}`, vec(i), { i })));
74
+ expect(spy.mock.calls.length).toBeLessThanOrEqual(2);
75
+ const reread = await makeStore('coalesce.vec.json');
76
+ expect((await reread.stats()).count).toBe(50); // nothing lost to coalescing
77
+ });
78
+
79
+ test('mutation AFTER a write started queues a fresh link (no lost update)', async () => {
80
+ const store = await makeStore('fresh-link.vec.json');
81
+ let release;
82
+ const gate = new Promise((r) => { release = r; });
83
+ const real = store._writeSnapshot.bind(store);
84
+ let calls = 0;
85
+ store._writeSnapshot = async () => { calls += 1; if (calls === 1) await gate; return real(); };
86
+
87
+ const first = store.upsert('one', vec(1), {});
88
+ await Promise.resolve(); // let the first write START (clears _flushQueued)
89
+ const second = store.upsert('two', vec(2), {}); // must queue a NEW link
90
+ release();
91
+ await Promise.all([first, second]);
92
+ expect(calls).toBe(2);
93
+ const reread = await makeStore('fresh-link.vec.json');
94
+ expect((await reread.stats()).count).toBe(2);
95
+ });
96
+
97
+ test('slow snapshot warns with path/bytes/rows, rate-limited to 1/min', async () => {
98
+ const warns = [];
99
+ const store = new InMemoryJsonStore({
100
+ filePath: path.join(dir, 'slow.vec.json'), dimensions: 3, modelFingerprint: FP,
101
+ logger: { warn: (...a) => warns.push(a) },
102
+ });
103
+ await store.load();
104
+ store._slowWriteMs = 0; // every write counts as slow
105
+ await store.upsert('a', vec(1), {});
106
+ await store.upsert('b', vec(2), {}); // within the rate window → elided
107
+ const slow = warns.filter(([msg]) => /SLOW snapshot/.test(msg));
108
+ expect(slow).toHaveLength(1);
109
+ expect(slow[0][1]).toMatchObject({ rows: expect.any(Number), stringifyMs: expect.any(Number) });
110
+ });
111
+ });
112
+
113
+ describe('load / validation / drop edges (file to 100%)', () => {
114
+ test('constructor arg validation', () => {
115
+ expect(() => new InMemoryJsonStore({})).toThrow(/filePath/);
116
+ expect(() => new InMemoryJsonStore({ filePath: 'x', dimensions: 0 })).toThrow(/dimensions/);
117
+ expect(() => new InMemoryJsonStore({ filePath: 'x', dimensions: 1.5 })).toThrow(/dimensions/);
118
+ expect(() => new InMemoryJsonStore({ filePath: 'x', dimensions: 3 })).toThrow(/modelFingerprint/);
119
+ });
120
+
121
+ test('load edges: corrupt JSON, version/dims/fingerprint mismatch, junk rows, read error, idempotent', async () => {
122
+ const f = (name) => path.join(dir, name);
123
+ const mk = async (name, content) => { await fs.writeFile(f(name), content, 'utf8'); };
124
+ const open = async (name, dims = 3, fp = FP) => {
125
+ const st = new InMemoryJsonStore({ filePath: f(name), dimensions: dims, modelFingerprint: fp,
126
+ logger: { warn: () => {}, info: () => {} } });
127
+ await st.load();
128
+ await st.load(); // idempotent second call
129
+ return st;
130
+ };
131
+
132
+ await mk('corrupt.vec.json', '{nope');
133
+ expect((await (await open('corrupt.vec.json')).stats()).count).toBe(0);
134
+
135
+ await mk('vers.vec.json', JSON.stringify({ version: -1, dimensions: 3, modelFingerprint: FP, rows: [] }));
136
+ expect((await (await open('vers.vec.json')).stats()).count).toBe(0);
137
+
138
+ const good = { version: (JSON.parse(await fs.readFile(f('vers.vec.json'), 'utf8')).version, 1), dimensions: 3, modelFingerprint: FP };
139
+ // Determine the real FILE_VERSION by writing through the store itself:
140
+ const w = new InMemoryJsonStore({ filePath: f('probe.vec.json'), dimensions: 3, modelFingerprint: FP });
141
+ await w.load();
142
+ await w.upsert('p', vec(1), {});
143
+ const FILE_VERSION = JSON.parse(await fs.readFile(f('probe.vec.json'), 'utf8')).version;
144
+
145
+ await mk('dims.vec.json', JSON.stringify({ version: FILE_VERSION, dimensions: 99, modelFingerprint: FP, rows: [] }));
146
+ expect((await (await open('dims.vec.json')).stats()).count).toBe(0);
147
+
148
+ await mk('fp.vec.json', JSON.stringify({ version: FILE_VERSION, dimensions: 3, modelFingerprint: 'other@2', rows: [] }));
149
+ expect((await (await open('fp.vec.json')).stats()).count).toBe(0);
150
+
151
+ await mk('rows.vec.json', JSON.stringify({ version: FILE_VERSION, dimensions: 3, modelFingerprint: FP, rows: 'junk' }));
152
+ expect((await (await open('rows.vec.json')).stats()).count).toBe(0);
153
+
154
+ await mk('junkrows.vec.json', JSON.stringify({ version: FILE_VERSION, dimensions: 3, modelFingerprint: FP,
155
+ rows: [null, { id: 7 }, { id: 'short', vector: [1] }, { id: 'ok', vector: [1, 2, 3] }] }));
156
+ expect((await (await open('junkrows.vec.json')).stats()).count).toBe(1);
157
+
158
+ // read error that is NOT ENOENT (directory as file)
159
+ await fs.mkdir(f('dir.vec.json'));
160
+ expect((await (await open('dir.vec.json')).stats()).count).toBe(0);
161
+ });
162
+
163
+ test('mutator validation + assertLoaded + deleteWhere edges', async () => {
164
+ const store = await makeStore('valid.vec.json');
165
+ await expect(store.upsert('', vec(1), {})).rejects.toThrow(/non-empty string/);
166
+ await expect(store.upsert('a', [1, 2, 3], {})).rejects.toThrow(/Float32Array/);
167
+ await expect(store.upsert('a', Float32Array.from([1]), {})).rejects.toThrow(/dimensions/);
168
+ await expect(store.upsertBatch('nope')).rejects.toThrow(/array/);
169
+ await expect(store.upsertBatch([{ id: '', vector: vec(1) }])).rejects.toThrow(/needs an id/);
170
+ await expect(store.deleteWhere('nope')).rejects.toThrow(/predicate/);
171
+ await store.delete('missing'); // no-throw on unknown id
172
+ expect(await store.deleteWhere(() => false)).toBe(0); // zero removed → no flush
173
+
174
+ // update-in-place branch (existing id) + upsertBatch update branch
175
+ await store.upsert('u', vec(1), { v: 1 });
176
+ // throwing predicate = treated as no-match PER ROW (store must have rows)
177
+ expect(await store.deleteWhere(() => { throw new Error('boom'); })).toBe(0);
178
+ await store.upsert('u', vec(2), { v: 2 });
179
+ await store.upsertBatch([{ id: 'u', vector: vec(3), metadata: { v: 3 } }]);
180
+ await store.upsertBatch([{ id: 'u2', vector: vec(4) }]); // metadata defaulted
181
+ expect((await store.stats()).count).toBe(2);
182
+
183
+ // delete swap-remove middle element
184
+ await store.upsert('tail', vec(9), {});
185
+ await store.delete('u'); // not last → swap path
186
+ expect((await store.stats()).count).toBe(2);
187
+
188
+ const unloaded = new InMemoryJsonStore({ filePath: path.join(dir, 'x.vec.json'), dimensions: 3, modelFingerprint: FP });
189
+ await expect(unloaded.upsert('a', vec(1), {})).rejects.toThrow(/not loaded/);
190
+ });
191
+
192
+ test('deleteWhere swap-removes a MIDDLE row (index remap branch)', async () => {
193
+ const store = await makeStore('mid.vec.json');
194
+ await store.upsert('first', vec(1), { kill: true });
195
+ await store.upsert('second', vec(2), { kill: false });
196
+ await store.upsert('third', vec(3), { kill: false });
197
+ expect(await store.deleteWhere((m) => m.kill)).toBe(1); // 'first' is mid-array from the tail walk
198
+ const q = await store.query(vec(2), { topK: 5 });
199
+ expect(q.map((r) => r.id).sort()).toEqual(['second', 'third']);
200
+ });
201
+
202
+ test('drop(): removes file (missing file silent; unlink failure warns)', async () => {
203
+ const store = await makeStore('drop.vec.json');
204
+ await store.upsert('a', vec(1), {});
205
+ await store.drop();
206
+ await store.drop(); // second drop: file already gone → ENOENT swallowed
207
+ expect((await store.stats()).count).toBe(0);
208
+
209
+ const warns = [];
210
+ const werr = new InMemoryJsonStore({ filePath: path.join(dir, 'wd.vec.json'), dimensions: 3,
211
+ modelFingerprint: FP, logger: { warn: (...a) => warns.push(a) } });
212
+ await werr.load();
213
+ await fs.mkdir(path.join(dir, 'wd.vec.json')); // unlink on a dir → EPERM/EISDIR (not ENOENT)
214
+ await werr.drop();
215
+ expect(warns.length).toBe(1);
216
+ });
217
+
218
+ test('stats() lastIndexedAt picks the newest createdAt', async () => {
219
+ const store = await makeStore('ts.vec.json');
220
+ await store.upsert('a', vec(1), { createdAt: '2026-01-01T00:00:00Z' });
221
+ await store.upsert('b', vec(2), { createdAt: '2026-03-01T00:00:00Z' });
222
+ await store.upsert('c', vec(3), {}); // no timestamp branch
223
+ expect((await store.stats()).lastIndexedAt).toBe('2026-03-01T00:00:00Z');
224
+ });
225
+ });
226
+
227
+ describe('query / hybridQuery branches', () => {
228
+ async function seeded() {
229
+ const store = await makeStore('q.vec.json');
230
+ await store.upsertBatch([
231
+ { id: 'alpha', vector: vec(1), metadata: { text: 'alpha document', tag: 'a' } },
232
+ { id: 'beta', vector: vec(5), metadata: { text: 'beta notes', tag: 'b' } },
233
+ { id: 'blank', vector: vec(9), metadata: { tag: 'a' } }, // no text branch
234
+ ]);
235
+ return store;
236
+ }
237
+
238
+ test('query: validation, filter, scoring adjuster, topK slice', async () => {
239
+ const store = await seeded();
240
+ await expect(store.query([1, 2, 3])).rejects.toThrow(/Float32Array/);
241
+
242
+ const all = await store.query(vec(1));
243
+ expect(all).toHaveLength(3);
244
+ expect(all[0].similarity).toBeDefined();
245
+
246
+ const filtered = await store.query(vec(1), { filter: (m) => m.tag === 'a' });
247
+ expect(filtered.map((r) => r.id).sort()).toEqual(['alpha', 'blank']);
248
+
249
+ const boosted = await store.query(vec(1), { scoring: (base, m) => (m.tag === 'b' ? base + 1000 : base) });
250
+ expect(boosted[0].id).toBe('beta');
251
+
252
+ expect(await store.query(vec(1), { topK: 1 })).toHaveLength(1);
253
+ });
254
+
255
+ test('hybridQuery: fuses substring + semantic; substring-only edges', async () => {
256
+ const store = await seeded();
257
+ const fused = await store.hybridQuery(vec(1), 'beta', { topK: 3 });
258
+ expect(fused.length).toBeGreaterThan(0);
259
+ expect(fused.some((r) => r.id === 'beta')).toBe(true);
260
+
261
+ // empty / non-string query text → substring list empty (still works)
262
+ expect((await store.hybridQuery(vec(1), '', { topK: 2 })).length).toBeGreaterThan(0);
263
+ expect((await store.hybridQuery(vec(1), null, { topK: 2 })).length).toBeGreaterThan(0);
264
+
265
+ // no match anywhere in substring path + filter applied
266
+ const filtered = await store.hybridQuery(vec(1), 'zzz-no-match', { filter: (m) => m.tag === 'a' });
267
+ expect(filtered.every((r) => r.metadata.tag === 'a')).toBe(true);
268
+
269
+ // defaults path (no opts object at all)
270
+ expect(Array.isArray(await store.hybridQuery(vec(1), 'alpha'))).toBe(true);
271
+
272
+ // TWO substring matches at different positions → the substring sort
273
+ // comparator actually runs (earlier match must rank higher)
274
+ await store.upsert('late-mention', vec(2), { text: 'notes about beta stuff' });
275
+ const two = await store.hybridQuery(vec(1), 'beta', { topK: 5 });
276
+ expect(two.filter((r) => ['beta', 'late-mention'].includes(r.id)).length).toBe(2);
277
+ });
278
+ });
279
+
280
+ describe('remaining default-arg + defensive branches', () => {
281
+ test('upsert/upsertBatch without metadata; delete the LAST row (no-swap branch)', async () => {
282
+ const store = await makeStore('defaults.vec.json');
283
+ await store.upsert('nometa', vec(1)); // metadata default {}
284
+ await store.upsertBatch([{ id: 'nometa', vector: vec(2) }]); // update-in-place, metadata || {} fallback
285
+ expect((await store.query(vec(1)))[0].metadata).toEqual({});
286
+ await store.upsert('tail', vec(3), {});
287
+ await store.delete('tail'); // idx === last → no swap
288
+ expect((await store.stats()).count).toBe(1);
289
+ });
290
+
291
+ test('hybridQuery tolerates a fused id that vanished from the index (defensive row-null branch)', async () => {
292
+ const store = await makeStore('phantom.vec.json');
293
+ await store.upsert('real', vec(1), { text: 'real thing' });
294
+ const realQuery = store.query.bind(store);
295
+ store.query = async (v, o) => {
296
+ const out = await realQuery(v, o);
297
+ return [{ id: 'phantom', score: 99, similarity: 0.99, metadata: {} }, ...out];
298
+ };
299
+ const fused = await store.hybridQuery(vec(1), 'real', { topK: 5 });
300
+ const phantom = fused.find((r) => r.id === 'phantom');
301
+ expect(phantom).toBeTruthy();
302
+ expect(phantom.metadata).toEqual({}); // row?.metadata || {} fallback
303
+ });
304
+ });
305
+
306
+ describe('failure isolation on the chain', () => {
307
+ test('a failing write rejects ITS caller only; later flushes succeed', async () => {
308
+ const store = await makeStore('fail.vec.json');
309
+ const real = store._writeSnapshot.bind(store);
310
+ let calls = 0;
311
+ store._writeSnapshot = jest.fn(async () => {
312
+ calls += 1;
313
+ if (calls === 1) throw new Error('disk hiccup');
314
+ return real();
315
+ });
316
+
317
+ await expect(store.upsert('a', vec(1), {})).rejects.toThrow('disk hiccup');
318
+ await expect(store.upsert('b', vec(2), {})).resolves.toBeUndefined();
319
+
320
+ const reread = await makeStore('fail.vec.json');
321
+ // Row 'a' mutated in memory before its flush failed; the SUCCESSFUL
322
+ // second flush persisted the newest state — both rows on disk.
323
+ expect((await reread.stats()).count).toBe(2);
324
+ });
325
+
326
+ test('explicit flush() rides the same chain', async () => {
327
+ const store = await makeStore('explicit.vec.json');
328
+ await Promise.all([
329
+ store.upsert('x', vec(1), {}),
330
+ store.flush(),
331
+ store.upsert('y', vec(2), {}),
332
+ ]);
333
+ const reread = await makeStore('explicit.vec.json');
334
+ expect((await reread.stats()).count).toBe(2);
335
+ });
336
+ });