chat-recall 0.5.5 → 0.5.7
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 +11473 -8310
- 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-diagnostics.d.ts.map +1 -1
- package/dist/mcp-diagnostics.js +10 -8
- package/dist/mcp-diagnostics.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +5763 -3045
- package/dist/mcp.js.map +1 -1
- package/dist/project-tasks.d.ts.map +1 -1
- package/dist/project-tasks.js +13 -8
- 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/service-installer.d.ts +1 -1
- package/dist/service-installer.d.ts.map +1 -1
- package/dist/service-installer.js +20 -6
- package/dist/service-installer.js.map +1 -1
- package/dist/sync-client.d.ts +25 -0
- package/dist/sync-client.d.ts.map +1 -1
- package/dist/sync-client.js +590 -176
- package/dist/sync-client.js.map +1 -1
- package/dist/sync-ledger.d.ts +22 -0
- package/dist/sync-ledger.d.ts.map +1 -1
- package/dist/sync-ledger.js +108 -1
- package/dist/sync-ledger.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 +8887 -6028
- package/hooks/chat_recall_escalate_hook.sh +28 -2
- package/hooks/chat_recall_save_hook.sh +17 -11
- package/hooks/chat_recall_wakeup_hook.sh +27 -1
- package/package.json +27 -4
package/dist/sync-client.js
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
* server owns them.
|
|
32
32
|
*/
|
|
33
33
|
import { join, dirname, basename } from 'node:path';
|
|
34
|
+
import { envCredentials } from '@chat-recall/engine/core/credentials-env.js';
|
|
34
35
|
import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, chmodSync, unlinkSync, readdirSync, statSync } from 'node:fs';
|
|
35
36
|
import { tmpdir, hostname } from 'node:os';
|
|
36
37
|
import { createHash } from 'node:crypto';
|
|
@@ -44,13 +45,11 @@ import { dropFuzzyFindings } from '@chat-recall/engine/core/secret-precision.js'
|
|
|
44
45
|
import { loadSettings, saveSettings, isProjectSyncable } from '@chat-recall/engine/core/settings.js';
|
|
45
46
|
import { getDataDir } from '@chat-recall/engine/core/paths.js';
|
|
46
47
|
import { listAvailableBackends } from '@chat-recall/engine/core/tool-backend.js';
|
|
47
|
-
import { extractTurnsAny, replaySessionAny } from '@chat-recall/engine/core/session-multi-tool.js';
|
|
48
48
|
import { parseTranscript, trimTranscriptForSync, TRANSCRIPT_VERSION, gzipContainer, mapContainerText, buildRawContainer, containerSrcHash, parseTranscriptFromContainer, updateShadow } from '@chat-recall/engine/transcript/index.js';
|
|
49
49
|
import { parseClaudeTranscriptText } from '@chat-recall/engine/transcript/claude.js';
|
|
50
50
|
import { parseGeminiTranscriptText } from '@chat-recall/engine/transcript/gemini.js';
|
|
51
51
|
import { parseCodexTranscriptText } from '@chat-recall/engine/transcript/codex.js';
|
|
52
52
|
import { getBackendForId, getBackend } from '@chat-recall/engine/core/tool-backend.js';
|
|
53
|
-
import { computeOutcome } from '@chat-recall/engine/core/session-outcome.js';
|
|
54
53
|
import { fileURLToPath } from 'node:url';
|
|
55
54
|
let ownVersion = '0.0.0';
|
|
56
55
|
try {
|
|
@@ -65,16 +64,70 @@ catch {
|
|
|
65
64
|
}
|
|
66
65
|
catch { }
|
|
67
66
|
}
|
|
68
|
-
import { markPrompt, summarizeMarkers } from '@chat-recall/engine/core/session-sentiment.js';
|
|
69
67
|
import { parseSessionFile, readCwdFromJsonl } from '@chat-recall/engine/parsers/session.js';
|
|
70
68
|
import { resolveProjectId } from '@chat-recall/engine/core/project-resolver.js';
|
|
71
69
|
import { extractEntities } from '@chat-recall/engine/core/entity-extractor.js';
|
|
72
70
|
import { buildSourceRegistry } from '@chat-recall/engine/parsers/all-sources.js';
|
|
73
|
-
import { getSyncedRows, markSynced, getLedgerData, persistLedgerData, fieldNeedsScan, markFieldCoverage, fieldNeedsFullPass, markFieldFullPassDone, syncMode, markFullResync, loadItemVersions, saveItemVersions } from './sync-ledger.js';
|
|
71
|
+
import { getSyncedRows, markSynced, getLedgerData, persistLedgerData, fieldNeedsScan, markFieldCoverage, fieldNeedsFullPass, markFieldFullPassDone, syncMode, markFullResync, loadItemVersions, saveItemVersions, flushLedger, pruneLedgerTargets } from './sync-ledger.js';
|
|
74
72
|
import { extractorVersionForTool, extractorVersionForId, toolOfId, EXTRACTOR_VERSION } from '@chat-recall/engine/core/extractor-version.js';
|
|
75
73
|
import { SYNC_FIELDS } from '@chat-recall/engine/core/sync-fields.js';
|
|
76
74
|
import { acquireIndexLock } from '@chat-recall/engine/core/index-lock.js';
|
|
77
75
|
import { fetchWithTimeout } from './http.js';
|
|
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';
|
|
78
131
|
import '@chat-recall/engine/core/backends/index.js'; // register the tool backends
|
|
79
132
|
// Lives under the data dir so CHAT_RECALL_DATA_DIR isolates credentials the
|
|
80
133
|
// same way it isolates the index (tests, multi-profile setups).
|
|
@@ -86,6 +139,10 @@ const credPath = () => join(getDataDir(), 'credentials.json');
|
|
|
86
139
|
* form still readable.
|
|
87
140
|
*/
|
|
88
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);
|
|
89
146
|
mkdirSync(dirname(credPath()), { recursive: true });
|
|
90
147
|
const targets = loadAllCredentials().filter((t) => t.serverUrl !== c.serverUrl);
|
|
91
148
|
// Newest login FIRST: single-target consumers (loadCredentials → targets[0],
|
|
@@ -107,6 +164,12 @@ export function saveCredentials(c) {
|
|
|
107
164
|
catch { /* settings unwritable — manual `chat-recall sync` still works */ }
|
|
108
165
|
}
|
|
109
166
|
export function loadAllCredentials() {
|
|
167
|
+
// A container, a CI job or a headless daemon has no credentials.json. When
|
|
168
|
+
// the environment carries an explicit token it IS the login — see
|
|
169
|
+
// credentials-env.ts for why the file is not consulted in that case.
|
|
170
|
+
const fromEnv = envCredentials();
|
|
171
|
+
if (fromEnv)
|
|
172
|
+
return [{ serverUrl: fromEnv.serverUrl, token: fromEnv.token }];
|
|
110
173
|
try {
|
|
111
174
|
const parsed = JSON.parse(readFileSync(credPath(), 'utf-8'));
|
|
112
175
|
// token may be '' for a no-auth local self-host server (AUTH_PROVIDER=none) —
|
|
@@ -156,6 +219,10 @@ async function fetchTenantSecurityConfig(cred) {
|
|
|
156
219
|
const packSpec = body.pack?.rules
|
|
157
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 })) }
|
|
158
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 };
|
|
159
226
|
const pack = installServerRulePack(packSpec);
|
|
160
227
|
rulePackVersion = serverRulePackVersion();
|
|
161
228
|
for (const r of pack.rejected) {
|
|
@@ -173,6 +240,19 @@ async function fetchTenantSecurityConfig(cred) {
|
|
|
173
240
|
const body = await settingsRes.json().catch(() => ({}));
|
|
174
241
|
if (typeof body.verifySecrets === 'boolean')
|
|
175
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
|
+
}
|
|
176
256
|
}
|
|
177
257
|
const config = { tenantRules, verifySecrets, rulePackVersion };
|
|
178
258
|
_securityConfigCache.set(base, { fetchedAt: Date.now(), config });
|
|
@@ -288,22 +368,6 @@ export function removeCredentials(serverUrl) {
|
|
|
288
368
|
/** Tokenize an absolute project path so the server can group by project
|
|
289
369
|
* without learning the developer's filesystem layout. */
|
|
290
370
|
const hashPath = (p) => (p ? 'p_' + createHash('sha256').update(p).digest('hex').slice(0, 12) : '');
|
|
291
|
-
/** Redact every string anywhere inside a JSON-serializable value. Used for
|
|
292
|
-
* derived payloads (diffs carry file content; outcomes carry prompt text)
|
|
293
|
-
* where field-by-field redaction would be fragile. */
|
|
294
|
-
function redactDeep(v, count) {
|
|
295
|
-
if (typeof v === 'string')
|
|
296
|
-
return redactSecrets(v, { force: true, count });
|
|
297
|
-
if (Array.isArray(v))
|
|
298
|
-
return v.map((x) => redactDeep(x, count));
|
|
299
|
-
if (v && typeof v === 'object') {
|
|
300
|
-
const o = {};
|
|
301
|
-
for (const [k, val] of Object.entries(v))
|
|
302
|
-
o[k] = redactDeep(val, count);
|
|
303
|
-
return o;
|
|
304
|
-
}
|
|
305
|
-
return v;
|
|
306
|
-
}
|
|
307
371
|
/** compute_cache kinds shipped to the server. Must stay in step with the
|
|
308
372
|
* kinds the server's conversation routes read (heavyCacheGet keys). */
|
|
309
373
|
const COMPUTE_KINDS = ['diff', 'outcome', 'commits', 'markers'];
|
|
@@ -316,35 +380,44 @@ const MAX_DERIVED_ROW_BYTES = 2 * 1024 * 1024;
|
|
|
316
380
|
* is no shared-tenant or rate-limiter to be polite to, so they get a higher
|
|
317
381
|
* in-flight cap and zero inter-batch sleep. Remote/SaaS hosts stay gentler.
|
|
318
382
|
*/
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '0.0.0.0')
|
|
328
|
-
return true;
|
|
329
|
-
if (host.endsWith('.local') || host.endsWith('.lan'))
|
|
330
|
-
return true;
|
|
331
|
-
if (/^10\./.test(host))
|
|
332
|
-
return true; // 10.0.0.0/8
|
|
333
|
-
if (/^192\.168\./.test(host))
|
|
334
|
-
return true; // 192.168.0.0/16
|
|
335
|
-
if (/^172\.(1[6-9]|2\d|3[01])\./.test(host))
|
|
336
|
-
return true; // 172.16.0.0/12
|
|
337
|
-
return false;
|
|
338
|
-
}
|
|
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;
|
|
339
391
|
export async function syncSessions(opts = {}) {
|
|
340
392
|
const targets = loadAllCredentials();
|
|
341
393
|
if (targets.length === 0)
|
|
342
394
|
throw new Error('Not logged in — run `chat-recall login <server-url> --token <token>`');
|
|
395
|
+
// Once per walk: forget servers we no longer sync to, so their rows stop
|
|
396
|
+
// riding along in every write of this single-file ledger.
|
|
397
|
+
try {
|
|
398
|
+
pruneLedgerTargets(targets.map((t) => t.serverUrl));
|
|
399
|
+
}
|
|
400
|
+
catch { /* never block a sync on housekeeping */ }
|
|
343
401
|
let agg = null;
|
|
344
402
|
const errors = [];
|
|
403
|
+
const skippedTargets = [];
|
|
345
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
|
+
}
|
|
346
418
|
try {
|
|
347
419
|
const r = await syncToTarget(cred, opts);
|
|
420
|
+
noteTargetSuccess(cred.serverUrl);
|
|
348
421
|
agg = agg ? {
|
|
349
422
|
uploaded: agg.uploaded + r.uploaded, skipped: agg.skipped + r.skipped, redactions: agg.redactions + r.redactions,
|
|
350
423
|
items: agg.items + r.items, links: agg.links + r.links, findings: agg.findings + r.findings,
|
|
@@ -353,9 +426,59 @@ export async function syncSessions(opts = {}) {
|
|
|
353
426
|
} : r;
|
|
354
427
|
}
|
|
355
428
|
catch (e) {
|
|
356
|
-
|
|
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
|
+
});
|
|
357
446
|
}
|
|
358
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
|
+
}
|
|
455
|
+
// Ledger writes are coalesced during the walk (see persist() in sync-ledger).
|
|
456
|
+
// This is the durability point: everything acked above reaches disk before we
|
|
457
|
+
// report success, so a crash after this cannot re-ship what the server has.
|
|
458
|
+
// In the finally, because a partial walk's acks are still worth keeping.
|
|
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(() => { });
|
|
359
482
|
if (!agg)
|
|
360
483
|
throw new Error(`sync failed for all targets:\n ${errors.join('\n ')}`);
|
|
361
484
|
if (errors.length > 0)
|
|
@@ -582,7 +705,95 @@ export function batchScanExternal(items, opts = {}) {
|
|
|
582
705
|
}
|
|
583
706
|
return { findings, scanned, slices, scanMs };
|
|
584
707
|
}
|
|
708
|
+
/**
|
|
709
|
+
* Server API version check, cached per target for an hour.
|
|
710
|
+
*
|
|
711
|
+
* Only SUCCESS is cached. A server that is down, or too old, is re-checked on
|
|
712
|
+
* the next tick — caching a failure would turn a thirty-second outage into an
|
|
713
|
+
* hour of refusing to sync.
|
|
714
|
+
*/
|
|
715
|
+
const CAPS_TTL_MS = 60 * 60_000;
|
|
716
|
+
const capsOk = new Map(); // serverUrl -> epoch ms verified
|
|
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
|
+
}
|
|
749
|
+
async function verifyServerApi(serverUrl, minApi) {
|
|
750
|
+
const base = serverUrl.replace(/\/$/, '');
|
|
751
|
+
const seen = capsOk.get(base);
|
|
752
|
+
if (seen !== undefined && Date.now() - seen < CAPS_TTL_MS)
|
|
753
|
+
return true;
|
|
754
|
+
const caps = await fetchWithTimeout(`${base}/api/capabilities`).then((r) => r.json());
|
|
755
|
+
if ((caps.apiVersion ?? 0) < minApi) {
|
|
756
|
+
throw new Error(`server too old: apiVersion ${caps.apiVersion ?? 'none'} < required ${minApi} — update the server image before syncing`);
|
|
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);
|
|
770
|
+
capsOk.set(base, Date.now());
|
|
771
|
+
return true;
|
|
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
|
+
*/
|
|
585
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);
|
|
586
797
|
const sync = loadSettings().sync;
|
|
587
798
|
// Exclusions = local settings ∪ server-side tenant config (dashboard-edited).
|
|
588
799
|
const serverCfg = await fetchTenantSyncConfig(cred);
|
|
@@ -618,16 +829,18 @@ async function syncToTarget(cred, opts = {}) {
|
|
|
618
829
|
// server ignores payload fields it doesn't know — which looks like
|
|
619
830
|
// success and produces gutted data (lived experience, June 2026).
|
|
620
831
|
const MIN_SERVER_API = 2;
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
832
|
+
{
|
|
833
|
+
// Cached for an hour per target. This ran on EVERY sync — every file-change
|
|
834
|
+
// debounce, every heartbeat, per target — to re-learn a number that changes
|
|
835
|
+
// only when the server is redeployed. The check itself stays: refusing to
|
|
836
|
+
// sync blind is the point, and an old server silently ignoring payload fields
|
|
837
|
+
// is how gutted data shipped in June 2026. Only the frequency changes.
|
|
838
|
+
//
|
|
839
|
+
// A FAILURE IS NEVER CACHED. An unreachable server must be retried on the
|
|
840
|
+
// next tick, not written off for an hour.
|
|
841
|
+
const ok = await verifyServerApi(cred.serverUrl, MIN_SERVER_API);
|
|
842
|
+
if (!ok)
|
|
843
|
+
throw new Error(`cannot verify server version — refusing to sync blind`);
|
|
631
844
|
}
|
|
632
845
|
// Fetch tenant-specific secret-scan configuration from the server. Rules are
|
|
633
846
|
// configured in the dashboard and must be applied to raw session text on the
|
|
@@ -677,7 +890,21 @@ async function syncToTarget(cred, opts = {}) {
|
|
|
677
890
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
678
891
|
const explicitThrottle = typeof opts.throttleMs === 'number' && opts.throttleMs > 0;
|
|
679
892
|
const throttleMs = explicitThrottle ? opts.throttleMs : 0;
|
|
680
|
-
|
|
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);
|
|
681
908
|
// Per-attempt upload timeout. WITHOUT this, a half-open/slow connection makes
|
|
682
909
|
// `fetch` hang FOREVER (Node fetch has no default timeout) — observed as a
|
|
683
910
|
// sync stuck for 20-40min at idle CPU with zero progress. With it, a hung
|
|
@@ -686,8 +913,41 @@ async function syncToTarget(cred, opts = {}) {
|
|
|
686
913
|
// Single POST with retry. Retryable: network/abort errors, HTTP 429 (obeying
|
|
687
914
|
// Retry-After — the server's own rate signal), and 5xx. Non-429 4xx (bad
|
|
688
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);
|
|
689
932
|
const post = async (body) => {
|
|
690
|
-
|
|
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
|
+
})();
|
|
691
951
|
const RETRY_DELAYS_MS = [2000, 8000, 30000];
|
|
692
952
|
for (let attempt = 0;; attempt++) {
|
|
693
953
|
const ac = new AbortController();
|
|
@@ -697,14 +957,38 @@ async function syncToTarget(cred, opts = {}) {
|
|
|
697
957
|
method: 'POST',
|
|
698
958
|
// No token ⇒ no auth header: a no-auth local server (AUTH_PROVIDER=none)
|
|
699
959
|
// accepts it as the single 'default' tenant.
|
|
700
|
-
headers:
|
|
701
|
-
|
|
702
|
-
|
|
960
|
+
headers: {
|
|
961
|
+
'content-type': 'application/json',
|
|
962
|
+
...(gzipBody ? { 'content-encoding': 'gzip' } : {}),
|
|
963
|
+
...(cred.token ? { authorization: `Bearer ${cred.token}` } : {}),
|
|
964
|
+
},
|
|
703
965
|
body: payload,
|
|
704
966
|
signal: ac.signal,
|
|
705
967
|
});
|
|
706
968
|
if (res.ok) {
|
|
707
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
|
+
}
|
|
708
992
|
if (body.cli && body.cli.version) {
|
|
709
993
|
const { planAutoUpdate, runAutoUpdate } = await import('./auto-update.js');
|
|
710
994
|
const authHeaders = cred.token ? { authorization: `Bearer ${cred.token}` } : {};
|
|
@@ -733,10 +1017,14 @@ async function syncToTarget(cred, opts = {}) {
|
|
|
733
1017
|
try {
|
|
734
1018
|
const body = JSON.parse(text);
|
|
735
1019
|
detail = body.error ?? text;
|
|
1020
|
+
// `detail` carries the part that stops this reading as breakage —
|
|
1021
|
+
// what still works, and the one command that restores the rest.
|
|
1022
|
+
if (body.detail)
|
|
1023
|
+
detail += `\n ${body.detail}`;
|
|
736
1024
|
if (body.resetsAt)
|
|
737
1025
|
detail += ` (resets ${new Date(body.resetsAt).toISOString().slice(0, 10)})`;
|
|
738
1026
|
if (body.upgradeUrl)
|
|
739
|
-
detail +=
|
|
1027
|
+
detail += `\n ${body.upgradeUrl}`;
|
|
740
1028
|
}
|
|
741
1029
|
catch { /* non-JSON 402 — print as-is */ }
|
|
742
1030
|
throw new Error(`sync paused by the server: ${detail}`);
|
|
@@ -776,15 +1064,29 @@ async function syncToTarget(cred, opts = {}) {
|
|
|
776
1064
|
// whole sync (matching the old serial behaviour).
|
|
777
1065
|
const inflight = new Set();
|
|
778
1066
|
let poolError = null;
|
|
779
|
-
|
|
780
|
-
|
|
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)) {
|
|
781
1081
|
await Promise.race(inflight);
|
|
1082
|
+
}
|
|
782
1083
|
if (poolError)
|
|
783
1084
|
throw poolError;
|
|
1085
|
+
inflightBytes += bytesHint;
|
|
784
1086
|
let task;
|
|
785
1087
|
task = (async () => { await post(body); after(); })()
|
|
786
1088
|
.catch((e) => { poolError = poolError ?? e; })
|
|
787
|
-
.finally(() => { inflight.delete(task); });
|
|
1089
|
+
.finally(() => { inflight.delete(task); inflightBytes -= bytesHint; });
|
|
788
1090
|
inflight.add(task);
|
|
789
1091
|
};
|
|
790
1092
|
const drainInflight = async () => {
|
|
@@ -813,7 +1115,12 @@ async function syncToTarget(cred, opts = {}) {
|
|
|
813
1115
|
return {
|
|
814
1116
|
sent: 0,
|
|
815
1117
|
async add(row) {
|
|
816
|
-
|
|
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));
|
|
817
1124
|
if (batch.length > 0 && (batchBytes + size > MAX_BATCH_BYTES || batch.length >= maxItems))
|
|
818
1125
|
await this.drain();
|
|
819
1126
|
batch.push(row);
|
|
@@ -823,11 +1130,14 @@ async function syncToTarget(cred, opts = {}) {
|
|
|
823
1130
|
if (batch.length === 0)
|
|
824
1131
|
return;
|
|
825
1132
|
const rows = flatten ? batch.flat() : batch;
|
|
1133
|
+
const bytes = batchBytes;
|
|
826
1134
|
batch = [];
|
|
827
1135
|
batchBytes = 0;
|
|
828
1136
|
// Hand off to the concurrency pool; `sent`/onFlush (e.g. markSynced)
|
|
829
|
-
// fire only after the server acks THIS batch, same as before.
|
|
830
|
-
|
|
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);
|
|
831
1141
|
},
|
|
832
1142
|
};
|
|
833
1143
|
};
|
|
@@ -954,29 +1264,50 @@ async function syncToTarget(cred, opts = {}) {
|
|
|
954
1264
|
// and redaction of everything shipped is unconditional.
|
|
955
1265
|
const scanSecrets = isSecretScannerAvailable() && upload.findings && process.env.CHAT_RECALL_SCAN_SECRETS !== '0';
|
|
956
1266
|
const verifySecretsScan = verifySecrets;
|
|
957
|
-
|
|
958
|
-
//
|
|
1267
|
+
let skipped = 0, redactions = 0, walked = 0;
|
|
1268
|
+
// ── Live KG accumulator. Triples are extracted on the spot from each item's
|
|
1269
|
+
// redacted text (no local knowledge-graph DB is read). Dedupe by
|
|
959
1270
|
// subject|predicate|object|valid_from; entity types come from `is_a`
|
|
960
1271
|
// triples seen in the same pass, defaulting to 'unknown' otherwise.
|
|
1272
|
+
//
|
|
1273
|
+
// TRIPLES SHIP IMMEDIATELY. They used to pile into an array that was drained
|
|
1274
|
+
// only after the ENTIRE walk — on this developer's box that is 15,700
|
|
1275
|
+
// sessions, so the array grew for the whole run and every row stayed live and
|
|
1276
|
+
// reachable. A full Mark-Compact freed 0 MB at 1602 MB before the daemon
|
|
1277
|
+
// aborted, which is the signature of retention, not of transient copies:
|
|
1278
|
+
// nothing transient survives a mark-compact. Handing each triple to the
|
|
1279
|
+
// batcher as it is produced bounds the resident set to one 4 MB window.
|
|
1280
|
+
//
|
|
1281
|
+
// Two small structures still span the walk, and both are bounded by DISTINCT
|
|
1282
|
+
// entity count rather than by session count:
|
|
1283
|
+
// kgSeen — dedup. Stores a 16-hex digest, because the old key
|
|
1284
|
+
// repeated all three long strings in a fourth string.
|
|
1285
|
+
// kgEntityNames — the names to emit entities for at the end. This is the
|
|
1286
|
+
// only reason the row array was ever kept; a Set of names
|
|
1287
|
+
// it already references costs a pointer, not a row.
|
|
961
1288
|
const kgSeen = new Set();
|
|
962
1289
|
const kgEntityType = new Map();
|
|
963
|
-
const
|
|
964
|
-
const
|
|
1290
|
+
const kgEntityNames = new Set();
|
|
1291
|
+
const kgKey = (subject, predicate, object, validFrom) => createHash('sha1').update(`${subject}|${predicate}|${object}|${validFrom}`).digest('hex').slice(0, 16);
|
|
1292
|
+
const accumulateKg = async (triples, sourceSession) => {
|
|
965
1293
|
for (const t of triples) {
|
|
966
|
-
const key =
|
|
1294
|
+
const key = kgKey(t.subject, t.predicate, t.object, t.validFrom ?? '');
|
|
967
1295
|
if (kgSeen.has(key))
|
|
968
1296
|
continue;
|
|
969
1297
|
kgSeen.add(key);
|
|
970
1298
|
if (t.predicate === 'is_a' && !kgEntityType.has(t.subject))
|
|
971
1299
|
kgEntityType.set(t.subject, t.object);
|
|
972
|
-
|
|
1300
|
+
kgEntityNames.add(t.subject);
|
|
1301
|
+
kgEntityNames.add(t.object);
|
|
1302
|
+
const count = { redactions: 0 };
|
|
1303
|
+
await kgTripleBatch.add(redactDeep({
|
|
973
1304
|
subject: t.subject, predicate: t.predicate, object: t.object,
|
|
974
1305
|
valid_from: t.validFrom ?? null, valid_to: null,
|
|
975
1306
|
confidence: t.confidence ?? 1.0, source_session: sourceSession,
|
|
976
|
-
});
|
|
1307
|
+
}, count));
|
|
1308
|
+
redactions += count.redactions;
|
|
977
1309
|
}
|
|
978
1310
|
};
|
|
979
|
-
let skipped = 0, redactions = 0, walked = 0;
|
|
980
1311
|
let scanMs = 0, scanned = 0; // secret-scan metrics
|
|
981
1312
|
const slice = refs.slice(0, opts.limit ?? refs.length);
|
|
982
1313
|
// CHAT_RECALL_SYNC_TRACE=1: phase + progress markers with heap usage, to
|
|
@@ -1076,8 +1407,68 @@ async function syncToTarget(cred, opts = {}) {
|
|
|
1076
1407
|
trace?.(`batch secret scan done: ${scanned} session(s) in ${batch.slices} slice(s)`);
|
|
1077
1408
|
}
|
|
1078
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 };
|
|
1079
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;
|
|
1080
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 };
|
|
1081
1472
|
if (trace && ++traceN % 500 === 0)
|
|
1082
1473
|
trace(`walk ${traceN}/${slice.length} · at ${ref.prefixedId}`);
|
|
1083
1474
|
localSessionIds.add(ref.prefixedId);
|
|
@@ -1214,7 +1605,7 @@ async function syncToTarget(cred, opts = {}) {
|
|
|
1214
1605
|
// Knowledge graph: extract triples live from the redacted conversation
|
|
1215
1606
|
// text (no local KG DB is read). Accumulated + deduped, flushed at the end.
|
|
1216
1607
|
if (upload.sessionMeta) {
|
|
1217
|
-
accumulateKg(extractEntities(built.kgText, { projectPath: built.projectPath, sourceType: 'session', sessionId: ref.prefixedId, validFrom: ref.mtime ? new Date(ref.mtime).toISOString().slice(0, 10) : undefined }), ref.prefixedId);
|
|
1608
|
+
await accumulateKg(extractEntities(built.kgText, { projectPath: built.projectPath, sourceType: 'session', sessionId: ref.prefixedId, validFrom: ref.mtime ? new Date(ref.mtime).toISOString().slice(0, 10) : undefined }), ref.prefixedId);
|
|
1218
1609
|
}
|
|
1219
1610
|
// Derived data — the CLI has the FS/git/transcript, the server doesn't.
|
|
1220
1611
|
// Always computed live (diff/outcome/commits/markers + badge row); no
|
|
@@ -1225,8 +1616,32 @@ async function syncToTarget(cred, opts = {}) {
|
|
|
1225
1616
|
if (upload.sessionMeta && sessionFileBytes(ref) <= FULL_BUILD_MAX_BYTES) {
|
|
1226
1617
|
try {
|
|
1227
1618
|
const count = { redactions: 0 };
|
|
1228
|
-
|
|
1229
|
-
|
|
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
|
+
}
|
|
1230
1645
|
}
|
|
1231
1646
|
catch (err) {
|
|
1232
1647
|
// Derived is best-effort — the conversation still shipped — but it must
|
|
@@ -1326,7 +1741,7 @@ async function syncToTarget(cred, opts = {}) {
|
|
|
1326
1741
|
});
|
|
1327
1742
|
// KG: extract triples live from this item's redacted chunk text.
|
|
1328
1743
|
if (upload.sessionMeta && redactedChunks.length > 0) {
|
|
1329
|
-
accumulateKg(extractEntities(redactedChunks.map((c) => c.text).join('\n'), { projectPath: item.projectPath || '', sourceType: item.sourceType, validFrom: item.mtime ? new Date(Number(item.mtime)).toISOString().slice(0, 10) : undefined }), null);
|
|
1744
|
+
await accumulateKg(extractEntities(redactedChunks.map((c) => c.text).join('\n'), { projectPath: item.projectPath || '', sourceType: item.sourceType, validFrom: item.mtime ? new Date(Number(item.mtime)).toISOString().slice(0, 10) : undefined }), null);
|
|
1330
1745
|
}
|
|
1331
1746
|
try {
|
|
1332
1747
|
for (const l of await source.extractLinks(item)) {
|
|
@@ -1355,16 +1770,12 @@ async function syncToTarget(cred, opts = {}) {
|
|
|
1355
1770
|
// also the incremental-correct set: no manual-fact replay, no local KG
|
|
1356
1771
|
// DB). Entities are derived from the triple subjects/objects, typed from
|
|
1357
1772
|
// any `is_a` triple seen in the same pass.
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
entityNames.add(t.subject);
|
|
1365
|
-
entityNames.add(t.object);
|
|
1366
|
-
}
|
|
1367
|
-
for (const name of entityNames) {
|
|
1773
|
+
// Triples already shipped inside accumulateKg (see above). Entities cannot:
|
|
1774
|
+
// a name's type comes from an `is_a` triple that may appear in any later
|
|
1775
|
+
// session, so the type is only known once the walk is over. The names are
|
|
1776
|
+
// held as a Set, bounded by distinct entities rather than by session count.
|
|
1777
|
+
if (upload.sessionMeta && kgEntityNames.size > 0) {
|
|
1778
|
+
for (const name of kgEntityNames) {
|
|
1368
1779
|
const count = { redactions: 0 };
|
|
1369
1780
|
await kgEntityBatch.add(redactDeep({ name, type: kgEntityType.get(name) ?? 'unknown', properties: {} }, count));
|
|
1370
1781
|
redactions += count.redactions;
|
|
@@ -1431,6 +1842,12 @@ async function syncToTarget(cred, opts = {}) {
|
|
|
1431
1842
|
}
|
|
1432
1843
|
if (opts.prune)
|
|
1433
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 };
|
|
1434
1851
|
return {
|
|
1435
1852
|
uploaded: convBatch.sent + appendResults.uploaded, skipped, redactions,
|
|
1436
1853
|
items: itemsBatch.sent, links: linksBatch.sent, findings: findingsBatch.sent, derived: derivedBatch.sent,
|
|
@@ -1516,18 +1933,53 @@ export async function buildConversationSync(ref, mtime, opts = {}) {
|
|
|
1516
1933
|
// is what stops a resume-truncated or mtime-touched session from being
|
|
1517
1934
|
// fully rebuilt on every tick (the export/read still happens — it's how we
|
|
1518
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;
|
|
1519
1941
|
if (exp) {
|
|
1520
1942
|
try {
|
|
1521
|
-
|
|
1943
|
+
built = buildRawContainer(exp);
|
|
1944
|
+
srcHash = containerSrcHash(built);
|
|
1522
1945
|
}
|
|
1523
1946
|
catch {
|
|
1947
|
+
built = undefined;
|
|
1524
1948
|
srcHash = undefined;
|
|
1525
1949
|
}
|
|
1526
1950
|
if (srcHash && opts.priorContentHash && srcHash === opts.priorContentHash) {
|
|
1527
1951
|
return { unchanged: true, srcHash };
|
|
1528
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
|
+
}
|
|
1529
1981
|
}
|
|
1530
|
-
const shadow = updateShadow(ref.rawId, exp);
|
|
1982
|
+
const shadow = updateShadow(ref.rawId, exp, built);
|
|
1531
1983
|
if (shadow.container) {
|
|
1532
1984
|
fullestContainer = shadow.container;
|
|
1533
1985
|
if (shadow.status === 'rewrite-merged' && shadow.recovered > 0) {
|
|
@@ -1641,11 +2093,42 @@ export async function buildConversationSync(ref, mtime, opts = {}) {
|
|
|
1641
2093
|
// applied to the raw text on the client so unredacted secrets stay local.
|
|
1642
2094
|
const findings = [];
|
|
1643
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
|
+
}
|
|
1644
2126
|
if (container) {
|
|
1645
2127
|
const t0 = performance.now();
|
|
1646
2128
|
const rawText = container.files.map((f) => f.text).join('\n');
|
|
1647
2129
|
try {
|
|
1648
|
-
|
|
2130
|
+
const builtin = offloaded ? offloaded.findings : scanTextForFindings(rawText);
|
|
2131
|
+
for (const f of builtin) {
|
|
1649
2132
|
findings.push({ session_id: ref.prefixedId, detector: 'builtin', rule: f.rule, line: f.line, preview: f.preview });
|
|
1650
2133
|
}
|
|
1651
2134
|
}
|
|
@@ -1727,17 +2210,27 @@ export async function buildConversationSync(ref, mtime, opts = {}) {
|
|
|
1727
2210
|
let raw_b64;
|
|
1728
2211
|
let raw_size;
|
|
1729
2212
|
if (includeRaw && container) {
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
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
|
+
}
|
|
1738
2231
|
}
|
|
2232
|
+
catch { /* raw is additive — conversation still ships */ }
|
|
1739
2233
|
}
|
|
1740
|
-
catch { /* raw is additive — conversation still ships */ }
|
|
1741
2234
|
}
|
|
1742
2235
|
const envTexts = envelope.messages.filter((m) => m.content?.trim());
|
|
1743
2236
|
const redactedText = envTexts.map((m) => m.content).join('\n');
|
|
@@ -1869,85 +2362,6 @@ export async function buildConversationTail(ref, fromOffset, opts = {}) {
|
|
|
1869
2362
|
findings,
|
|
1870
2363
|
};
|
|
1871
2364
|
}
|
|
1872
|
-
/**
|
|
1873
|
-
* Per-session derived payload: the four compute kinds the server's deep-dive
|
|
1874
|
-
* routes read (diff/outcome/commits/markers) + the outcome-badge row. ALWAYS
|
|
1875
|
-
* computed live from the transcript/git (no local compute/outcome cache is
|
|
1876
|
-
* read or warmed — thin collector). Everything is deep-redacted — diffs carry
|
|
1877
|
-
* file content.
|
|
1878
|
-
*/
|
|
1879
|
-
function collectDerived(ref, mtime, count) {
|
|
1880
|
-
const id = ref.prefixedId;
|
|
1881
|
-
const compute = [];
|
|
1882
|
-
let fullOutcome = null;
|
|
1883
|
-
for (const kind of COMPUTE_KINDS) {
|
|
1884
|
-
let data = null;
|
|
1885
|
-
try {
|
|
1886
|
-
if (kind === 'diff') {
|
|
1887
|
-
const replay = replaySessionAny(id);
|
|
1888
|
-
if (replay.found)
|
|
1889
|
-
data = replay;
|
|
1890
|
-
}
|
|
1891
|
-
else if (kind === 'outcome') {
|
|
1892
|
-
fullOutcome = computeOutcome(id);
|
|
1893
|
-
if (fullOutcome.found)
|
|
1894
|
-
data = fullOutcome;
|
|
1895
|
-
}
|
|
1896
|
-
else if (kind === 'commits') {
|
|
1897
|
-
// computeOutcome already ran git for the session window — reuse.
|
|
1898
|
-
if (!fullOutcome)
|
|
1899
|
-
fullOutcome = computeOutcome(id);
|
|
1900
|
-
if (fullOutcome.found)
|
|
1901
|
-
data = fullOutcome.commits;
|
|
1902
|
-
}
|
|
1903
|
-
else if (kind === 'markers') {
|
|
1904
|
-
// Analysis-only use of the turns extractor (R4) — markers need
|
|
1905
|
-
// per-prompt line/ts, not render fidelity.
|
|
1906
|
-
const turns = extractTurnsAny(id, { maxTurns: 50_000 });
|
|
1907
|
-
const prompts = turns.turns
|
|
1908
|
-
.filter((t) => t.kind === 'user' && t.text)
|
|
1909
|
-
.map((t) => ({ line: t.line, ts: t.ts, tsIso: t.tsIso, ...markPrompt(t.text) }));
|
|
1910
|
-
data = { sessionId: id, prompts, summary: summarizeMarkers(prompts) };
|
|
1911
|
-
}
|
|
1912
|
-
}
|
|
1913
|
-
catch {
|
|
1914
|
-
data = null;
|
|
1915
|
-
}
|
|
1916
|
-
if (data === null)
|
|
1917
|
-
continue;
|
|
1918
|
-
const redacted = redactDeep(data, count);
|
|
1919
|
-
if (JSON.stringify(redacted).length > MAX_DERIVED_ROW_BYTES)
|
|
1920
|
-
continue;
|
|
1921
|
-
compute.push({ kind, mtime, data: redacted });
|
|
1922
|
-
}
|
|
1923
|
-
// Outcome-badge row (session_outcome_cache) — what the list badges read.
|
|
1924
|
-
// Computed live from the outcome we already ran above.
|
|
1925
|
-
let outcomeRow = null;
|
|
1926
|
-
if (fullOutcome?.found) {
|
|
1927
|
-
outcomeRow = {
|
|
1928
|
-
sessionId: id,
|
|
1929
|
-
tool: ref.toolId,
|
|
1930
|
-
status: fullOutcome.status,
|
|
1931
|
-
reason: fullOutcome.reason,
|
|
1932
|
-
fileMtime: mtime,
|
|
1933
|
-
fileSize: 0,
|
|
1934
|
-
contentHash: '',
|
|
1935
|
-
fileCount: fullOutcome.fileCount,
|
|
1936
|
-
linesAdded: fullOutcome.totalLinesAdded,
|
|
1937
|
-
linesRemoved: fullOutcome.totalLinesRemoved,
|
|
1938
|
-
commits: fullOutcome.commits?.totalCommits ?? 0,
|
|
1939
|
-
isFull: true,
|
|
1940
|
-
classifiedAt: Date.now(),
|
|
1941
|
-
lastScannedOffset: 0,
|
|
1942
|
-
};
|
|
1943
|
-
}
|
|
1944
|
-
return {
|
|
1945
|
-
session_id: id,
|
|
1946
|
-
mtime,
|
|
1947
|
-
compute,
|
|
1948
|
-
outcome_row: outcomeRow ? redactDeep({ ...outcomeRow, sessionId: undefined }, count) : null,
|
|
1949
|
-
};
|
|
1950
|
-
}
|
|
1951
2365
|
export function isSyncSkip(r) {
|
|
1952
2366
|
return typeof r.skipped === 'string';
|
|
1953
2367
|
}
|