chat-recall 0.5.6 → 0.5.8
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 +332 -0
- package/dist/cli.js +7057 -4542
- package/dist/cli.js.map +1 -1
- package/dist/code-index-cursor.d.ts +18 -0
- package/dist/code-index-cursor.d.ts.map +1 -0
- package/dist/code-index-cursor.js +89 -0
- package/dist/code-index-cursor.js.map +1 -0
- package/dist/daemon-log.d.ts +44 -0
- package/dist/daemon-log.d.ts.map +1 -0
- package/dist/daemon-log.js +74 -0
- package/dist/daemon-log.js.map +1 -0
- package/dist/derived.d.ts +20 -0
- package/dist/derived.d.ts.map +1 -0
- package/dist/derived.js +106 -0
- package/dist/derived.js.map +1 -0
- package/dist/install-skills.d.ts.map +1 -1
- package/dist/install-skills.js +6 -1
- package/dist/install-skills.js.map +1 -1
- package/dist/log-rotate.d.ts +16 -0
- package/dist/log-rotate.d.ts.map +1 -0
- package/dist/log-rotate.js +50 -0
- package/dist/log-rotate.js.map +1 -0
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +5865 -3789
- package/dist/mcp.js.map +1 -1
- package/dist/project-tasks.js +1 -1
- package/dist/project-tasks.js.map +1 -1
- package/dist/redact-deep.d.ts +4 -0
- package/dist/redact-deep.d.ts.map +1 -0
- package/dist/redact-deep.js +26 -0
- package/dist/redact-deep.js.map +1 -0
- package/dist/scan-pool.d.ts +56 -0
- package/dist/scan-pool.d.ts.map +1 -0
- package/dist/scan-pool.js +245 -0
- package/dist/scan-pool.js.map +1 -0
- package/dist/scan-worker.d.ts +78 -0
- package/dist/scan-worker.d.ts.map +1 -0
- package/dist/scan-worker.js +11175 -0
- package/dist/scan-worker.js.map +1 -0
- package/dist/self-restart.d.ts +20 -2
- package/dist/self-restart.d.ts.map +1 -1
- package/dist/self-restart.js +16 -6
- package/dist/self-restart.js.map +1 -1
- package/dist/server-probe.d.ts +76 -0
- package/dist/server-probe.d.ts.map +1 -0
- package/dist/server-probe.js +106 -0
- package/dist/server-probe.js.map +1 -0
- package/dist/sync-client.d.ts +24 -0
- package/dist/sync-client.d.ts.map +1 -1
- package/dist/sync-client.js +496 -151
- package/dist/sync-client.js.map +1 -1
- package/dist/target-breaker.d.ts +61 -0
- package/dist/target-breaker.d.ts.map +1 -0
- package/dist/target-breaker.js +85 -0
- package/dist/target-breaker.js.map +1 -0
- package/dist/telemetry-consent.d.ts +31 -0
- package/dist/telemetry-consent.d.ts.map +1 -0
- package/dist/telemetry-consent.js +115 -0
- package/dist/telemetry-consent.js.map +1 -0
- package/dist/telemetry.d.ts +22 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +125 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/tick-queue.d.ts +67 -0
- package/dist/tick-queue.d.ts.map +1 -0
- package/dist/tick-queue.js +104 -0
- package/dist/tick-queue.js.map +1 -0
- package/dist/transport-safety.d.ts +43 -0
- package/dist/transport-safety.d.ts.map +1 -0
- package/dist/transport-safety.js +100 -0
- package/dist/transport-safety.js.map +1 -0
- package/dist/verify-repair.js +1 -1
- package/dist/verify-repair.js.map +1 -1
- package/dist/watch.js +8069 -5720
- package/package.json +21 -2
package/dist/sync-client.js
CHANGED
|
@@ -45,13 +45,11 @@ import { dropFuzzyFindings } from '@chat-recall/engine/core/secret-precision.js'
|
|
|
45
45
|
import { loadSettings, saveSettings, isProjectSyncable } from '@chat-recall/engine/core/settings.js';
|
|
46
46
|
import { getDataDir } from '@chat-recall/engine/core/paths.js';
|
|
47
47
|
import { listAvailableBackends } from '@chat-recall/engine/core/tool-backend.js';
|
|
48
|
-
import { extractTurnsAny, replaySessionAny } from '@chat-recall/engine/core/session-multi-tool.js';
|
|
49
48
|
import { parseTranscript, trimTranscriptForSync, TRANSCRIPT_VERSION, gzipContainer, mapContainerText, buildRawContainer, containerSrcHash, parseTranscriptFromContainer, updateShadow } from '@chat-recall/engine/transcript/index.js';
|
|
50
49
|
import { parseClaudeTranscriptText } from '@chat-recall/engine/transcript/claude.js';
|
|
51
50
|
import { parseGeminiTranscriptText } from '@chat-recall/engine/transcript/gemini.js';
|
|
52
51
|
import { parseCodexTranscriptText } from '@chat-recall/engine/transcript/codex.js';
|
|
53
52
|
import { getBackendForId, getBackend } from '@chat-recall/engine/core/tool-backend.js';
|
|
54
|
-
import { computeOutcome } from '@chat-recall/engine/core/session-outcome.js';
|
|
55
53
|
import { fileURLToPath } from 'node:url';
|
|
56
54
|
let ownVersion = '0.0.0';
|
|
57
55
|
try {
|
|
@@ -66,7 +64,6 @@ catch {
|
|
|
66
64
|
}
|
|
67
65
|
catch { }
|
|
68
66
|
}
|
|
69
|
-
import { markPrompt, summarizeMarkers } from '@chat-recall/engine/core/session-sentiment.js';
|
|
70
67
|
import { parseSessionFile, readCwdFromJsonl } from '@chat-recall/engine/parsers/session.js';
|
|
71
68
|
import { resolveProjectId } from '@chat-recall/engine/core/project-resolver.js';
|
|
72
69
|
import { extractEntities } from '@chat-recall/engine/core/entity-extractor.js';
|
|
@@ -76,7 +73,61 @@ import { extractorVersionForTool, extractorVersionForId, toolOfId, EXTRACTOR_VER
|
|
|
76
73
|
import { SYNC_FIELDS } from '@chat-recall/engine/core/sync-fields.js';
|
|
77
74
|
import { acquireIndexLock } from '@chat-recall/engine/core/index-lock.js';
|
|
78
75
|
import { fetchWithTimeout } from './http.js';
|
|
79
|
-
import {
|
|
76
|
+
import { gzipSync } from 'zlib';
|
|
77
|
+
import { redactDeep } from './redact-deep.js';
|
|
78
|
+
import { collectDerivedRows } from './derived.js';
|
|
79
|
+
import { scanPool } from './scan-pool.js';
|
|
80
|
+
import { isPrivateHost, transportRisk, assertTransportSafe } from './transport-safety.js';
|
|
81
|
+
import { breakerState, breakerNotice, noteTargetSuccess, noteTargetFailure } from './target-breaker.js';
|
|
82
|
+
import { setTelemetryEligible } from './telemetry-consent.js';
|
|
83
|
+
import { record, flush } from './telemetry.js';
|
|
84
|
+
/**
|
|
85
|
+
* The server rule pack as it arrived, kept for the scan workers.
|
|
86
|
+
*
|
|
87
|
+
* Workers run in their own isolate with their own copy of the redactor, so they
|
|
88
|
+
* need the patterns as data. The installed form is compiled RegExp objects,
|
|
89
|
+
* which cannot cross a postMessage.
|
|
90
|
+
*/
|
|
91
|
+
/**
|
|
92
|
+
* Walk progress for the next upload to carry.
|
|
93
|
+
*
|
|
94
|
+
* Module-scope because the producer (the walk) and the consumer (the batch
|
|
95
|
+
* poster) are far apart, and threading it through every batcher signature to
|
|
96
|
+
* deliver a cosmetic field would be worse than a single slot.
|
|
97
|
+
*/
|
|
98
|
+
/**
|
|
99
|
+
* Reduce an error message to a bounded CLASS.
|
|
100
|
+
*
|
|
101
|
+
* Telemetry must never carry a raw error string: server errors quote paths,
|
|
102
|
+
* payload fragments and occasionally tokens. An enum is enough to answer "what
|
|
103
|
+
* is breaking for customers", and cannot leak.
|
|
104
|
+
*/
|
|
105
|
+
function classifyError(msg) {
|
|
106
|
+
const m = msg.toLowerCase();
|
|
107
|
+
if (m.includes('429'))
|
|
108
|
+
return 'rate_limited';
|
|
109
|
+
if (m.includes('402'))
|
|
110
|
+
return 'payment_required';
|
|
111
|
+
if (m.includes('401') || m.includes('403') || m.includes('token'))
|
|
112
|
+
return 'auth';
|
|
113
|
+
if (m.includes('timed out') || m.includes('etimedout'))
|
|
114
|
+
return 'timeout';
|
|
115
|
+
if (m.includes('econnrefused'))
|
|
116
|
+
return 'refused';
|
|
117
|
+
if (m.includes('enotfound') || m.includes('dns'))
|
|
118
|
+
return 'dns';
|
|
119
|
+
if (m.includes('fetch failed') || m.includes('econnreset'))
|
|
120
|
+
return 'network';
|
|
121
|
+
if (m.includes('plain http') || m.includes('https'))
|
|
122
|
+
return 'insecure_transport';
|
|
123
|
+
if (/5\d\d/.test(m))
|
|
124
|
+
return 'server_error';
|
|
125
|
+
return 'other';
|
|
126
|
+
}
|
|
127
|
+
let walkProgressForUpload = null;
|
|
128
|
+
let lastServerPackSpec = null;
|
|
129
|
+
import { reportWalkProgress, readCollectorHealth, updateCollectorHealth } from '@chat-recall/engine/core/collector-health.js';
|
|
130
|
+
import { withSessionScanScope } from '@chat-recall/engine/core/live-session-scan.js';
|
|
80
131
|
import '@chat-recall/engine/core/backends/index.js'; // register the tool backends
|
|
81
132
|
// Lives under the data dir so CHAT_RECALL_DATA_DIR isolates credentials the
|
|
82
133
|
// same way it isolates the index (tests, multi-profile setups).
|
|
@@ -88,6 +139,10 @@ const credPath = () => join(getDataDir(), 'credentials.json');
|
|
|
88
139
|
* form still readable.
|
|
89
140
|
*/
|
|
90
141
|
export function saveCredentials(c) {
|
|
142
|
+
// REFUSE AT THE DOOR. Every login path funnels through here, so this is the
|
|
143
|
+
// one place that can stop a cleartext target being persisted at all — better
|
|
144
|
+
// than warning at sync time, by which point the user believes they are set up.
|
|
145
|
+
assertTransportSafe(c.serverUrl);
|
|
91
146
|
mkdirSync(dirname(credPath()), { recursive: true });
|
|
92
147
|
const targets = loadAllCredentials().filter((t) => t.serverUrl !== c.serverUrl);
|
|
93
148
|
// Newest login FIRST: single-target consumers (loadCredentials → targets[0],
|
|
@@ -164,6 +219,10 @@ async function fetchTenantSecurityConfig(cred) {
|
|
|
164
219
|
const packSpec = body.pack?.rules
|
|
165
220
|
? { version: body.pack.version, rules: body.pack.rules.map((r) => ({ name: r.name, regex: r.regex, flags: r.flags, redact: true, source: r.source })) }
|
|
166
221
|
: { version: body.version, rules: enabled.filter((r) => r.redact === true).map((r) => ({ name: r.name, regex: r.regex, redact: true, source: 'tenant' })) };
|
|
222
|
+
// Keep the SPEC, not just the compiled rules: a worker needs the raw
|
|
223
|
+
// patterns to install them in its own isolate, and the compiled form
|
|
224
|
+
// holds RegExp objects that do not survive a postMessage.
|
|
225
|
+
lastServerPackSpec = { version: packSpec.version || '', rules: packSpec.rules };
|
|
167
226
|
const pack = installServerRulePack(packSpec);
|
|
168
227
|
rulePackVersion = serverRulePackVersion();
|
|
169
228
|
for (const r of pack.rejected) {
|
|
@@ -181,6 +240,19 @@ async function fetchTenantSecurityConfig(cred) {
|
|
|
181
240
|
const body = await settingsRes.json().catch(() => ({}));
|
|
182
241
|
if (typeof body.verifySecrets === 'boolean')
|
|
183
242
|
verifySecrets = body.verifySecrets;
|
|
243
|
+
// Learned HERE because this request happens on every sync. Reading it from
|
|
244
|
+
// the /api/sync response meant a fully-synced machine — which uploads
|
|
245
|
+
// nothing, so gets no response — could never learn its own eligibility.
|
|
246
|
+
if (typeof body.telemetry === 'boolean') {
|
|
247
|
+
setTelemetryEligible(base, body.telemetry);
|
|
248
|
+
try {
|
|
249
|
+
const prior = readCollectorHealth()?.telemetryEligible ?? {};
|
|
250
|
+
updateCollectorHealth({
|
|
251
|
+
telemetryEligible: { ...prior, [base]: { allowed: body.telemetry, at: Date.now() } },
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
catch { /* cosmetic — never fail a sync over it */ }
|
|
255
|
+
}
|
|
184
256
|
}
|
|
185
257
|
const config = { tenantRules, verifySecrets, rulePackVersion };
|
|
186
258
|
_securityConfigCache.set(base, { fetchedAt: Date.now(), config });
|
|
@@ -296,22 +368,6 @@ export function removeCredentials(serverUrl) {
|
|
|
296
368
|
/** Tokenize an absolute project path so the server can group by project
|
|
297
369
|
* without learning the developer's filesystem layout. */
|
|
298
370
|
const hashPath = (p) => (p ? 'p_' + createHash('sha256').update(p).digest('hex').slice(0, 12) : '');
|
|
299
|
-
/** Redact every string anywhere inside a JSON-serializable value. Used for
|
|
300
|
-
* derived payloads (diffs carry file content; outcomes carry prompt text)
|
|
301
|
-
* where field-by-field redaction would be fragile. */
|
|
302
|
-
function redactDeep(v, count) {
|
|
303
|
-
if (typeof v === 'string')
|
|
304
|
-
return redactSecrets(v, { force: true, count });
|
|
305
|
-
if (Array.isArray(v))
|
|
306
|
-
return v.map((x) => redactDeep(x, count));
|
|
307
|
-
if (v && typeof v === 'object') {
|
|
308
|
-
const o = {};
|
|
309
|
-
for (const [k, val] of Object.entries(v))
|
|
310
|
-
o[k] = redactDeep(val, count);
|
|
311
|
-
return o;
|
|
312
|
-
}
|
|
313
|
-
return v;
|
|
314
|
-
}
|
|
315
371
|
/** compute_cache kinds shipped to the server. Must stay in step with the
|
|
316
372
|
* kinds the server's conversation routes read (heavyCacheGet keys). */
|
|
317
373
|
const COMPUTE_KINDS = ['diff', 'outcome', 'commits', 'markers'];
|
|
@@ -324,26 +380,14 @@ const MAX_DERIVED_ROW_BYTES = 2 * 1024 * 1024;
|
|
|
324
380
|
* is no shared-tenant or rate-limiter to be polite to, so they get a higher
|
|
325
381
|
* in-flight cap and zero inter-batch sleep. Remote/SaaS hosts stay gentler.
|
|
326
382
|
*/
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '0.0.0.0')
|
|
336
|
-
return true;
|
|
337
|
-
if (host.endsWith('.local') || host.endsWith('.lan'))
|
|
338
|
-
return true;
|
|
339
|
-
if (/^10\./.test(host))
|
|
340
|
-
return true; // 10.0.0.0/8
|
|
341
|
-
if (/^192\.168\./.test(host))
|
|
342
|
-
return true; // 192.168.0.0/16
|
|
343
|
-
if (/^172\.(1[6-9]|2\d|3[01])\./.test(host))
|
|
344
|
-
return true; // 172.16.0.0/12
|
|
345
|
-
return false;
|
|
346
|
-
}
|
|
383
|
+
/**
|
|
384
|
+
* "You own this box", for upload pacing.
|
|
385
|
+
*
|
|
386
|
+
* One definition, shared with the transport gate — these were two copies of the
|
|
387
|
+
* same private-address list, and the moment they disagree either politeness or
|
|
388
|
+
* SECURITY is wrong. transport-safety.ts owns the list.
|
|
389
|
+
*/
|
|
390
|
+
const isLocalHost = isPrivateHost;
|
|
347
391
|
export async function syncSessions(opts = {}) {
|
|
348
392
|
const targets = loadAllCredentials();
|
|
349
393
|
if (targets.length === 0)
|
|
@@ -356,9 +400,24 @@ export async function syncSessions(opts = {}) {
|
|
|
356
400
|
catch { /* never block a sync on housekeeping */ }
|
|
357
401
|
let agg = null;
|
|
358
402
|
const errors = [];
|
|
403
|
+
const skippedTargets = [];
|
|
359
404
|
for (const cred of targets) {
|
|
405
|
+
// CIRCUIT BREAKER. Targets are walked in SEQUENCE, so a dead one does not
|
|
406
|
+
// just fail — it delays every ship to the healthy targets behind it, and
|
|
407
|
+
// delays the walk's completion, which the ledger, the health file and the
|
|
408
|
+
// progress report all wait on. A LAN box that is off should cost one log
|
|
409
|
+
// line, not a full retry storm per session, every walk, forever.
|
|
410
|
+
const verdict = breakerState(cred.serverUrl);
|
|
411
|
+
if (verdict.open) {
|
|
412
|
+
const notice = breakerNotice(cred.serverUrl, verdict);
|
|
413
|
+
if (notice)
|
|
414
|
+
console.error(`[sync] ${notice}`);
|
|
415
|
+
skippedTargets.push(cred.serverUrl);
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
360
418
|
try {
|
|
361
419
|
const r = await syncToTarget(cred, opts);
|
|
420
|
+
noteTargetSuccess(cred.serverUrl);
|
|
362
421
|
agg = agg ? {
|
|
363
422
|
uploaded: agg.uploaded + r.uploaded, skipped: agg.skipped + r.skipped, redactions: agg.redactions + r.redactions,
|
|
364
423
|
items: agg.items + r.items, links: agg.links + r.links, findings: agg.findings + r.findings,
|
|
@@ -367,14 +426,59 @@ export async function syncSessions(opts = {}) {
|
|
|
367
426
|
} : r;
|
|
368
427
|
}
|
|
369
428
|
catch (e) {
|
|
370
|
-
|
|
429
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
430
|
+
errors.push(`${cred.serverUrl}: ${msg}`);
|
|
431
|
+
// Report the NEXT attempt in the same breath as the failure, so the log
|
|
432
|
+
// says what will happen rather than leaving the user to infer it.
|
|
433
|
+
const next = noteTargetFailure(cred.serverUrl, msg);
|
|
434
|
+
const notice = breakerNotice(cred.serverUrl, next);
|
|
435
|
+
if (notice)
|
|
436
|
+
console.error(`[sync] ${notice}`);
|
|
437
|
+
// The error CLASS, never the message: a server error string can quote a
|
|
438
|
+
// path or a payload, and this channel must not carry either.
|
|
439
|
+
record({
|
|
440
|
+
kind: next.open ? 'breaker_trip' : 'target_failure',
|
|
441
|
+
failures: next.failures,
|
|
442
|
+
retryInMs: next.retryInMs,
|
|
443
|
+
errorClass: classifyError(msg),
|
|
444
|
+
local: isLocalHost(cred.serverUrl) ? 1 : 0,
|
|
445
|
+
});
|
|
371
446
|
}
|
|
372
447
|
}
|
|
448
|
+
// A walk that shipped nothing because every target is in cooldown is NOT a
|
|
449
|
+
// success, and must not be reported as one — the caller marks target health
|
|
450
|
+
// from this, and "0 pushed, no error" would read as a healthy quiet walk.
|
|
451
|
+
if (!agg && skippedTargets.length > 0 && errors.length === 0) {
|
|
452
|
+
throw new Error(`every sync target is in failure cooldown: ${skippedTargets.join(', ')}. `
|
|
453
|
+
+ 'They will be retried automatically; run `chat-recall doctor` for the last error.');
|
|
454
|
+
}
|
|
373
455
|
// Ledger writes are coalesced during the walk (see persist() in sync-ledger).
|
|
374
456
|
// This is the durability point: everything acked above reaches disk before we
|
|
375
457
|
// report success, so a crash after this cannot re-ship what the server has.
|
|
376
458
|
// In the finally, because a partial walk's acks are still worth keeping.
|
|
377
459
|
flushLedger();
|
|
460
|
+
// OPERATIONAL TELEMETRY, gated on consent + plan (telemetry-consent.ts). These
|
|
461
|
+
// are the numbers that were previously only ever measured on one developer
|
|
462
|
+
// machine, which is why "how long is a first sync for a 5,000-session
|
|
463
|
+
// customer?" had no answer. Numbers only — the payload cannot carry a path,
|
|
464
|
+
// project or session id, structurally.
|
|
465
|
+
if (agg) {
|
|
466
|
+
record({
|
|
467
|
+
kind: 'sync_walk',
|
|
468
|
+
targets: targets.length,
|
|
469
|
+
skippedTargets: skippedTargets.length,
|
|
470
|
+
failedTargets: errors.length,
|
|
471
|
+
uploaded: agg.uploaded,
|
|
472
|
+
skipped: agg.skipped,
|
|
473
|
+
redactions: agg.redactions,
|
|
474
|
+
scanned: agg.scanned,
|
|
475
|
+
scanMs: Math.round(agg.scanMs),
|
|
476
|
+
rssPeakMb: Math.round(process.memoryUsage().rss / 1048576),
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
// After the walk: the connection is warm and the numbers are final. Never
|
|
480
|
+
// awaited into the caller's error path — telemetry must not fail a sync.
|
|
481
|
+
void flush().catch(() => { });
|
|
378
482
|
if (!agg)
|
|
379
483
|
throw new Error(`sync failed for all targets:\n ${errors.join('\n ')}`);
|
|
380
484
|
if (errors.length > 0)
|
|
@@ -610,7 +714,38 @@ export function batchScanExternal(items, opts = {}) {
|
|
|
610
714
|
*/
|
|
611
715
|
const CAPS_TTL_MS = 60 * 60_000;
|
|
612
716
|
const capsOk = new Map(); // serverUrl -> epoch ms verified
|
|
613
|
-
|
|
717
|
+
/** Ingest concurrency the server says it will accept, per serverUrl. */
|
|
718
|
+
const advertisedIngestConc = new Map();
|
|
719
|
+
/** Servers that accept a gzipped request body. */
|
|
720
|
+
const advertisedGzipBody = new Set();
|
|
721
|
+
export function _resetCapsCacheForTests() {
|
|
722
|
+
capsOk.clear();
|
|
723
|
+
advertisedIngestConc.clear();
|
|
724
|
+
advertisedGzipBody.clear();
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Learn the tenant-scoped ingest allowance from an authenticated response.
|
|
728
|
+
*
|
|
729
|
+
* `verifyServerApi` sets the same map from /api/capabilities, which is pre-auth
|
|
730
|
+
* and therefore can only report the CLASS ceiling. This overrides it with the
|
|
731
|
+
* number that actually applies to this tenant.
|
|
732
|
+
*/
|
|
733
|
+
export function setServerIngestConcurrency(serverUrl, n) {
|
|
734
|
+
advertisedIngestConc.set(serverUrl.replace(/\/$/, ''), n);
|
|
735
|
+
}
|
|
736
|
+
/** Does this server accept `Content-Encoding: gzip` on a request body? */
|
|
737
|
+
export function serverAcceptsGzipBody(serverUrl) {
|
|
738
|
+
return advertisedGzipBody.has(serverUrl.replace(/\/$/, ''));
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* How many uploads may be in flight at once, according to the SERVER.
|
|
742
|
+
*
|
|
743
|
+
* Returns null when the server does not advertise it (older image) — the caller
|
|
744
|
+
* then keeps its own default.
|
|
745
|
+
*/
|
|
746
|
+
export function serverIngestConcurrency(serverUrl) {
|
|
747
|
+
return advertisedIngestConc.get(serverUrl.replace(/\/$/, '')) ?? null;
|
|
748
|
+
}
|
|
614
749
|
async function verifyServerApi(serverUrl, minApi) {
|
|
615
750
|
const base = serverUrl.replace(/\/$/, '');
|
|
616
751
|
const seen = capsOk.get(base);
|
|
@@ -620,10 +755,45 @@ async function verifyServerApi(serverUrl, minApi) {
|
|
|
620
755
|
if ((caps.apiVersion ?? 0) < minApi) {
|
|
621
756
|
throw new Error(`server too old: apiVersion ${caps.apiVersion ?? 'none'} < required ${minApi} — update the server image before syncing`);
|
|
622
757
|
}
|
|
758
|
+
// Learn the server's in-flight budget instead of assuming one. The old
|
|
759
|
+
// hardcoded 4 was exactly twice the server's per-tenant cap of 2, so every
|
|
760
|
+
// walk manufactured its own 429 storm — 3,526 of them in one measured log.
|
|
761
|
+
// A number the server owns can be tuned without shipping a CLI release.
|
|
762
|
+
const adv = caps.limits?.ingestConcurrencyPerTenant;
|
|
763
|
+
if (typeof adv === 'number' && Number.isFinite(adv) && adv >= 1) {
|
|
764
|
+
advertisedIngestConc.set(base, Math.floor(adv));
|
|
765
|
+
}
|
|
766
|
+
if (caps.limits?.acceptsGzipBody === true)
|
|
767
|
+
advertisedGzipBody.add(base);
|
|
768
|
+
else
|
|
769
|
+
advertisedGzipBody.delete(base);
|
|
623
770
|
capsOk.set(base, Date.now());
|
|
624
771
|
return true;
|
|
625
772
|
}
|
|
773
|
+
/**
|
|
774
|
+
* One walk = one scan scope.
|
|
775
|
+
*
|
|
776
|
+
* Locating a session used to probe every project directory in every profile
|
|
777
|
+
* home, PER SESSION — and the walk asks twice per session
|
|
778
|
+
* (`spansMultipleSources`, then `fileSize`). Measured on a 15,724-session
|
|
779
|
+
* corpus: 62.8 seconds of filesystem syscalls to discover that 2 sessions had
|
|
780
|
+
* changed. With the scope open the directories are listed once instead: 196 ms.
|
|
781
|
+
*
|
|
782
|
+
* The scope is the honest place for the cache. A walk snapshots its session
|
|
783
|
+
* list up front, so within it "where does this session live" genuinely cannot
|
|
784
|
+
* change; outside it every lookup still reads the filesystem.
|
|
785
|
+
*/
|
|
626
786
|
async function syncToTarget(cred, opts = {}) {
|
|
787
|
+
return withSessionScanScope(() => syncToTargetInScope(cred, opts));
|
|
788
|
+
}
|
|
789
|
+
async function syncToTargetInScope(cred, opts = {}) {
|
|
790
|
+
// Checked again here, not only at login: credentials.json is a plain file a
|
|
791
|
+
// user can edit, an env var can supply a target that never saw the login gate
|
|
792
|
+
// (see credentials-env.ts), and a target saved by an older version predates
|
|
793
|
+
// the check entirely. The upload path is the last place to say no.
|
|
794
|
+
const risk = transportRisk(cred.serverUrl);
|
|
795
|
+
if (risk)
|
|
796
|
+
throw new Error(risk);
|
|
627
797
|
const sync = loadSettings().sync;
|
|
628
798
|
// Exclusions = local settings ∪ server-side tenant config (dashboard-edited).
|
|
629
799
|
const serverCfg = await fetchTenantSyncConfig(cred);
|
|
@@ -720,7 +890,21 @@ async function syncToTarget(cred, opts = {}) {
|
|
|
720
890
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
721
891
|
const explicitThrottle = typeof opts.throttleMs === 'number' && opts.throttleMs > 0;
|
|
722
892
|
const throttleMs = explicitThrottle ? opts.throttleMs : 0;
|
|
723
|
-
|
|
893
|
+
// In-flight budget: the server's advertised per-tenant ingest concurrency when
|
|
894
|
+
// it publishes one, else the historical default. Never exceed what the server
|
|
895
|
+
// will accept — an extra in-flight batch does not go faster, it comes back
|
|
896
|
+
// 429 and then sleeps out its Retry-After.
|
|
897
|
+
//
|
|
898
|
+
// A local target is yours: no politeness needed, and no advertised cap to
|
|
899
|
+
// respect beyond its own.
|
|
900
|
+
const LOCAL_INFLIGHT = 6;
|
|
901
|
+
const REMOTE_INFLIGHT_FALLBACK = 4;
|
|
902
|
+
const advertised = serverIngestConcurrency(cred.serverUrl);
|
|
903
|
+
const maxInflight = explicitThrottle
|
|
904
|
+
? 1
|
|
905
|
+
: isLocalHost(cred.serverUrl)
|
|
906
|
+
? Math.max(LOCAL_INFLIGHT, advertised ?? 0)
|
|
907
|
+
: (advertised ?? REMOTE_INFLIGHT_FALLBACK);
|
|
724
908
|
// Per-attempt upload timeout. WITHOUT this, a half-open/slow connection makes
|
|
725
909
|
// `fetch` hang FOREVER (Node fetch has no default timeout) — observed as a
|
|
726
910
|
// sync stuck for 20-40min at idle CPU with zero progress. With it, a hung
|
|
@@ -729,8 +913,41 @@ async function syncToTarget(cred, opts = {}) {
|
|
|
729
913
|
// Single POST with retry. Retryable: network/abort errors, HTTP 429 (obeying
|
|
730
914
|
// Retry-After — the server's own rate signal), and 5xx. Non-429 4xx (bad
|
|
731
915
|
// token, oversized body) is fatal: retrying can't fix it.
|
|
916
|
+
// COMPRESS THE BODY. Nothing compressed it before — every sync shipped raw
|
|
917
|
+
// JSON. Measured over five real sessions, 11.47 MB of envelope became 6.69 MB:
|
|
918
|
+
// 42% off the wire for one header and one gzipSync.
|
|
919
|
+
//
|
|
920
|
+
// Why gzip and not a binary/multipart redesign. The payload is ~64% `raw_b64`
|
|
921
|
+
// — a gzipped transcript, base64'd into JSON, which inflates it ~33%. Sending
|
|
922
|
+
// those bytes binary alongside a compressed metadata part measures 6.66 MB:
|
|
923
|
+
// the SAME bytes as just gzipping the whole thing, because gzip recovers most
|
|
924
|
+
// of what base64 wasted. Binary buys CPU (115 ms vs 318 ms), not bandwidth,
|
|
925
|
+
// and costs a new wire format on both sides. Brotli reaches 6.07 MB — another
|
|
926
|
+
// 5% — and is where to go if bandwidth ever matters more than simplicity.
|
|
927
|
+
//
|
|
928
|
+
// Gated on the server advertising it: body-parser inflates transparently and
|
|
929
|
+
// its size limit still applies to the DECOMPRESSED body, so the 32 MB ceiling
|
|
930
|
+
// on /api/sync is unchanged.
|
|
931
|
+
const gzipBody = serverAcceptsGzipBody(cred.serverUrl);
|
|
732
932
|
const post = async (body) => {
|
|
733
|
-
|
|
933
|
+
// SERIALISE INTO A BUFFER AND KEEP ONLY THAT. Retries hold the payload for
|
|
934
|
+
// up to four attempts × a 90s timeout, so anything still referenced here is
|
|
935
|
+
// resident for minutes. The JSON string is scoped to this block so it is
|
|
936
|
+
// collectable the moment the Buffer exists — previously both the string and
|
|
937
|
+
// (with gzip on) the compressed copy stayed reachable for the whole retry
|
|
938
|
+
// chain, i.e. ~2.3× the payload held for as long as the server was slow.
|
|
939
|
+
const payload = (() => {
|
|
940
|
+
// Ride the progress report along on whatever batch is already going out.
|
|
941
|
+
// The UI had only an AGE to show ("25m behind"), which during a first sync
|
|
942
|
+
// is indistinguishable from broken. No extra request, no new endpoint —
|
|
943
|
+
// the server stashes it and serves it on the status the UI already polls.
|
|
944
|
+
const withProgress = walkProgressForUpload
|
|
945
|
+
? { ...body, progress: walkProgressForUpload }
|
|
946
|
+
: body;
|
|
947
|
+
const json = JSON.stringify(withProgress);
|
|
948
|
+
const raw = Buffer.from(json, 'utf8');
|
|
949
|
+
return gzipBody ? gzipSync(raw, { level: 6 }) : raw;
|
|
950
|
+
})();
|
|
734
951
|
const RETRY_DELAYS_MS = [2000, 8000, 30000];
|
|
735
952
|
for (let attempt = 0;; attempt++) {
|
|
736
953
|
const ac = new AbortController();
|
|
@@ -740,14 +957,38 @@ async function syncToTarget(cred, opts = {}) {
|
|
|
740
957
|
method: 'POST',
|
|
741
958
|
// No token ⇒ no auth header: a no-auth local server (AUTH_PROVIDER=none)
|
|
742
959
|
// accepts it as the single 'default' tenant.
|
|
743
|
-
headers:
|
|
744
|
-
|
|
745
|
-
|
|
960
|
+
headers: {
|
|
961
|
+
'content-type': 'application/json',
|
|
962
|
+
...(gzipBody ? { 'content-encoding': 'gzip' } : {}),
|
|
963
|
+
...(cred.token ? { authorization: `Bearer ${cred.token}` } : {}),
|
|
964
|
+
},
|
|
746
965
|
body: payload,
|
|
747
966
|
signal: ac.signal,
|
|
748
967
|
});
|
|
749
968
|
if (res.ok) {
|
|
750
969
|
const body = await res.json().catch(() => ({}));
|
|
970
|
+
// The server owns the plan answer; the user's opt-out is checked
|
|
971
|
+
// separately and wins over it.
|
|
972
|
+
if (typeof body.telemetry === 'boolean') {
|
|
973
|
+
setTelemetryEligible(base, body.telemetry);
|
|
974
|
+
// Persisted so `doctor` — a separate process that never syncs — can
|
|
975
|
+
// tell the user the truth about what leaves their machine.
|
|
976
|
+
try {
|
|
977
|
+
const prior = readCollectorHealth()?.telemetryEligible ?? {};
|
|
978
|
+
updateCollectorHealth({
|
|
979
|
+
telemetryEligible: { ...prior, [base]: { allowed: body.telemetry, at: Date.now() } },
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
catch { /* cosmetic — never fail a sync over it */ }
|
|
983
|
+
}
|
|
984
|
+
// The AUTHENTICATED per-tenant allowance beats the pre-auth class
|
|
985
|
+
// ceiling from /api/capabilities: only this response knows the plan,
|
|
986
|
+
// and the gate scales the ceiling down per tenant. Learning it here is
|
|
987
|
+
// what stops a free tenant asking for 6 slots it will never get.
|
|
988
|
+
const adv = body.limits?.ingestConcurrencyPerTenant;
|
|
989
|
+
if (typeof adv === 'number' && Number.isFinite(adv) && adv >= 1) {
|
|
990
|
+
setServerIngestConcurrency(base, Math.floor(adv));
|
|
991
|
+
}
|
|
751
992
|
if (body.cli && body.cli.version) {
|
|
752
993
|
const { planAutoUpdate, runAutoUpdate } = await import('./auto-update.js');
|
|
753
994
|
const authHeaders = cred.token ? { authorization: `Bearer ${cred.token}` } : {};
|
|
@@ -823,15 +1064,29 @@ async function syncToTarget(cred, opts = {}) {
|
|
|
823
1064
|
// whole sync (matching the old serial behaviour).
|
|
824
1065
|
const inflight = new Set();
|
|
825
1066
|
let poolError = null;
|
|
826
|
-
|
|
827
|
-
|
|
1067
|
+
// BOUND THE BYTES, not only the request count. `maxInflight` alone permits
|
|
1068
|
+
// maxInflight × the largest batch resident at once, and a batch is capped at
|
|
1069
|
+
// 4 MB only for the row categories that go through a batcher — a single
|
|
1070
|
+
// oversized conversation ships ALONE and can be far larger. Counting requests
|
|
1071
|
+
// said "4 in flight" whether that was 4 KB or 40 MB.
|
|
1072
|
+
//
|
|
1073
|
+
// An item bigger than the whole budget still ships, alone, once the pipe is
|
|
1074
|
+
// empty: refusing it would silently drop a session, and waiting forever would
|
|
1075
|
+
// deadlock the walk.
|
|
1076
|
+
const MAX_INFLIGHT_BYTES = (Number(process.env.CHAT_RECALL_MAX_INFLIGHT_MB) || 32) * 1024 * 1024;
|
|
1077
|
+
let inflightBytes = 0;
|
|
1078
|
+
const submit = async (body, after, bytesHint = 0) => {
|
|
1079
|
+
while (inflight.size > 0
|
|
1080
|
+
&& (inflight.size >= maxInflight || inflightBytes + bytesHint > MAX_INFLIGHT_BYTES)) {
|
|
828
1081
|
await Promise.race(inflight);
|
|
1082
|
+
}
|
|
829
1083
|
if (poolError)
|
|
830
1084
|
throw poolError;
|
|
1085
|
+
inflightBytes += bytesHint;
|
|
831
1086
|
let task;
|
|
832
1087
|
task = (async () => { await post(body); after(); })()
|
|
833
1088
|
.catch((e) => { poolError = poolError ?? e; })
|
|
834
|
-
.finally(() => { inflight.delete(task); });
|
|
1089
|
+
.finally(() => { inflight.delete(task); inflightBytes -= bytesHint; });
|
|
835
1090
|
inflight.add(task);
|
|
836
1091
|
};
|
|
837
1092
|
const drainInflight = async () => {
|
|
@@ -860,7 +1115,12 @@ async function syncToTarget(cred, opts = {}) {
|
|
|
860
1115
|
return {
|
|
861
1116
|
sent: 0,
|
|
862
1117
|
async add(row) {
|
|
863
|
-
|
|
1118
|
+
// BYTES, not UTF-16 code units. `.length` on the serialised string
|
|
1119
|
+
// undercounts exactly the content that blows the budget: any non-ASCII
|
|
1120
|
+
// character is 2–4 bytes on the wire but 1–2 units here, so a batch of
|
|
1121
|
+
// emoji-heavy or CJK transcript could be ~3× the 4 MB cap it was
|
|
1122
|
+
// measured against and get rejected by the server's body limit.
|
|
1123
|
+
const size = Buffer.byteLength(JSON.stringify(row));
|
|
864
1124
|
if (batch.length > 0 && (batchBytes + size > MAX_BATCH_BYTES || batch.length >= maxItems))
|
|
865
1125
|
await this.drain();
|
|
866
1126
|
batch.push(row);
|
|
@@ -870,11 +1130,14 @@ async function syncToTarget(cred, opts = {}) {
|
|
|
870
1130
|
if (batch.length === 0)
|
|
871
1131
|
return;
|
|
872
1132
|
const rows = flatten ? batch.flat() : batch;
|
|
1133
|
+
const bytes = batchBytes;
|
|
873
1134
|
batch = [];
|
|
874
1135
|
batchBytes = 0;
|
|
875
1136
|
// Hand off to the concurrency pool; `sent`/onFlush (e.g. markSynced)
|
|
876
|
-
// fire only after the server acks THIS batch, same as before.
|
|
877
|
-
|
|
1137
|
+
// fire only after the server acks THIS batch, same as before. The byte
|
|
1138
|
+
// total is already known here, so the pool can bound memory by size
|
|
1139
|
+
// instead of guessing from the request count.
|
|
1140
|
+
await submit({ [field]: rows }, () => { this.sent += rows.length; onFlush?.(rows); }, bytes);
|
|
878
1141
|
},
|
|
879
1142
|
};
|
|
880
1143
|
};
|
|
@@ -1144,8 +1407,68 @@ async function syncToTarget(cred, opts = {}) {
|
|
|
1144
1407
|
trace?.(`batch secret scan done: ${scanned} session(s) in ${batch.slices} slice(s)`);
|
|
1145
1408
|
}
|
|
1146
1409
|
trace?.('conversation walk start');
|
|
1410
|
+
// Say that a walk STARTED, before anything is done. A first sync on a large
|
|
1411
|
+
// corpus runs for a long time, and "0 of 15,724" is the difference between a
|
|
1412
|
+
// user waiting and a user assuming it is broken.
|
|
1413
|
+
const walkStartedAt = Date.now();
|
|
1414
|
+
reportWalkProgress({ done: 0, total: slice.length, startedAt: walkStartedAt, complete: false });
|
|
1415
|
+
walkProgressForUpload = { done: 0, total: slice.length, complete: false };
|
|
1147
1416
|
let traceN = 0;
|
|
1417
|
+
// ── KEEP THE EVENT LOOP ALIVE ───────────────────────────────────────────
|
|
1418
|
+
//
|
|
1419
|
+
// THIS IS WHY THE COLLECTOR LOOKED DEAD. Every `await` in this loop resolves
|
|
1420
|
+
// as a MICROTASK, and Node drains the entire microtask queue before it
|
|
1421
|
+
// advances to the timer phase. So a backlog of sessions — each one followed by
|
|
1422
|
+
// a multi-second synchronous secret scan — runs as ONE unbroken chain, and for
|
|
1423
|
+
// its whole duration nothing else in the process happens:
|
|
1424
|
+
//
|
|
1425
|
+
// · the 60-second heartbeat never fires, so the log goes silent
|
|
1426
|
+
// · collector-health.json freezes, so every reader says "not running"
|
|
1427
|
+
// · SIGCHLD is not serviced, so a finished codeindex child sits <defunct>
|
|
1428
|
+
// · SIGTERM is not serviced, so systemd waits 90s and then SIGKILLs
|
|
1429
|
+
//
|
|
1430
|
+
// Measured: 12 sessions, 388 MB, 24.2 seconds of walking, ZERO of 24 expected
|
|
1431
|
+
// heartbeats. With `setImmediate` between sessions — the check phase runs
|
|
1432
|
+
// AFTER the timer phase — the same work fired 11. The daemon was never hung;
|
|
1433
|
+
// it simply never yielded, and "never yields for nine minutes" is
|
|
1434
|
+
// indistinguishable from dead to everything watching it.
|
|
1435
|
+
//
|
|
1436
|
+
// Once per session, unconditionally. A first version gated this behind a
|
|
1437
|
+
// 50 ms budget to avoid "wasting" loop turns on the ~15,700 refs that skip in
|
|
1438
|
+
// microseconds — then measured it: 15,727 unconditional yields cost 37.9 ms
|
|
1439
|
+
// total, 2.4 µs each, against a walk that takes over a minute. The budget
|
|
1440
|
+
// bought nothing and cost an arbitrary constant plus a clock read in the hot
|
|
1441
|
+
// path, so it is gone. One yield per iteration is deterministic and needs no
|
|
1442
|
+
// tuning.
|
|
1443
|
+
//
|
|
1444
|
+
// NOT THE WHOLE ANSWER, and worth being clear about: this makes the process
|
|
1445
|
+
// RESPONSIVE while it works, it does not make the work faster. The scan is
|
|
1446
|
+
// pure, CPU-bound and uses one of this machine's twelve cores. Moving it to
|
|
1447
|
+
// worker_threads is the real fix — it would keep the main thread completely
|
|
1448
|
+
// free AND parallelise across cores. That is a much larger change; yielding is
|
|
1449
|
+
// the correct minimal one, and it is what turns "the collector is dead" back
|
|
1450
|
+
// into "the collector is busy".
|
|
1451
|
+
let considered = 0;
|
|
1148
1452
|
for (const ref of slice) {
|
|
1453
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1454
|
+
// PROGRESS COUNTS SESSIONS CONSIDERED, not sessions shipped.
|
|
1455
|
+
//
|
|
1456
|
+
// Reporting `walked` (the shipped count) looked right and was useless: on an
|
|
1457
|
+
// already-synced corpus the ledger skips ~15,700 of 15,727 sessions, so the
|
|
1458
|
+
// report sat at "0 of 15,727 (0%)" for the whole walk and then vanished.
|
|
1459
|
+
// What a waiting user needs to know is how much of the corpus has been
|
|
1460
|
+
// LOOKED AT, which is this counter and matches the denominator.
|
|
1461
|
+
considered++;
|
|
1462
|
+
// complete is `considered === slice.length`, not a constant false.
|
|
1463
|
+
//
|
|
1464
|
+
// The post-loop report below sets complete:true, but it lands after the last
|
|
1465
|
+
// upload has gone out, so it only ships on the NEXT post — and a walk with
|
|
1466
|
+
// nothing new to upload never makes one. The server was therefore left
|
|
1467
|
+
// holding done === total with complete:false forever, and the UI read that
|
|
1468
|
+
// as "syncing 99%" until something else happened to sync.
|
|
1469
|
+
const walkDone = considered >= slice.length;
|
|
1470
|
+
reportWalkProgress({ done: considered, total: slice.length, startedAt: walkStartedAt, complete: walkDone });
|
|
1471
|
+
walkProgressForUpload = { done: considered, total: slice.length, complete: walkDone };
|
|
1149
1472
|
if (trace && ++traceN % 500 === 0)
|
|
1150
1473
|
trace(`walk ${traceN}/${slice.length} · at ${ref.prefixedId}`);
|
|
1151
1474
|
localSessionIds.add(ref.prefixedId);
|
|
@@ -1293,8 +1616,32 @@ async function syncToTarget(cred, opts = {}) {
|
|
|
1293
1616
|
if (upload.sessionMeta && sessionFileBytes(ref) <= FULL_BUILD_MAX_BYTES) {
|
|
1294
1617
|
try {
|
|
1295
1618
|
const count = { redactions: 0 };
|
|
1296
|
-
|
|
1297
|
-
|
|
1619
|
+
// OFF THE MAIN THREAD. This is the heaviest work in the walk: replay,
|
|
1620
|
+
// outcome and turns each read the whole transcript and shell out to git,
|
|
1621
|
+
// measured at ~5 seconds per session against 78ms for the secret scan.
|
|
1622
|
+
// It was the entire remaining tail of a walk — 15,500 sessions skip in
|
|
1623
|
+
// seconds, then the couple of hundred that need rebuilding take minutes,
|
|
1624
|
+
// all of it on the thread that has to answer signals.
|
|
1625
|
+
//
|
|
1626
|
+
// Only an id crosses the boundary; the worker reads the transcript
|
|
1627
|
+
// itself. Null means the pool could not take it, and the inline path —
|
|
1628
|
+
// the code that shipped before this — runs instead.
|
|
1629
|
+
const offloaded = await scanPool().runDerived({
|
|
1630
|
+
prefixedId: ref.prefixedId, toolId: ref.toolId, mtime,
|
|
1631
|
+
maxRowBytes: MAX_DERIVED_ROW_BYTES,
|
|
1632
|
+
pack: lastServerPackSpec && scanPool().needsPack(lastServerPackSpec.version)
|
|
1633
|
+
? { version: lastServerPackSpec.version, rules: lastServerPackSpec.rules }
|
|
1634
|
+
: undefined,
|
|
1635
|
+
packVersion: lastServerPackSpec?.version ?? '',
|
|
1636
|
+
});
|
|
1637
|
+
if (offloaded?.rows) {
|
|
1638
|
+
redactions += offloaded.redactions;
|
|
1639
|
+
await derivedBatch.add(offloaded.rows);
|
|
1640
|
+
}
|
|
1641
|
+
else {
|
|
1642
|
+
await derivedBatch.add(collectDerivedRows(ref, mtime, count, MAX_DERIVED_ROW_BYTES));
|
|
1643
|
+
redactions += count.redactions;
|
|
1644
|
+
}
|
|
1298
1645
|
}
|
|
1299
1646
|
catch (err) {
|
|
1300
1647
|
// Derived is best-effort — the conversation still shipped — but it must
|
|
@@ -1495,6 +1842,12 @@ async function syncToTarget(cred, opts = {}) {
|
|
|
1495
1842
|
}
|
|
1496
1843
|
if (opts.prune)
|
|
1497
1844
|
await post({ prune_empty_sessions: true });
|
|
1845
|
+
// The walk finished. Marking it complete is what stops every later reader
|
|
1846
|
+
// showing a frozen "8,400 of 15,724" forever — and it is also how a reader
|
|
1847
|
+
// tells a finished walk from a daemon that died mid-walk, which is the state
|
|
1848
|
+
// an in-progress value left behind means.
|
|
1849
|
+
reportWalkProgress({ done: slice.length, total: slice.length, startedAt: walkStartedAt, complete: true });
|
|
1850
|
+
walkProgressForUpload = { done: slice.length, total: slice.length, complete: true };
|
|
1498
1851
|
return {
|
|
1499
1852
|
uploaded: convBatch.sent + appendResults.uploaded, skipped, redactions,
|
|
1500
1853
|
items: itemsBatch.sent, links: linksBatch.sent, findings: findingsBatch.sent, derived: derivedBatch.sent,
|
|
@@ -1580,18 +1933,53 @@ export async function buildConversationSync(ref, mtime, opts = {}) {
|
|
|
1580
1933
|
// is what stops a resume-truncated or mtime-touched session from being
|
|
1581
1934
|
// fully rebuilt on every tick (the export/read still happens — it's how we
|
|
1582
1935
|
// know — but everything expensive after it is skipped).
|
|
1936
|
+
// Build the container ONCE. The hash is needed before the shadow is
|
|
1937
|
+
// touched (a content match short-circuits the whole session), and
|
|
1938
|
+
// updateShadow used to build a second identical copy from the same
|
|
1939
|
+
// export — a wasted copy of every byte on a 47 MB transcript.
|
|
1940
|
+
let built;
|
|
1583
1941
|
if (exp) {
|
|
1584
1942
|
try {
|
|
1585
|
-
|
|
1943
|
+
built = buildRawContainer(exp);
|
|
1944
|
+
srcHash = containerSrcHash(built);
|
|
1586
1945
|
}
|
|
1587
1946
|
catch {
|
|
1947
|
+
built = undefined;
|
|
1588
1948
|
srcHash = undefined;
|
|
1589
1949
|
}
|
|
1590
1950
|
if (srcHash && opts.priorContentHash && srcHash === opts.priorContentHash) {
|
|
1591
1951
|
return { unchanged: true, srcHash };
|
|
1592
1952
|
}
|
|
1953
|
+
// THE SIZE GUARD, MEASURED ON WHAT WE ACTUALLY HAVE.
|
|
1954
|
+
//
|
|
1955
|
+
// `oversized` above is decided from sessionFileBytes(), which returns 0
|
|
1956
|
+
// for any backend that is not append-only — OpenCode implements neither
|
|
1957
|
+
// fileSize() nor isAppendOnly(), because its sessions live in SQLite and
|
|
1958
|
+
// have no file at all. So for those the ceiling never fired and a huge
|
|
1959
|
+
// session took the FULL path: replay and computeOutcome each re-read the
|
|
1960
|
+
// whole thing, and a 353 MB session with 100 MB JSON lines OOMed the walk.
|
|
1961
|
+
//
|
|
1962
|
+
// The export has already happened by here, and it is bounded by the
|
|
1963
|
+
// session's real size. What this protects is everything AFTER it — the
|
|
1964
|
+
// scan, the redact, the git replay, the derived compute — which is where
|
|
1965
|
+
// the memory actually went. Measuring the container closes the guard for
|
|
1966
|
+
// every backend, including any whose fileSize() under-reports.
|
|
1967
|
+
const containerBytes = built
|
|
1968
|
+
? built.files.reduce((n, f) => n + Buffer.byteLength(f.text), 0)
|
|
1969
|
+
: 0;
|
|
1970
|
+
if (containerBytes > FULL_BUILD_MAX_BYTES) {
|
|
1971
|
+
console.error(`[sync] ${ref.prefixedId} is ${(containerBytes / 1048576).toFixed(0)}MB `
|
|
1972
|
+
+ `(over the ${(FULL_BUILD_MAX_BYTES / 1048576).toFixed(0)}MB ceiling) — shipping without `
|
|
1973
|
+
+ 'the raw archive and derived data to keep the walk in memory');
|
|
1974
|
+
// Size and tool only. Knowing that customers hit this, and how big
|
|
1975
|
+
// their sessions get, is how the ceiling gets set from evidence rather
|
|
1976
|
+
// than from one developer's corpus.
|
|
1977
|
+
record({ kind: 'oversized_session', mb: Math.round(containerBytes / 1048576), tool: ref.toolId });
|
|
1978
|
+
includeRaw = false;
|
|
1979
|
+
includeMeta = false;
|
|
1980
|
+
}
|
|
1593
1981
|
}
|
|
1594
|
-
const shadow = updateShadow(ref.rawId, exp);
|
|
1982
|
+
const shadow = updateShadow(ref.rawId, exp, built);
|
|
1595
1983
|
if (shadow.container) {
|
|
1596
1984
|
fullestContainer = shadow.container;
|
|
1597
1985
|
if (shadow.status === 'rewrite-merged' && shadow.recovered > 0) {
|
|
@@ -1705,11 +2093,42 @@ export async function buildConversationSync(ref, mtime, opts = {}) {
|
|
|
1705
2093
|
// applied to the raw text on the client so unredacted secrets stay local.
|
|
1706
2094
|
const findings = [];
|
|
1707
2095
|
let scanMs = 0;
|
|
2096
|
+
// OFF THE MAIN THREAD. Scan + redact + gzip + base64 are ~98% of the CPU this
|
|
2097
|
+
// function burns (measured: 31.2 s over 200 MB of real sessions) and all four
|
|
2098
|
+
// touch the same text, so one worker task carries the text in and returns only
|
|
2099
|
+
// small results — findings, a redaction count and the compressed archive. The
|
|
2100
|
+
// 30 MB string never comes back, which is what keeps the transfer at 1.8% of
|
|
2101
|
+
// the work moved.
|
|
2102
|
+
//
|
|
2103
|
+
// Null means "not offloaded" for any reason at all (pool disabled, no worker
|
|
2104
|
+
// bundle, worker died, task threw). Every use below falls back to the inline
|
|
2105
|
+
// code that shipped before this existed.
|
|
2106
|
+
let offloaded = null;
|
|
2107
|
+
if (container) {
|
|
2108
|
+
const pool = scanPool();
|
|
2109
|
+
if (pool.enabled) {
|
|
2110
|
+
const packVersion = lastServerPackSpec?.version ?? '';
|
|
2111
|
+
const sendPack = lastServerPackSpec !== null && pool.needsPack(packVersion);
|
|
2112
|
+
offloaded = await pool.run({
|
|
2113
|
+
files: container.files.map((f) => ({ name: f.name, text: f.text })),
|
|
2114
|
+
container: { v: 1, tool: container.tool, mtime: container.mtime, srcHash: container.srcHash },
|
|
2115
|
+
includeRaw,
|
|
2116
|
+
maxRawBytes: 8 * 1024 * 1024,
|
|
2117
|
+
pack: sendPack && lastServerPackSpec
|
|
2118
|
+
? { version: packVersion, rules: lastServerPackSpec.rules }
|
|
2119
|
+
: undefined,
|
|
2120
|
+
packVersion,
|
|
2121
|
+
});
|
|
2122
|
+
if (offloaded && sendPack)
|
|
2123
|
+
pool.markPackSent(packVersion);
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
1708
2126
|
if (container) {
|
|
1709
2127
|
const t0 = performance.now();
|
|
1710
2128
|
const rawText = container.files.map((f) => f.text).join('\n');
|
|
1711
2129
|
try {
|
|
1712
|
-
|
|
2130
|
+
const builtin = offloaded ? offloaded.findings : scanTextForFindings(rawText);
|
|
2131
|
+
for (const f of builtin) {
|
|
1713
2132
|
findings.push({ session_id: ref.prefixedId, detector: 'builtin', rule: f.rule, line: f.line, preview: f.preview });
|
|
1714
2133
|
}
|
|
1715
2134
|
}
|
|
@@ -1791,17 +2210,27 @@ export async function buildConversationSync(ref, mtime, opts = {}) {
|
|
|
1791
2210
|
let raw_b64;
|
|
1792
2211
|
let raw_size;
|
|
1793
2212
|
if (includeRaw && container) {
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
2213
|
+
if (offloaded) {
|
|
2214
|
+
// The worker already redacted, gzipped and encoded it. An absent rawB64
|
|
2215
|
+
// means the archive exceeded the 8 MB ceiling — the same decision the
|
|
2216
|
+
// inline path makes, taken in the worker so the bytes never travel.
|
|
2217
|
+
count.redactions += offloaded.redactions;
|
|
2218
|
+
raw_b64 = offloaded.rawB64;
|
|
2219
|
+
raw_size = offloaded.rawSize;
|
|
2220
|
+
}
|
|
2221
|
+
else {
|
|
2222
|
+
try {
|
|
2223
|
+
const count2 = { redactions: 0 };
|
|
2224
|
+
const redacted = mapContainerText(container, (t) => redactSecrets(t, { force: true, count: count2 }));
|
|
2225
|
+
count.redactions += count2.redactions;
|
|
2226
|
+
const { gz, size } = gzipContainer(redacted);
|
|
2227
|
+
if (gz.length <= 8 * 1024 * 1024) {
|
|
2228
|
+
raw_b64 = gz.toString('base64');
|
|
2229
|
+
raw_size = size;
|
|
2230
|
+
}
|
|
1802
2231
|
}
|
|
2232
|
+
catch { /* raw is additive — conversation still ships */ }
|
|
1803
2233
|
}
|
|
1804
|
-
catch { /* raw is additive — conversation still ships */ }
|
|
1805
2234
|
}
|
|
1806
2235
|
const envTexts = envelope.messages.filter((m) => m.content?.trim());
|
|
1807
2236
|
const redactedText = envTexts.map((m) => m.content).join('\n');
|
|
@@ -1933,90 +2362,6 @@ export async function buildConversationTail(ref, fromOffset, opts = {}) {
|
|
|
1933
2362
|
findings,
|
|
1934
2363
|
};
|
|
1935
2364
|
}
|
|
1936
|
-
/**
|
|
1937
|
-
* Per-session derived payload: the four compute kinds the server's deep-dive
|
|
1938
|
-
* routes read (diff/outcome/commits/markers) + the outcome-badge row. ALWAYS
|
|
1939
|
-
* computed live from the transcript/git (no local compute/outcome cache is
|
|
1940
|
-
* read or warmed — thin collector). Everything is deep-redacted — diffs carry
|
|
1941
|
-
* file content.
|
|
1942
|
-
*/
|
|
1943
|
-
function collectDerived(ref, mtime, count) {
|
|
1944
|
-
const id = ref.prefixedId;
|
|
1945
|
-
const compute = [];
|
|
1946
|
-
let fullOutcome = null;
|
|
1947
|
-
// One read of this transcript instead of four. replay, computeOutcome (twice
|
|
1948
|
-
// internally) and extractTurns each re-read the whole file; the scope holds
|
|
1949
|
-
// exactly one session's text and drops it on the way out.
|
|
1950
|
-
return withSessionReadCache(() => {
|
|
1951
|
-
for (const kind of COMPUTE_KINDS) {
|
|
1952
|
-
let data = null;
|
|
1953
|
-
try {
|
|
1954
|
-
if (kind === 'diff') {
|
|
1955
|
-
const replay = replaySessionAny(id);
|
|
1956
|
-
if (replay.found)
|
|
1957
|
-
data = replay;
|
|
1958
|
-
}
|
|
1959
|
-
else if (kind === 'outcome') {
|
|
1960
|
-
fullOutcome = computeOutcome(id);
|
|
1961
|
-
if (fullOutcome.found)
|
|
1962
|
-
data = fullOutcome;
|
|
1963
|
-
}
|
|
1964
|
-
else if (kind === 'commits') {
|
|
1965
|
-
// computeOutcome already ran git for the session window — reuse.
|
|
1966
|
-
if (!fullOutcome)
|
|
1967
|
-
fullOutcome = computeOutcome(id);
|
|
1968
|
-
if (fullOutcome.found)
|
|
1969
|
-
data = fullOutcome.commits;
|
|
1970
|
-
}
|
|
1971
|
-
else if (kind === 'markers') {
|
|
1972
|
-
// Analysis-only use of the turns extractor (R4) — markers need
|
|
1973
|
-
// per-prompt line/ts, not render fidelity.
|
|
1974
|
-
const turns = extractTurnsAny(id, { maxTurns: 50_000 });
|
|
1975
|
-
const prompts = turns.turns
|
|
1976
|
-
.filter((t) => t.kind === 'user' && t.text)
|
|
1977
|
-
.map((t) => ({ line: t.line, ts: t.ts, tsIso: t.tsIso, ...markPrompt(t.text) }));
|
|
1978
|
-
data = { sessionId: id, prompts, summary: summarizeMarkers(prompts) };
|
|
1979
|
-
}
|
|
1980
|
-
}
|
|
1981
|
-
catch {
|
|
1982
|
-
data = null;
|
|
1983
|
-
}
|
|
1984
|
-
if (data === null)
|
|
1985
|
-
continue;
|
|
1986
|
-
const redacted = redactDeep(data, count);
|
|
1987
|
-
if (JSON.stringify(redacted).length > MAX_DERIVED_ROW_BYTES)
|
|
1988
|
-
continue;
|
|
1989
|
-
compute.push({ kind, mtime, data: redacted });
|
|
1990
|
-
}
|
|
1991
|
-
// Outcome-badge row (session_outcome_cache) — what the list badges read.
|
|
1992
|
-
// Computed live from the outcome we already ran above.
|
|
1993
|
-
let outcomeRow = null;
|
|
1994
|
-
if (fullOutcome?.found) {
|
|
1995
|
-
outcomeRow = {
|
|
1996
|
-
sessionId: id,
|
|
1997
|
-
tool: ref.toolId,
|
|
1998
|
-
status: fullOutcome.status,
|
|
1999
|
-
reason: fullOutcome.reason,
|
|
2000
|
-
fileMtime: mtime,
|
|
2001
|
-
fileSize: 0,
|
|
2002
|
-
contentHash: '',
|
|
2003
|
-
fileCount: fullOutcome.fileCount,
|
|
2004
|
-
linesAdded: fullOutcome.totalLinesAdded,
|
|
2005
|
-
linesRemoved: fullOutcome.totalLinesRemoved,
|
|
2006
|
-
commits: fullOutcome.commits?.totalCommits ?? 0,
|
|
2007
|
-
isFull: true,
|
|
2008
|
-
classifiedAt: Date.now(),
|
|
2009
|
-
lastScannedOffset: 0,
|
|
2010
|
-
};
|
|
2011
|
-
}
|
|
2012
|
-
return {
|
|
2013
|
-
session_id: id,
|
|
2014
|
-
mtime,
|
|
2015
|
-
compute,
|
|
2016
|
-
outcome_row: outcomeRow ? redactDeep({ ...outcomeRow, sessionId: undefined }, count) : null,
|
|
2017
|
-
};
|
|
2018
|
-
});
|
|
2019
|
-
}
|
|
2020
2365
|
export function isSyncSkip(r) {
|
|
2021
2366
|
return typeof r.skipped === 'string';
|
|
2022
2367
|
}
|