sidekick-shared 0.26.4 → 0.26.5
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/CHANGELOG.md +17 -0
- package/README.md +5 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +12 -9
- package/dist/node.d.ts +4 -1
- package/dist/node.js +4 -1
- package/dist/observedSessionCollector.d.ts +117 -5
- package/dist/observedSessionCollector.js +483 -76
- package/dist/providers/claudeCode.d.ts +23 -2
- package/dist/providers/claudeCode.js +143 -51
- package/dist/providers/codex.d.ts +10 -2
- package/dist/providers/codex.js +95 -12
- package/dist/providers/directoryListingCache.d.ts +85 -0
- package/dist/providers/directoryListingCache.js +244 -0
- package/dist/providers/rolloutWalker.d.ts +28 -5
- package/dist/providers/rolloutWalker.js +100 -16
- package/dist/providers/types.d.ts +48 -1
- package/dist/sessionMonitor.js +24 -2
- package/package.json +1 -1
|
@@ -46,6 +46,12 @@ const DEFAULT_MAX_CONCURRENT_READS = 4;
|
|
|
46
46
|
const MAX_CONCURRENT_READS = 16;
|
|
47
47
|
const DEFAULT_DEBOUNCE_MS = 100;
|
|
48
48
|
const DEFAULT_POLL_INTERVAL_MS = 30_000;
|
|
49
|
+
const DEFAULT_MIN_RECONCILE_GAP_MS = 2_000;
|
|
50
|
+
const DEFAULT_MAX_CACHE_ENTRIES = 20_000;
|
|
51
|
+
const DEFAULT_MAX_CACHE_BYTES = 128 * 1024 * 1024;
|
|
52
|
+
const FALLBACK_VALUE_BYTES = 1_024;
|
|
53
|
+
/** A full discovery may recur once it has been idle this many times its own duration. */
|
|
54
|
+
const GAP_COST_MULTIPLIER = 4;
|
|
49
55
|
/**
|
|
50
56
|
* Fail-soft observed-session collection with fingerprint-keyed parse caching,
|
|
51
57
|
* cooperative reads, and optional provider-level subscriptions.
|
|
@@ -53,12 +59,22 @@ const DEFAULT_POLL_INTERVAL_MS = 30_000;
|
|
|
53
59
|
class ObservedSessionCollector {
|
|
54
60
|
options;
|
|
55
61
|
failures = new Map();
|
|
56
|
-
cache
|
|
62
|
+
cache;
|
|
57
63
|
subscriptions = new Set();
|
|
64
|
+
/** Latest complete reference set per provider, kept current by scoped reconciles. */
|
|
65
|
+
lastDiscovery = new Map();
|
|
66
|
+
/** One full discovery per provider at a time; concurrent callers share it. */
|
|
67
|
+
inflightDiscovery = new Map();
|
|
68
|
+
fullPassState = new Map();
|
|
69
|
+
/** Scoped results newer than an in-flight walk, so the walk cannot undo them. */
|
|
70
|
+
scopedReferences = new Map();
|
|
71
|
+
generation = 0;
|
|
72
|
+
passId = 0;
|
|
58
73
|
initialBackoffMs;
|
|
59
74
|
maxBackoffMs;
|
|
60
75
|
clock;
|
|
61
76
|
maxConcurrentReads;
|
|
77
|
+
minReconcileGapMs;
|
|
62
78
|
yieldBetweenReads;
|
|
63
79
|
constructor(options) {
|
|
64
80
|
this.options = options;
|
|
@@ -66,6 +82,17 @@ class ObservedSessionCollector {
|
|
|
66
82
|
this.maxBackoffMs = Math.max(this.initialBackoffMs, options.maxBackoffMs ?? 5 * 60_000);
|
|
67
83
|
this.clock = options.clock ?? { now: () => Date.now() };
|
|
68
84
|
this.maxConcurrentReads = Math.min(MAX_CONCURRENT_READS, Math.max(1, Math.floor(options.maxConcurrentReads ?? DEFAULT_MAX_CONCURRENT_READS)));
|
|
85
|
+
this.minReconcileGapMs = Math.max(0, options.minReconcileGapMs ?? DEFAULT_MIN_RECONCILE_GAP_MS);
|
|
86
|
+
const estimate = options.approximateValueBytes ?? defaultValueBytes;
|
|
87
|
+
this.cache = new BoundedCache(Math.max(1, Math.floor(options.maxCacheEntries ?? DEFAULT_MAX_CACHE_ENTRIES)), Math.max(1, Math.floor(options.maxCacheBytes ?? DEFAULT_MAX_CACHE_BYTES)), (value) => {
|
|
88
|
+
try {
|
|
89
|
+
const bytes = estimate(value);
|
|
90
|
+
return Number.isFinite(bytes) && bytes >= 0 ? bytes : FALLBACK_VALUE_BYTES;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return FALLBACK_VALUE_BYTES;
|
|
94
|
+
}
|
|
95
|
+
});
|
|
69
96
|
this.yieldBetweenReads = async () => {
|
|
70
97
|
if (options.yieldBetweenReads) {
|
|
71
98
|
await options.yieldBetweenReads();
|
|
@@ -74,17 +101,28 @@ class ObservedSessionCollector {
|
|
|
74
101
|
await new Promise((resolve) => setImmediate(resolve));
|
|
75
102
|
};
|
|
76
103
|
}
|
|
77
|
-
async collect() {
|
|
78
|
-
|
|
104
|
+
async collect(options = {}) {
|
|
105
|
+
this.passId++;
|
|
106
|
+
const nested = await Promise.all(this.options.sources.map((source) => this.collectSource(source, options)));
|
|
79
107
|
return nested.flat();
|
|
80
108
|
}
|
|
109
|
+
/** Number of parsed sessions currently cached. */
|
|
110
|
+
get cacheSize() {
|
|
111
|
+
return this.cache.size;
|
|
112
|
+
}
|
|
81
113
|
/**
|
|
82
114
|
* Subscribe to debounced provider changes. The listener receives fingerprints
|
|
83
115
|
* only; call collect() to consume changed sessions through the parse cache.
|
|
116
|
+
*
|
|
117
|
+
* A signal that names a file is reconciled with one stat. Signals without a
|
|
118
|
+
* filename, catch-up polls, and the initial pass run a full discovery, at
|
|
119
|
+
* most once per gap per provider; bursts inside the debounce window join a
|
|
120
|
+
* single trailing pass.
|
|
84
121
|
*/
|
|
85
122
|
subscribe(listener, options = {}) {
|
|
86
123
|
const debounceMs = Math.max(0, options.debounceMs ?? DEFAULT_DEBOUNCE_MS);
|
|
87
124
|
const pollIntervalMs = Math.max(0, options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS);
|
|
125
|
+
const gapCeilingMs = Math.max(0, options.minReconcileGapMs ?? this.minReconcileGapMs);
|
|
88
126
|
const known = new Map();
|
|
89
127
|
for (const item of options.knownFingerprints ?? []) {
|
|
90
128
|
known.set(this.key(item.providerId, item.sessionId), {
|
|
@@ -96,60 +134,137 @@ class ObservedSessionCollector {
|
|
|
96
134
|
known.set(key, { fingerprint: entry.fingerprint, parts: entry.fingerprintParts });
|
|
97
135
|
}
|
|
98
136
|
let disposed = false;
|
|
99
|
-
let
|
|
100
|
-
let
|
|
101
|
-
let
|
|
137
|
+
let timer = null;
|
|
138
|
+
let timerFireAt = 0;
|
|
139
|
+
let running = false;
|
|
140
|
+
let pendingScoped = new Map();
|
|
141
|
+
let pendingFull = new Map();
|
|
102
142
|
const sourceDisposables = [];
|
|
103
|
-
const
|
|
143
|
+
const gapRemainingMs = (source, trigger) => {
|
|
144
|
+
if (trigger === 'initial')
|
|
145
|
+
return 0;
|
|
146
|
+
const state = this.fullPassState.get(source.providerId);
|
|
147
|
+
if (!state)
|
|
148
|
+
return 0;
|
|
149
|
+
return Math.max(0, state.endedAt + this.effectiveGapMs(source.providerId, gapCeilingMs) - Date.now());
|
|
150
|
+
};
|
|
151
|
+
const emit = (batch) => {
|
|
152
|
+
if (batch.changes.length === 0)
|
|
153
|
+
return;
|
|
154
|
+
try {
|
|
155
|
+
listener(batch);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// Subscriber failures cannot stop reconciliation.
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
// Arms the trailing timer; a later request can only pull it earlier,
|
|
162
|
+
// never push it out, so a busy live session cannot starve the pass.
|
|
163
|
+
const arm = (delayMs) => {
|
|
104
164
|
if (disposed)
|
|
105
165
|
return;
|
|
106
|
-
|
|
107
|
-
|
|
166
|
+
const fireAt = Date.now() + delayMs;
|
|
167
|
+
if (timer && timerFireAt <= fireAt)
|
|
168
|
+
return;
|
|
169
|
+
if (timer)
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
timerFireAt = fireAt;
|
|
172
|
+
timer = setTimeout(() => {
|
|
173
|
+
timer = null;
|
|
174
|
+
void run();
|
|
175
|
+
}, delayMs);
|
|
176
|
+
timer.unref?.();
|
|
177
|
+
};
|
|
178
|
+
const requestFull = (source, trigger) => {
|
|
179
|
+
const existing = pendingFull.get(source);
|
|
180
|
+
if (!existing || existing === 'event')
|
|
181
|
+
pendingFull.set(source, trigger);
|
|
182
|
+
};
|
|
183
|
+
const onSignal = (source, signal) => {
|
|
184
|
+
if (disposed)
|
|
185
|
+
return;
|
|
186
|
+
const parsed = typeof signal === 'object' && signal !== null ? signal : undefined;
|
|
187
|
+
if (parsed &&
|
|
188
|
+
typeof parsed.filename === 'string' &&
|
|
189
|
+
parsed.filename.length > 0 &&
|
|
190
|
+
source.resolveReference) {
|
|
191
|
+
pendingScoped.set(`${source.providerId}\0${parsed.root ?? ''}\0${parsed.filename}`, {
|
|
192
|
+
source,
|
|
193
|
+
signal: parsed,
|
|
194
|
+
});
|
|
195
|
+
arm(debounceMs);
|
|
108
196
|
return;
|
|
109
197
|
}
|
|
110
|
-
|
|
198
|
+
const trigger = parsed?.trigger === 'poll' ? 'poll' : 'event';
|
|
199
|
+
requestFull(source, trigger);
|
|
200
|
+
arm(Math.max(debounceMs, gapRemainingMs(source, trigger)));
|
|
201
|
+
};
|
|
202
|
+
const run = async () => {
|
|
203
|
+
if (disposed || running)
|
|
204
|
+
return;
|
|
205
|
+
running = true;
|
|
206
|
+
// Swap before the first await so signals landing mid-run are kept.
|
|
207
|
+
const scoped = pendingScoped;
|
|
208
|
+
pendingScoped = new Map();
|
|
209
|
+
const full = pendingFull;
|
|
210
|
+
pendingFull = new Map();
|
|
111
211
|
try {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
212
|
+
const scopedChanges = new Map();
|
|
213
|
+
for (const { source, signal } of scoped.values()) {
|
|
214
|
+
if (disposed)
|
|
215
|
+
return;
|
|
216
|
+
const result = await this.reconcileSignal(source, signal, known, gapCeilingMs);
|
|
217
|
+
if (disposed)
|
|
218
|
+
return;
|
|
219
|
+
if (result === 'unknown') {
|
|
220
|
+
requestFull(source, 'event');
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (!result)
|
|
224
|
+
continue;
|
|
225
|
+
const list = scopedChanges.get(source) ?? [];
|
|
226
|
+
list.push(result);
|
|
227
|
+
scopedChanges.set(source, list);
|
|
228
|
+
}
|
|
229
|
+
for (const [source, changes] of scopedChanges) {
|
|
230
|
+
emit({
|
|
231
|
+
providerId: source.providerId,
|
|
232
|
+
changes,
|
|
233
|
+
observedAt: new Date(this.clock.now()).toISOString(),
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
for (const [source, trigger] of full) {
|
|
237
|
+
if (disposed)
|
|
238
|
+
return;
|
|
239
|
+
if (gapRemainingMs(source, trigger) > 0) {
|
|
240
|
+
requestFull(source, trigger);
|
|
241
|
+
continue;
|
|
130
242
|
}
|
|
131
|
-
|
|
243
|
+
const batch = await this.reconcileSource(source, known, trigger, gapCeilingMs);
|
|
244
|
+
// Re-check after the await: disposal during the reconcile must
|
|
245
|
+
// not deliver the pre-disposal batch to the listener.
|
|
246
|
+
if (disposed)
|
|
247
|
+
return;
|
|
248
|
+
if (batch)
|
|
249
|
+
emit(batch);
|
|
250
|
+
}
|
|
132
251
|
}
|
|
133
252
|
finally {
|
|
134
|
-
|
|
253
|
+
running = false;
|
|
254
|
+
if (!disposed && (pendingScoped.size > 0 || pendingFull.size > 0)) {
|
|
255
|
+
let delayMs = pendingScoped.size > 0 ? debounceMs : Infinity;
|
|
256
|
+
for (const [source, trigger] of pendingFull) {
|
|
257
|
+
delayMs = Math.min(delayMs, Math.max(debounceMs, gapRemainingMs(source, trigger)));
|
|
258
|
+
}
|
|
259
|
+
arm(delayMs);
|
|
260
|
+
}
|
|
135
261
|
}
|
|
136
262
|
};
|
|
137
|
-
const schedule = () => {
|
|
138
|
-
if (disposed)
|
|
139
|
-
return;
|
|
140
|
-
if (debounceTimer)
|
|
141
|
-
clearTimeout(debounceTimer);
|
|
142
|
-
debounceTimer = setTimeout(() => {
|
|
143
|
-
debounceTimer = null;
|
|
144
|
-
void reconcile();
|
|
145
|
-
}, debounceMs);
|
|
146
|
-
debounceTimer.unref?.();
|
|
147
|
-
};
|
|
148
263
|
let needsCollectorPoll = false;
|
|
149
264
|
for (const source of this.options.sources) {
|
|
150
265
|
if (source.subscribe) {
|
|
151
266
|
try {
|
|
152
|
-
sourceDisposables.push(source.subscribe(
|
|
267
|
+
sourceDisposables.push(source.subscribe((signal) => onSignal(source, signal), { pollIntervalMs }));
|
|
153
268
|
}
|
|
154
269
|
catch {
|
|
155
270
|
// The collector-level poll below remains the correctness fallback.
|
|
@@ -162,17 +277,22 @@ class ObservedSessionCollector {
|
|
|
162
277
|
}
|
|
163
278
|
let pollTimer = null;
|
|
164
279
|
if (needsCollectorPoll && pollIntervalMs > 0) {
|
|
165
|
-
pollTimer = setInterval(
|
|
280
|
+
pollTimer = setInterval(() => {
|
|
281
|
+
for (const source of this.options.sources)
|
|
282
|
+
onSignal(source, { trigger: 'poll' });
|
|
283
|
+
}, pollIntervalMs);
|
|
166
284
|
pollTimer.unref?.();
|
|
167
285
|
}
|
|
168
|
-
|
|
286
|
+
for (const source of this.options.sources)
|
|
287
|
+
requestFull(source, 'initial');
|
|
288
|
+
arm(debounceMs);
|
|
169
289
|
const disposable = {
|
|
170
290
|
dispose: () => {
|
|
171
291
|
if (disposed)
|
|
172
292
|
return;
|
|
173
293
|
disposed = true;
|
|
174
|
-
if (
|
|
175
|
-
clearTimeout(
|
|
294
|
+
if (timer)
|
|
295
|
+
clearTimeout(timer);
|
|
176
296
|
if (pollTimer)
|
|
177
297
|
clearInterval(pollTimer);
|
|
178
298
|
for (const item of sourceDisposables) {
|
|
@@ -192,6 +312,8 @@ class ObservedSessionCollector {
|
|
|
192
312
|
reset() {
|
|
193
313
|
this.failures.clear();
|
|
194
314
|
this.cache.clear();
|
|
315
|
+
this.lastDiscovery.clear();
|
|
316
|
+
this.scopedReferences.clear();
|
|
195
317
|
}
|
|
196
318
|
dispose() {
|
|
197
319
|
for (const subscription of [...this.subscriptions])
|
|
@@ -206,22 +328,35 @@ class ObservedSessionCollector {
|
|
|
206
328
|
}
|
|
207
329
|
this.reset();
|
|
208
330
|
}
|
|
209
|
-
async collectSource(source) {
|
|
331
|
+
async collectSource(source, options) {
|
|
210
332
|
const discoveryKey = `${source.providerId}\0$discovery`;
|
|
211
333
|
const discoveryFailure = this.failures.get(discoveryKey);
|
|
212
334
|
if (discoveryFailure && this.clock.now() < discoveryFailure.retryAt)
|
|
213
335
|
return [];
|
|
336
|
+
const partial = options.limit !== undefined || options.since !== undefined;
|
|
214
337
|
let references;
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
338
|
+
// A reconcile pass within the gap already produced the references; a
|
|
339
|
+
// collect() issued from the listener must not walk the provider again.
|
|
340
|
+
const snapshot = !partial && this.subscriptions.size > 0
|
|
341
|
+
? this.lastDiscovery.get(source.providerId)
|
|
342
|
+
: undefined;
|
|
343
|
+
if (snapshot && Date.now() < snapshot.reuseUntil) {
|
|
344
|
+
references = [...snapshot.references.values()];
|
|
218
345
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
346
|
+
else {
|
|
347
|
+
try {
|
|
348
|
+
references = await this.runDiscovery(source, 'collect', options);
|
|
349
|
+
this.recover(discoveryKey, 'provider-recovered', source.providerId);
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
this.fail(discoveryKey, 'provider-discovery-failed', source.providerId, undefined, null, error);
|
|
353
|
+
return [];
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (!partial) {
|
|
357
|
+
const activeKeys = new Set(references.map((ref) => this.key(source.providerId, ref.sessionId)));
|
|
358
|
+
this.evictMissing(source.providerId, activeKeys);
|
|
222
359
|
}
|
|
223
|
-
const activeKeys = new Set(references.map((ref) => this.key(source.providerId, ref.sessionId)));
|
|
224
|
-
this.evictMissing(source.providerId, activeKeys);
|
|
225
360
|
const rows = await this.mapWithConcurrency(references, async (reference) => {
|
|
226
361
|
const result = await this.collectSession(source, reference);
|
|
227
362
|
await this.yieldBetweenReads();
|
|
@@ -239,7 +374,7 @@ class ObservedSessionCollector {
|
|
|
239
374
|
return null;
|
|
240
375
|
}
|
|
241
376
|
const observedAt = new Date(this.clock.now()).toISOString();
|
|
242
|
-
const cached = fingerprint.value === null ? undefined : this.cache.get(key);
|
|
377
|
+
const cached = fingerprint.value === null ? undefined : this.cache.get(key, this.passId);
|
|
243
378
|
if (cached && cached.fingerprint === fingerprint.value) {
|
|
244
379
|
let value = cached.value;
|
|
245
380
|
try {
|
|
@@ -272,7 +407,7 @@ class ObservedSessionCollector {
|
|
|
272
407
|
fingerprint: fingerprint.value,
|
|
273
408
|
fingerprintParts: fingerprint.parts,
|
|
274
409
|
contentObservedAt,
|
|
275
|
-
});
|
|
410
|
+
}, this.passId);
|
|
276
411
|
}
|
|
277
412
|
const observation = this.makeObservation(source, reference, value, fingerprint, false, observedAt, contentObservedAt);
|
|
278
413
|
this.emitObservation(observation);
|
|
@@ -298,22 +433,169 @@ class ObservedSessionCollector {
|
|
|
298
433
|
// Host callbacks cannot turn a healthy provider read into a collection failure.
|
|
299
434
|
}
|
|
300
435
|
}
|
|
301
|
-
|
|
436
|
+
/**
|
|
437
|
+
* Runs `discover()` once per provider at a time. Unbounded discoveries are
|
|
438
|
+
* shared with concurrent callers and update the gap bookkeeping; every
|
|
439
|
+
* discovery emits one payload-free cost diagnostic.
|
|
440
|
+
*/
|
|
441
|
+
runDiscovery(source, trigger, options = {}) {
|
|
442
|
+
return this.startDiscovery(source, trigger, options).promise;
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Starts (or joins) a discovery and reports the generation it started at,
|
|
446
|
+
* so a reconcile that joins a walk begun earlier by `collect()` compares
|
|
447
|
+
* scoped results against the walk's start rather than its own entry.
|
|
448
|
+
*/
|
|
449
|
+
startDiscovery(source, trigger, options = {}) {
|
|
450
|
+
const providerId = source.providerId;
|
|
451
|
+
const partial = options.limit !== undefined || options.since !== undefined;
|
|
452
|
+
if (!partial) {
|
|
453
|
+
const inflight = this.inflightDiscovery.get(providerId);
|
|
454
|
+
if (inflight)
|
|
455
|
+
return inflight;
|
|
456
|
+
}
|
|
457
|
+
const generation = this.generation;
|
|
458
|
+
const stats = {};
|
|
459
|
+
const startedAt = Date.now();
|
|
460
|
+
const promise = (async () => {
|
|
461
|
+
const references = await source.discover({
|
|
462
|
+
limit: options.limit,
|
|
463
|
+
since: options.since,
|
|
464
|
+
stats,
|
|
465
|
+
});
|
|
466
|
+
const endedAt = Date.now();
|
|
467
|
+
const durationMs = endedAt - startedAt;
|
|
468
|
+
if (!partial)
|
|
469
|
+
this.fullPassState.set(providerId, { endedAt, durationMs });
|
|
470
|
+
this.emitDiagnostic(compact({
|
|
471
|
+
kind: 'provider-discovery-completed',
|
|
472
|
+
severity: 'info',
|
|
473
|
+
phase: 'discover',
|
|
474
|
+
providerId,
|
|
475
|
+
trigger,
|
|
476
|
+
durationMs,
|
|
477
|
+
referenceCount: references.length,
|
|
478
|
+
directoriesListed: stats.directoriesListed,
|
|
479
|
+
directoriesStatted: stats.directoriesStatted,
|
|
480
|
+
filesStatted: stats.filesStatted,
|
|
481
|
+
partial: partial ? true : undefined,
|
|
482
|
+
}));
|
|
483
|
+
return references;
|
|
484
|
+
})();
|
|
485
|
+
const handle = { promise, generation };
|
|
486
|
+
if (!partial) {
|
|
487
|
+
this.inflightDiscovery.set(providerId, handle);
|
|
488
|
+
const settle = () => {
|
|
489
|
+
if (this.inflightDiscovery.get(providerId) === handle) {
|
|
490
|
+
this.inflightDiscovery.delete(providerId);
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
promise.then(settle, settle);
|
|
494
|
+
}
|
|
495
|
+
return handle;
|
|
496
|
+
}
|
|
497
|
+
effectiveGapMs(providerId, ceilingMs) {
|
|
498
|
+
const state = this.fullPassState.get(providerId);
|
|
499
|
+
if (!state)
|
|
500
|
+
return 0;
|
|
501
|
+
return Math.min(ceilingMs, state.durationMs * GAP_COST_MULTIPLIER);
|
|
502
|
+
}
|
|
503
|
+
/** Reconcile one watch signal against `known` with at most one stat. */
|
|
504
|
+
async reconcileSignal(source, signal, known, gapCeilingMs) {
|
|
505
|
+
if (!source.resolveReference)
|
|
506
|
+
return 'unknown';
|
|
507
|
+
let resolution;
|
|
508
|
+
try {
|
|
509
|
+
resolution = await source.resolveReference(signal);
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
return 'unknown';
|
|
513
|
+
}
|
|
514
|
+
if (!resolution || typeof resolution !== 'object')
|
|
515
|
+
return 'unknown';
|
|
516
|
+
const providerId = source.providerId;
|
|
517
|
+
switch (resolution.status) {
|
|
518
|
+
case 'ignored':
|
|
519
|
+
return null;
|
|
520
|
+
case 'missing': {
|
|
521
|
+
const key = this.key(providerId, resolution.sessionId);
|
|
522
|
+
this.scopedReferences.set(key, { reference: null, generation: ++this.generation });
|
|
523
|
+
this.lastDiscovery.get(providerId)?.references.delete(resolution.sessionId);
|
|
524
|
+
const previous = known.get(key);
|
|
525
|
+
if (!previous)
|
|
526
|
+
return null;
|
|
527
|
+
known.delete(key);
|
|
528
|
+
return {
|
|
529
|
+
type: 'removed',
|
|
530
|
+
reference: { sessionId: resolution.sessionId },
|
|
531
|
+
previousFingerprint: previous.fingerprint,
|
|
532
|
+
previousFingerprintParts: previous.parts,
|
|
533
|
+
fingerprint: null,
|
|
534
|
+
fingerprintParts: null,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
case 'present': {
|
|
538
|
+
const reference = resolution.reference;
|
|
539
|
+
const key = this.key(providerId, reference.sessionId);
|
|
540
|
+
const current = await this.fingerprint(source, reference);
|
|
541
|
+
if (current.value === null)
|
|
542
|
+
return null;
|
|
543
|
+
this.scopedReferences.set(key, { reference, generation: ++this.generation });
|
|
544
|
+
const snapshot = this.lastDiscovery.get(providerId);
|
|
545
|
+
if (snapshot) {
|
|
546
|
+
snapshot.references.set(reference.sessionId, reference);
|
|
547
|
+
snapshot.reuseUntil = Date.now() + this.effectiveGapMs(providerId, gapCeilingMs);
|
|
548
|
+
}
|
|
549
|
+
const previous = known.get(key);
|
|
550
|
+
known.set(key, { fingerprint: current.value, parts: current.parts });
|
|
551
|
+
if (previous && previous.fingerprint === current.value)
|
|
552
|
+
return null;
|
|
553
|
+
return {
|
|
554
|
+
type: previous ? 'changed' : 'added',
|
|
555
|
+
reference,
|
|
556
|
+
previousFingerprint: previous?.fingerprint ?? null,
|
|
557
|
+
previousFingerprintParts: previous?.parts ?? null,
|
|
558
|
+
fingerprint: current.value,
|
|
559
|
+
fingerprintParts: current.parts,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
default:
|
|
563
|
+
return 'unknown';
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
async reconcileSource(source, known, trigger, gapCeilingMs) {
|
|
567
|
+
const providerId = source.providerId;
|
|
568
|
+
const prefix = `${providerId}\0`;
|
|
569
|
+
// The walk may have been started earlier by a concurrent collect(); its
|
|
570
|
+
// start generation, not this pass's entry, decides which scoped results
|
|
571
|
+
// are newer than the references it returns.
|
|
572
|
+
let startGeneration;
|
|
302
573
|
let references;
|
|
303
574
|
try {
|
|
304
|
-
|
|
575
|
+
const discovery = this.startDiscovery(source, trigger);
|
|
576
|
+
startGeneration = discovery.generation;
|
|
577
|
+
references = await discovery.promise;
|
|
305
578
|
}
|
|
306
579
|
catch {
|
|
307
580
|
return null;
|
|
308
581
|
}
|
|
309
582
|
const changes = [];
|
|
310
583
|
const seen = new Set();
|
|
311
|
-
|
|
312
|
-
|
|
584
|
+
const snapshotReferences = new Map();
|
|
585
|
+
for (const discovered of references) {
|
|
586
|
+
const key = this.key(providerId, discovered.sessionId);
|
|
313
587
|
seen.add(key);
|
|
588
|
+
// A scoped result that landed while the walk was running is newer than
|
|
589
|
+
// the walk's copy; use it so the walk cannot flip the fingerprint back.
|
|
590
|
+
const scoped = this.scopedReferences.get(key);
|
|
591
|
+
const newer = scoped !== undefined && scoped.generation > startGeneration;
|
|
592
|
+
if (newer && scoped.reference === null)
|
|
593
|
+
continue;
|
|
594
|
+
const reference = newer && scoped.reference ? scoped.reference : discovered;
|
|
314
595
|
const current = await this.fingerprint(source, reference);
|
|
315
596
|
if (current.value === null)
|
|
316
597
|
continue;
|
|
598
|
+
snapshotReferences.set(reference.sessionId, reference);
|
|
317
599
|
const previous = known.get(key);
|
|
318
600
|
if (!previous || previous.fingerprint !== current.value) {
|
|
319
601
|
changes.push({
|
|
@@ -328,9 +610,15 @@ class ObservedSessionCollector {
|
|
|
328
610
|
known.set(key, { fingerprint: current.value, parts: current.parts });
|
|
329
611
|
}
|
|
330
612
|
for (const [key, previous] of [...known]) {
|
|
331
|
-
if (!key.startsWith(
|
|
613
|
+
if (!key.startsWith(prefix) || seen.has(key))
|
|
614
|
+
continue;
|
|
615
|
+
const scoped = this.scopedReferences.get(key);
|
|
616
|
+
if (scoped && scoped.generation > startGeneration && scoped.reference) {
|
|
617
|
+
// Created after the walk began; the scoped pass already reported it.
|
|
618
|
+
snapshotReferences.set(scoped.reference.sessionId, scoped.reference);
|
|
332
619
|
continue;
|
|
333
|
-
|
|
620
|
+
}
|
|
621
|
+
const sessionId = key.slice(prefix.length);
|
|
334
622
|
changes.push({
|
|
335
623
|
type: 'removed',
|
|
336
624
|
reference: { sessionId },
|
|
@@ -341,8 +629,17 @@ class ObservedSessionCollector {
|
|
|
341
629
|
});
|
|
342
630
|
known.delete(key);
|
|
343
631
|
}
|
|
632
|
+
for (const [key, entry] of this.scopedReferences) {
|
|
633
|
+
if (key.startsWith(prefix) && entry.generation <= startGeneration) {
|
|
634
|
+
this.scopedReferences.delete(key);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
this.lastDiscovery.set(providerId, {
|
|
638
|
+
references: snapshotReferences,
|
|
639
|
+
reuseUntil: Date.now() + this.effectiveGapMs(providerId, gapCeilingMs),
|
|
640
|
+
});
|
|
344
641
|
return {
|
|
345
|
-
providerId
|
|
642
|
+
providerId,
|
|
346
643
|
changes,
|
|
347
644
|
observedAt: new Date(this.clock.now()).toISOString(),
|
|
348
645
|
};
|
|
@@ -367,7 +664,7 @@ class ObservedSessionCollector {
|
|
|
367
664
|
this.failures.delete(key);
|
|
368
665
|
}
|
|
369
666
|
}
|
|
370
|
-
for (const key of this.cache.keys()) {
|
|
667
|
+
for (const key of [...this.cache.keys()]) {
|
|
371
668
|
if (key.startsWith(`${providerId}\0`) && !activeKeys.has(key))
|
|
372
669
|
this.cache.delete(key);
|
|
373
670
|
}
|
|
@@ -434,6 +731,82 @@ class ObservedSessionCollector {
|
|
|
434
731
|
}
|
|
435
732
|
}
|
|
436
733
|
exports.ObservedSessionCollector = ObservedSessionCollector;
|
|
734
|
+
/**
|
|
735
|
+
* Parse cache bounded by entry count and approximate bytes. Insertion order
|
|
736
|
+
* doubles as recency: a hit re-inserts, so the first entry is always the
|
|
737
|
+
* least recently observed. Entries observed in the current pass are never
|
|
738
|
+
* evicted by that pass, so one oversized pass cannot thrash itself.
|
|
739
|
+
*/
|
|
740
|
+
class BoundedCache {
|
|
741
|
+
maxEntries;
|
|
742
|
+
maxBytes;
|
|
743
|
+
estimate;
|
|
744
|
+
entries = new Map();
|
|
745
|
+
totalBytes = 0;
|
|
746
|
+
constructor(maxEntries, maxBytes, estimate) {
|
|
747
|
+
this.maxEntries = maxEntries;
|
|
748
|
+
this.maxBytes = maxBytes;
|
|
749
|
+
this.estimate = estimate;
|
|
750
|
+
}
|
|
751
|
+
get size() {
|
|
752
|
+
return this.entries.size;
|
|
753
|
+
}
|
|
754
|
+
get(key, passId) {
|
|
755
|
+
const entry = this.entries.get(key);
|
|
756
|
+
if (!entry)
|
|
757
|
+
return undefined;
|
|
758
|
+
this.entries.delete(key);
|
|
759
|
+
this.entries.set(key, entry);
|
|
760
|
+
entry.passId = passId;
|
|
761
|
+
return entry;
|
|
762
|
+
}
|
|
763
|
+
set(key, entry, passId) {
|
|
764
|
+
this.delete(key);
|
|
765
|
+
const bytes = this.estimate(entry.value);
|
|
766
|
+
this.entries.set(key, { ...entry, bytes, passId });
|
|
767
|
+
this.totalBytes += bytes;
|
|
768
|
+
this.evict(passId);
|
|
769
|
+
}
|
|
770
|
+
delete(key) {
|
|
771
|
+
const entry = this.entries.get(key);
|
|
772
|
+
if (!entry)
|
|
773
|
+
return;
|
|
774
|
+
this.totalBytes -= entry.bytes;
|
|
775
|
+
this.entries.delete(key);
|
|
776
|
+
}
|
|
777
|
+
keys() {
|
|
778
|
+
return this.entries.keys();
|
|
779
|
+
}
|
|
780
|
+
[Symbol.iterator]() {
|
|
781
|
+
return this.entries.entries();
|
|
782
|
+
}
|
|
783
|
+
clear() {
|
|
784
|
+
this.entries.clear();
|
|
785
|
+
this.totalBytes = 0;
|
|
786
|
+
}
|
|
787
|
+
evict(passId) {
|
|
788
|
+
while (this.entries.size > this.maxEntries || this.totalBytes > this.maxBytes) {
|
|
789
|
+
// Untouched entries always precede entries touched in this pass, so
|
|
790
|
+
// a protected first entry means every remaining entry is protected.
|
|
791
|
+
const first = this.entries.entries().next();
|
|
792
|
+
if (first.done || first.value[1].passId === passId)
|
|
793
|
+
return;
|
|
794
|
+
this.delete(first.value[0]);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
function defaultValueBytes(value) {
|
|
799
|
+
const json = JSON.stringify(value);
|
|
800
|
+
return typeof json === 'string' ? json.length : FALLBACK_VALUE_BYTES;
|
|
801
|
+
}
|
|
802
|
+
function compact(diagnostic) {
|
|
803
|
+
const result = {};
|
|
804
|
+
for (const [key, value] of Object.entries(diagnostic)) {
|
|
805
|
+
if (value !== undefined)
|
|
806
|
+
result[key] = value;
|
|
807
|
+
}
|
|
808
|
+
return result;
|
|
809
|
+
}
|
|
437
810
|
/**
|
|
438
811
|
* Adapt a provider using path-only discovery and independent adapter reads, so
|
|
439
812
|
* one malformed session cannot reject discovery of its healthy siblings.
|
|
@@ -454,17 +827,26 @@ function observedSessionSourceFromProvider(provider, cwd, options = {}) {
|
|
|
454
827
|
fingerprintParts: parts ?? undefined,
|
|
455
828
|
};
|
|
456
829
|
};
|
|
457
|
-
const
|
|
830
|
+
const referenceFromInfo = (info) => {
|
|
831
|
+
const mtimeMs = info.mtime.getTime();
|
|
832
|
+
const suppliedParts = info.sizeBytes !== undefined && Number.isFinite(mtimeMs)
|
|
833
|
+
? { sizeBytes: info.sizeBytes, mtimeMs }
|
|
834
|
+
: undefined;
|
|
835
|
+
return referenceFromPath(info.path, info.sessionId ?? provider.getSessionId(info.path), suppliedParts);
|
|
836
|
+
};
|
|
837
|
+
const discover = async (discoverOptions = {}) => {
|
|
458
838
|
if (provider.listSessionFilesAsync) {
|
|
459
839
|
try {
|
|
460
|
-
const files = await provider.listSessionFilesAsync(cwd
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
840
|
+
const files = await provider.listSessionFilesAsync(cwd, {
|
|
841
|
+
limit: discoverOptions.limit,
|
|
842
|
+
since: discoverOptions.since,
|
|
843
|
+
stats: discoverOptions.stats,
|
|
844
|
+
// Appends do not bump a directory's mtime; a full pass re-checks
|
|
845
|
+
// files modified recently so a missed watch event cannot hide a
|
|
846
|
+
// live session's growth from a cached listing.
|
|
847
|
+
revalidateRecent: true,
|
|
467
848
|
});
|
|
849
|
+
return files.map(referenceFromInfo);
|
|
468
850
|
}
|
|
469
851
|
catch {
|
|
470
852
|
// Retain compatibility with third-party providers whose optional async
|
|
@@ -483,7 +865,7 @@ function observedSessionSourceFromProvider(provider, cwd, options = {}) {
|
|
|
483
865
|
};
|
|
484
866
|
});
|
|
485
867
|
};
|
|
486
|
-
|
|
868
|
+
const source = {
|
|
487
869
|
providerId: provider.id,
|
|
488
870
|
capabilities: adapter.capabilities,
|
|
489
871
|
discover,
|
|
@@ -506,6 +888,26 @@ function observedSessionSourceFromProvider(provider, cwd, options = {}) {
|
|
|
506
888
|
},
|
|
507
889
|
dispose: () => adapter.dispose(),
|
|
508
890
|
};
|
|
891
|
+
if (provider.statWatchedSessionFile) {
|
|
892
|
+
const statWatchedSessionFile = provider.statWatchedSessionFile.bind(provider);
|
|
893
|
+
source.resolveReference = async (signal) => {
|
|
894
|
+
if (!signal.root || typeof signal.filename !== 'string' || signal.filename.length === 0) {
|
|
895
|
+
return { status: 'unknown' };
|
|
896
|
+
}
|
|
897
|
+
const resolved = await statWatchedSessionFile(signal.root, signal.filename, cwd || undefined);
|
|
898
|
+
switch (resolved.status) {
|
|
899
|
+
case 'present':
|
|
900
|
+
return { status: 'present', reference: referenceFromInfo(resolved.file) };
|
|
901
|
+
case 'missing':
|
|
902
|
+
return { status: 'missing', sessionId: resolved.sessionId };
|
|
903
|
+
case 'ignored':
|
|
904
|
+
return { status: 'ignored' };
|
|
905
|
+
default:
|
|
906
|
+
return { status: 'unknown' };
|
|
907
|
+
}
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
return source;
|
|
509
911
|
}
|
|
510
912
|
function parseFingerprintParts(value) {
|
|
511
913
|
if (!value)
|
|
@@ -535,7 +937,12 @@ function subscribeToProviderRoot(provider, listener, pollIntervalMs) {
|
|
|
535
937
|
}
|
|
536
938
|
for (const root of new Set(roots)) {
|
|
537
939
|
try {
|
|
538
|
-
const watcher = fs.watch(root, { persistent: false, recursive: true }, listener
|
|
940
|
+
const watcher = fs.watch(root, { persistent: false, recursive: true }, (eventType, filename) => listener({
|
|
941
|
+
trigger: 'event',
|
|
942
|
+
root,
|
|
943
|
+
eventType,
|
|
944
|
+
filename: filename === null || filename === undefined ? null : String(filename),
|
|
945
|
+
}));
|
|
539
946
|
watchers.add(watcher);
|
|
540
947
|
watcher.on('error', () => {
|
|
541
948
|
watchers.delete(watcher);
|
|
@@ -552,7 +959,7 @@ function subscribeToProviderRoot(provider, listener, pollIntervalMs) {
|
|
|
552
959
|
}
|
|
553
960
|
}
|
|
554
961
|
if (pollIntervalMs > 0) {
|
|
555
|
-
pollTimer = setInterval(listener, pollIntervalMs);
|
|
962
|
+
pollTimer = setInterval(() => listener({ trigger: 'poll' }), pollIntervalMs);
|
|
556
963
|
pollTimer.unref?.();
|
|
557
964
|
}
|
|
558
965
|
return {
|