@yeaft/webchat-agent 1.0.336 → 1.0.338

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,530 @@
1
+ import { Worker } from 'node:worker_threads';
2
+ import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import { writeAtomic } from '../storage/atomic.js';
5
+ import {
6
+ conversationIndexDatabasePath,
7
+ conversationIndexManifestPath,
8
+ flushConversationIndexMutations,
9
+ readConversationMutationInfo,
10
+ readConversationMutationRevision,
11
+ } from './history-index-state.js';
12
+
13
+ const MAX_REBUILD_RETRIES = 3;
14
+ const MAX_HISTORY_INDEX_MANAGERS = 8;
15
+ const HISTORY_INDEX_IDLE_MS = 5 * 60_000;
16
+ const DESTRUCTIVE_MUTATION_REASONS = new Set([
17
+ 'archive-session',
18
+ 'clear',
19
+ 'compact-orphans',
20
+ 'delete-session',
21
+ 'restore-session',
22
+ ]);
23
+ let requestCounter = 0;
24
+ const managers = new Map();
25
+ let managerAdmission = Promise.resolve();
26
+
27
+ async function withManagerAdmission(run) {
28
+ const previous = managerAdmission;
29
+ let release;
30
+ managerAdmission = new Promise(resolve => { release = resolve; });
31
+ await previous;
32
+ try {
33
+ return await run();
34
+ } finally {
35
+ release();
36
+ }
37
+ }
38
+
39
+ function managerKey(ownerRoot, sessionId) {
40
+ return `${ownerRoot}\u001f${sessionId}`;
41
+ }
42
+
43
+ function readManifest(ownerRoot, sessionId) {
44
+ const path = conversationIndexManifestPath(ownerRoot, sessionId);
45
+ if (!existsSync(path)) return null;
46
+ try {
47
+ const value = JSON.parse(readFileSync(path, 'utf8'));
48
+ if (Number(value?.indexSchemaVersion) !== 2) return null;
49
+ const generation = Number(value?.generation);
50
+ if (!Number.isInteger(generation) || generation < 1) return null;
51
+ const databasePath = conversationIndexDatabasePath(ownerRoot, sessionId, generation);
52
+ if (value.databasePath !== databasePath) return null;
53
+ return { ...value, generation, databasePath };
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ function writeManifest(ownerRoot, sessionId, manifest) {
60
+ const path = conversationIndexManifestPath(ownerRoot, sessionId);
61
+ mkdirSync(dirname(path), { recursive: true });
62
+ writeAtomic(path, `${JSON.stringify(manifest, null, 2)}\n`);
63
+ }
64
+
65
+ function spawnOneShot(mode, data) {
66
+ return new Promise((resolve, reject) => {
67
+ const worker = new Worker(new URL('./history-index-worker.js', import.meta.url), {
68
+ workerData: { mode, ...data },
69
+ });
70
+ let settled = false;
71
+ worker.on('message', message => {
72
+ if (message?.type === 'rebuilt' || message?.type === 'fingerprint') {
73
+ settled = true;
74
+ resolve(message.result);
75
+ } else if (message?.type === 'fatal') {
76
+ settled = true;
77
+ reject(new Error(message.error));
78
+ }
79
+ });
80
+ worker.on('error', error => {
81
+ if (!settled) reject(error);
82
+ });
83
+ worker.on('exit', code => {
84
+ if (!settled && code !== 0) reject(new Error(`history index rebuild worker exited ${code}`));
85
+ });
86
+ });
87
+ }
88
+
89
+ class HistoryIndexQueryWorker {
90
+ constructor({ ownerRoot, sessionId, databasePath, generation }) {
91
+ this.ownerRoot = ownerRoot;
92
+ this.sessionId = sessionId;
93
+ this.databasePath = databasePath;
94
+ this.generation = generation;
95
+ this.pending = new Map();
96
+ this.worker = new Worker(new URL('./history-index-worker.js', import.meta.url), {
97
+ workerData: {
98
+ mode: 'query',
99
+ ownerRoot,
100
+ sessionId,
101
+ databasePath,
102
+ generation,
103
+ testHooksEnabled: process.env.NODE_ENV === 'test',
104
+ },
105
+ });
106
+ this.worker.unref();
107
+ this.worker.on('message', message => {
108
+ if (message?.type === 'fatal') {
109
+ this.#rejectAll(new Error(message.error));
110
+ return;
111
+ }
112
+ const pending = this.pending.get(message?.requestId);
113
+ if (!pending) return;
114
+ this.pending.delete(message.requestId);
115
+ if (message.error) {
116
+ const error = new Error(message.error);
117
+ if (message.code) error.code = message.code;
118
+ pending.reject(error);
119
+ } else pending.resolve(message.result);
120
+ });
121
+ this.worker.on('error', error => this.#rejectAll(error));
122
+ this.worker.on('exit', code => {
123
+ if (code !== 0) this.#rejectAll(new Error(`history index query worker exited ${code}`));
124
+ });
125
+ }
126
+
127
+ #rejectAll(error) {
128
+ for (const pending of this.pending.values()) pending.reject(error);
129
+ this.pending.clear();
130
+ }
131
+
132
+ request(op, payload) {
133
+ const requestId = `history-index-${process.pid}-${++requestCounter}`;
134
+ return new Promise((resolve, reject) => {
135
+ this.pending.set(requestId, { resolve, reject });
136
+ this.worker.postMessage({ requestId, op, payload });
137
+ });
138
+ }
139
+
140
+ async close({ graceful = false } = {}) {
141
+ if (graceful) {
142
+ const deadline = Date.now() + 5_000;
143
+ while (this.pending.size > 0 && Date.now() < deadline) {
144
+ await new Promise(resolve => setTimeout(resolve, 5));
145
+ }
146
+ }
147
+ try { this.worker.postMessage({ op: 'close' }); } catch {}
148
+ await this.worker.terminate();
149
+ this.#rejectAll(new Error('history index worker closed'));
150
+ }
151
+ }
152
+
153
+ class SessionHistoryIndex {
154
+ constructor(ownerRoot, sessionId) {
155
+ this.ownerRoot = ownerRoot;
156
+ this.sessionId = sessionId;
157
+ this.active = null;
158
+ this.activeManifest = null;
159
+ this.rebuildPromise = null;
160
+ this.reconcilePromise = null;
161
+ this.rebuildTimer = null;
162
+ this.idleTimer = null;
163
+ this.lastUsedAt = Date.now();
164
+ this.leases = 0;
165
+ this.closed = false;
166
+ }
167
+
168
+ touch() {
169
+ if (this.closed) return;
170
+ this.lastUsedAt = Date.now();
171
+ if (this.idleTimer) clearTimeout(this.idleTimer);
172
+ this.idleTimer = setTimeout(() => {
173
+ if (this.leases > 0 || this.rebuildPromise || this.reconcilePromise) {
174
+ this.touch();
175
+ return;
176
+ }
177
+ retireConversationHistoryIndex(this.ownerRoot, this.sessionId).catch(error => {
178
+ console.warn('[history-index] idle retirement failed:', error?.message || error);
179
+ });
180
+ }, HISTORY_INDEX_IDLE_MS);
181
+ if (typeof this.idleTimer.unref === 'function') this.idleTimer.unref();
182
+ }
183
+
184
+ acquire() {
185
+ if (this.closed) return false;
186
+ this.leases += 1;
187
+ this.touch();
188
+ return true;
189
+ }
190
+
191
+ release() {
192
+ if (this.leases > 0) this.leases -= 1;
193
+ this.touch();
194
+ }
195
+
196
+ get evictable() {
197
+ return !this.closed && this.leases === 0 && !this.rebuildPromise && !this.reconcilePromise;
198
+ }
199
+
200
+ async #activate(manifest) {
201
+ if (this.closed || !manifest?.databasePath || !existsSync(manifest.databasePath)) return false;
202
+ if (this.active?.databasePath === manifest.databasePath) {
203
+ this.activeManifest = manifest;
204
+ return true;
205
+ }
206
+ const next = new HistoryIndexQueryWorker({
207
+ ownerRoot: this.ownerRoot,
208
+ sessionId: this.sessionId,
209
+ databasePath: manifest.databasePath,
210
+ generation: manifest.generation,
211
+ });
212
+ const previous = this.active;
213
+ this.active = next;
214
+ this.activeManifest = manifest;
215
+ if (previous) {
216
+ await previous.close({ graceful: true });
217
+ if (previous.databasePath !== manifest.databasePath) {
218
+ for (const suffix of ['', '-wal', '-shm']) rmSync(`${previous.databasePath}${suffix}`, { force: true });
219
+ }
220
+ }
221
+ return true;
222
+ }
223
+
224
+ async #needsRebuild() {
225
+ const manifest = readManifest(this.ownerRoot, this.sessionId);
226
+ if (!manifest?.databasePath || !existsSync(manifest.databasePath)) return { needs: true, manifest };
227
+ const revision = readConversationMutationRevision(this.ownerRoot, 'session', this.sessionId);
228
+ if (Number(manifest.sourceRevision) !== revision) return { needs: true, manifest };
229
+ if (this.active?.databasePath === manifest.databasePath) return { needs: false, manifest };
230
+ const source = await spawnOneShot('fingerprint', {
231
+ ownerRoot: this.ownerRoot,
232
+ sessionId: this.sessionId,
233
+ });
234
+ if (source.fingerprint !== manifest.sourceFingerprint
235
+ || source.rawFingerprint !== manifest.rawSourceFingerprint) return { needs: true, manifest };
236
+ await this.#activate(manifest);
237
+ return { needs: false, manifest };
238
+ }
239
+
240
+ async #reconcileActiveSource() {
241
+ if (!this.active || !this.activeManifest) return false;
242
+ if (this.reconcilePromise) return this.reconcilePromise;
243
+ this.reconcilePromise = (async () => {
244
+ const revision = readConversationMutationRevision(this.ownerRoot, 'session', this.sessionId);
245
+ if (Number(this.activeManifest.sourceRevision) !== revision) {
246
+ await this.rebuild();
247
+ return true;
248
+ }
249
+ const worker = this.active;
250
+ const manifest = this.activeManifest;
251
+ let source;
252
+ try {
253
+ source = await worker.request('source-token', {});
254
+ } catch (error) {
255
+ if (this.closed || worker !== this.active) return false;
256
+ throw error;
257
+ }
258
+ if (worker !== this.active || manifest !== this.activeManifest) return false;
259
+ if (source.fingerprint === manifest.rawSourceFingerprint) return false;
260
+ await this.rebuild();
261
+ return true;
262
+ })();
263
+ try {
264
+ return await this.reconcilePromise;
265
+ } finally {
266
+ this.reconcilePromise = null;
267
+ }
268
+ }
269
+
270
+ scheduleRebuild() {
271
+ if (this.closed || this.rebuildTimer || this.rebuildPromise || !this.active) return;
272
+ this.rebuildTimer = setTimeout(() => {
273
+ this.rebuildTimer = null;
274
+ this.rebuild().catch(error => {
275
+ console.warn('[history-index] background rebuild failed:', error?.message || error);
276
+ });
277
+ }, 50);
278
+ if (typeof this.rebuildTimer.unref === 'function') this.rebuildTimer.unref();
279
+ }
280
+
281
+ async ensureReady({ allowStale = true, waitForBuild = true } = {}) {
282
+ if (this.active) {
283
+ const revision = readConversationMutationRevision(this.ownerRoot, 'session', this.sessionId);
284
+ if (Number(this.activeManifest?.sourceRevision) !== revision) {
285
+ this.scheduleRebuild();
286
+ if (!allowStale) await this.rebuild();
287
+ }
288
+ return this.active;
289
+ }
290
+
291
+ const manifest = readManifest(this.ownerRoot, this.sessionId);
292
+ if (manifest?.databasePath && existsSync(manifest.databasePath)) {
293
+ if (allowStale) {
294
+ await this.#activate(manifest);
295
+ return this.active;
296
+ }
297
+ const state = await this.#needsRebuild();
298
+ if (state.needs) await this.rebuild();
299
+ return this.active;
300
+ }
301
+ if (!waitForBuild) {
302
+ this.rebuild().catch(error => {
303
+ console.warn('[history-index] initial background build failed:', error?.message || error);
304
+ });
305
+ const error = new Error('history index building');
306
+ error.code = 'index_building';
307
+ throw error;
308
+ }
309
+ await this.rebuild();
310
+ return this.active;
311
+ }
312
+
313
+ async rebuild() {
314
+ if (this.rebuildPromise) return this.rebuildPromise;
315
+ if (this.closed) throw new Error('history index manager closed');
316
+ if (this.rebuildTimer) clearTimeout(this.rebuildTimer);
317
+ this.rebuildTimer = null;
318
+ this.rebuildPromise = (async () => {
319
+ for (let attempt = 0; attempt < MAX_REBUILD_RETRIES; attempt += 1) {
320
+ const previous = readManifest(this.ownerRoot, this.sessionId);
321
+ const generation = Math.max(Number(previous?.generation) || 0, Number(this.activeManifest?.generation) || 0) + 1;
322
+ const sourceRevision = readConversationMutationRevision(this.ownerRoot, 'session', this.sessionId);
323
+ const databasePath = conversationIndexDatabasePath(this.ownerRoot, this.sessionId, generation);
324
+ let result;
325
+ try {
326
+ result = await spawnOneShot('rebuild', {
327
+ ownerRoot: this.ownerRoot,
328
+ sessionId: this.sessionId,
329
+ databasePath,
330
+ generation,
331
+ sourceRevision,
332
+ });
333
+ } catch (error) {
334
+ rmSync(databasePath, { force: true });
335
+ if (String(error?.message || '').includes('source changed during rebuild') && attempt + 1 < MAX_REBUILD_RETRIES) continue;
336
+ throw error;
337
+ }
338
+ const currentRevision = readConversationMutationRevision(this.ownerRoot, 'session', this.sessionId);
339
+ if (currentRevision !== sourceRevision) {
340
+ rmSync(databasePath, { force: true });
341
+ if (attempt + 1 < MAX_REBUILD_RETRIES) continue;
342
+ throw new Error('history source did not stabilize during rebuild');
343
+ }
344
+ const manifest = {
345
+ version: 1,
346
+ indexSchemaVersion: 2,
347
+ sessionId: this.sessionId,
348
+ generation,
349
+ databasePath,
350
+ sourceRevision,
351
+ sourceFingerprint: result.fingerprint,
352
+ rawSourceFingerprint: result.rawFingerprint,
353
+ sourceFiles: result.files,
354
+ sourceBytes: result.bytes,
355
+ entryCount: result.entryCount,
356
+ builtAt: new Date().toISOString(),
357
+ };
358
+ if (this.closed) {
359
+ for (const suffix of ['', '-wal', '-shm']) rmSync(`${databasePath}${suffix}`, { force: true });
360
+ return manifest;
361
+ }
362
+ writeManifest(this.ownerRoot, this.sessionId, manifest);
363
+ await this.#activate(manifest);
364
+ return manifest;
365
+ }
366
+ throw new Error('history index rebuild exhausted retries');
367
+ })();
368
+ try {
369
+ return await this.rebuildPromise;
370
+ } finally {
371
+ this.rebuildPromise = null;
372
+ const revision = readConversationMutationRevision(this.ownerRoot, 'session', this.sessionId);
373
+ if (this.active && Number(this.activeManifest?.sourceRevision) !== revision) this.scheduleRebuild();
374
+ }
375
+ }
376
+
377
+ async #strictRequest(op, payload, retries = 1) {
378
+ await this.#reconcileActiveSource();
379
+ const worker = this.active;
380
+ const manifest = this.activeManifest;
381
+ const revision = readConversationMutationRevision(this.ownerRoot, 'session', this.sessionId);
382
+ const result = await worker.request(op, payload);
383
+ const source = await worker.request('source-token', {});
384
+ const currentRevision = readConversationMutationRevision(this.ownerRoot, 'session', this.sessionId);
385
+ const stable = worker === this.active
386
+ && manifest?.generation === this.activeManifest?.generation
387
+ && revision === currentRevision
388
+ && source.fingerprint === manifest?.rawSourceFingerprint;
389
+ if (stable) return result;
390
+ if (retries > 0 && op !== 'validate-and-read-window') {
391
+ await this.rebuild();
392
+ return this.#strictRequest(op, payload, retries - 1);
393
+ }
394
+ const error = new Error('history source changed during request');
395
+ error.code = 'stale_result';
396
+ throw error;
397
+ }
398
+
399
+ async request(op, payload) {
400
+ this.leases += 1;
401
+ this.touch();
402
+ try {
403
+ const mutation = readConversationMutationInfo(this.ownerRoot, 'session', this.sessionId);
404
+ const allowStale = op === 'outline'
405
+ && !payload?.cursor
406
+ && !DESTRUCTIVE_MUTATION_REASONS.has(mutation.reason);
407
+ const waitForBuild = payload?._waitForBuild === true;
408
+ const worker = await this.ensureReady({ allowStale, waitForBuild });
409
+ try {
410
+ if (allowStale) {
411
+ const result = await worker.request(op, payload);
412
+ this.#reconcileActiveSource().catch(error => {
413
+ if (!this.closed) {
414
+ console.warn('[history-index] background source reconcile failed:', error?.message || error);
415
+ }
416
+ });
417
+ return result;
418
+ }
419
+ return await this.#strictRequest(op, payload);
420
+ } catch (error) {
421
+ if (error?.code === 'stale_result' || this.closed) throw error;
422
+ await this.rebuild();
423
+ return allowStale
424
+ ? this.active.request(op, payload)
425
+ : this.#strictRequest(op, payload, 0);
426
+ }
427
+ } finally {
428
+ this.leases -= 1;
429
+ this.touch();
430
+ }
431
+ }
432
+
433
+ async close() {
434
+ this.closed = true;
435
+ if (this.idleTimer) clearTimeout(this.idleTimer);
436
+ this.idleTimer = null;
437
+ if (this.rebuildTimer) clearTimeout(this.rebuildTimer);
438
+ this.rebuildTimer = null;
439
+ await Promise.allSettled([this.rebuildPromise].filter(Boolean));
440
+ if (this.active) await this.active.close();
441
+ this.active = null;
442
+ this.activeManifest = null;
443
+ this.reconcilePromise = null;
444
+ }
445
+ }
446
+
447
+ async function acquireHistoryIndexManager(ownerRoot, sessionId) {
448
+ const key = managerKey(ownerRoot, sessionId);
449
+ for (;;) {
450
+ const manager = await withManagerAdmission(async () => {
451
+ const existing = managers.get(key);
452
+ if (existing?.acquire()) return existing;
453
+ if (managers.size >= MAX_HISTORY_INDEX_MANAGERS) {
454
+ const candidate = Array.from(managers.entries())
455
+ .filter(([, current]) => current.evictable)
456
+ .sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt)[0];
457
+ if (!candidate) return null;
458
+ managers.delete(candidate[0]);
459
+ await candidate[1].close();
460
+ }
461
+ const created = new SessionHistoryIndex(ownerRoot, sessionId);
462
+ managers.set(key, created);
463
+ created.acquire();
464
+ return created;
465
+ });
466
+ if (manager) return manager;
467
+ await new Promise(resolve => setTimeout(resolve, 5));
468
+ }
469
+ }
470
+
471
+ async function requestConversationHistoryIndex(ownerRoot, sessionId, op, payload) {
472
+ const manager = await acquireHistoryIndexManager(ownerRoot, sessionId);
473
+ try {
474
+ return await manager.request(op, payload);
475
+ } finally {
476
+ manager.release();
477
+ }
478
+ }
479
+
480
+ export async function retireConversationHistoryIndex(ownerRoot, sessionId) {
481
+ const key = managerKey(ownerRoot, sessionId);
482
+ const manager = managers.get(key);
483
+ if (!manager) return false;
484
+ managers.delete(key);
485
+ await manager.close();
486
+ return true;
487
+ }
488
+
489
+ export async function searchConversationIndex(ownerRoot, sessionId, query, opts = {}) {
490
+ return requestConversationHistoryIndex(ownerRoot, sessionId, 'search', {
491
+ query,
492
+ ...opts,
493
+ _waitForBuild: opts._waitForBuild !== false,
494
+ });
495
+ }
496
+
497
+ export async function loadConversationOutlineFromIndex(ownerRoot, sessionId, opts = {}) {
498
+ return requestConversationHistoryIndex(ownerRoot, sessionId, 'outline', {
499
+ ...opts,
500
+ _waitForBuild: opts._waitForBuild !== false,
501
+ });
502
+ }
503
+
504
+ export async function validateConversationIndexAnchor(ownerRoot, sessionId, anchor) {
505
+ return requestConversationHistoryIndex(ownerRoot, sessionId, 'validate-anchor', {
506
+ ...anchor,
507
+ _waitForBuild: anchor?._waitForBuild !== false,
508
+ });
509
+ }
510
+
511
+ export async function readConversationIndexWindow(ownerRoot, sessionId, anchor) {
512
+ return requestConversationHistoryIndex(ownerRoot, sessionId, 'validate-and-read-window', {
513
+ ...anchor,
514
+ _waitForBuild: anchor?._waitForBuild !== false,
515
+ });
516
+ }
517
+
518
+ export async function closeConversationHistoryIndexes({ releaseMutationState = true } = {}) {
519
+ const closing = Array.from(managers.values(), manager => manager.close());
520
+ managers.clear();
521
+ await Promise.allSettled(closing);
522
+ flushConversationIndexMutations(null, { release: releaseMutationState });
523
+ }
524
+
525
+ export const __historyIndexForTest = {
526
+ readManifest,
527
+ writeManifest,
528
+ managers,
529
+ maxManagers: MAX_HISTORY_INDEX_MANAGERS,
530
+ };