@mnemonik/scanner 5.137.0 → 5.151.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/daemon.ts DELETED
@@ -1,679 +0,0 @@
1
- import { createHash } from 'crypto';
2
- import { readFile } from 'fs/promises';
3
- import { join } from 'path';
4
- import { CodeScanner, scrubSecrets, type CodeChunk } from '@mnemonik/shared';
5
-
6
- /** Maximum raw content bytes accepted by the server's scanFileSchema. */
7
- const MAX_PUSH_CONTENT_BYTES = 5_000_000;
8
- import { MnemonikClient, type ScanPushCommit, type ScanPushFile } from './client.js';
9
- import { FileWatcher } from './watcher.js';
10
- import { ProjectDiscovery, type DiscoveredProject } from './discovery.js';
11
- import { probeGit, fetchCommits } from './git.js';
12
-
13
- export interface DaemonConfig {
14
- serverUrl: string;
15
- apiKey: string;
16
- roots: string[];
17
- refreshIntervalMs?: number;
18
- maxConcurrentScans?: number;
19
- }
20
-
21
- interface WatchedProject {
22
- projectId: string;
23
- path: string;
24
- name?: string;
25
- watcher: FileWatcher;
26
- pendingRetries: Set<string>;
27
- retryTimer: ReturnType<typeof setInterval> | null;
28
- // git-mining state cached across scans. `isGitRepo` is set once at
29
- // project add time (false for non-git projects, skips git calls entirely).
30
- // `gitMiningEnabled` reflects server-side Pro+ tier + feature flag +
31
- // per-project opt-out. `lastMinedCommit` bounds `git log` from below.
32
- isGitRepo: boolean;
33
- gitMiningEnabled: boolean;
34
- lastMinedCommit: string | null;
35
- fullRescanInProgress: boolean;
36
- // Snapshot of file paths observed during the most recent full scan
37
- // (initial + periodic refresh). Used as the "before" side of the local
38
- // diff that computes removedFiles each scan tick. Empty on first call
39
- // for a project; the daemon-restart bootstrap is handled by seeding
40
- // from `client.getStatus()` (the server's known-paths set) before the
41
- // first scan completes.
42
- lastInventory: Set<string>;
43
- // Server's authoritative file_path -> content hash, as last known to this
44
- // daemon (seeded from getStatus, overlaid with files this daemon pushed).
45
- // Lets handleChanges skip re-pushing a file whose content is unchanged from
46
- // what the server already has — fs.watch fires on no-op events (editor flush,
47
- // chmod, mtime touch) that would otherwise re-chunk + re-push + re-ingest an
48
- // identical file. Reset on a full rescan so server-side hash invalidation
49
- // can never leave a stale "already pushed" entry.
50
- cachedFileHashes: Map<string, string>;
51
- }
52
-
53
- export class ScannerDaemon {
54
- private client: MnemonikClient;
55
- private scanner: CodeScanner;
56
- private projects = new Map<string, WatchedProject>();
57
- private refreshTimer: ReturnType<typeof setInterval> | null = null;
58
- private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
59
- private discovery: ProjectDiscovery;
60
- private refreshIntervalMs: number;
61
- private maxConcurrentScans: number;
62
- private scannerVersion: string | null | undefined;
63
-
64
- constructor(private config: DaemonConfig) {
65
- this.client = new MnemonikClient(config.serverUrl, config.apiKey);
66
- this.scanner = new CodeScanner();
67
- this.discovery = new ProjectDiscovery(config.roots);
68
- this.refreshIntervalMs = config.refreshIntervalMs ?? 300_000; // 5 min
69
- this.maxConcurrentScans = config.maxConcurrentScans ?? 5;
70
- }
71
-
72
- async start(): Promise<void> {
73
- console.log(`[scanner] Starting daemon`);
74
- console.log(`[scanner] Server: ${this.config.serverUrl}`);
75
- console.log(`[scanner] Roots: ${this.config.roots.join(', ')}`);
76
-
77
- await this.waitForServer();
78
- await this.refreshProjects();
79
-
80
- this.refreshTimer = setInterval(() => {
81
- this.refreshProjects().catch((err) => {
82
- console.warn('[scanner] Refresh failed:', (err as Error).message);
83
- });
84
- }, this.refreshIntervalMs);
85
- this.refreshTimer.unref();
86
-
87
- // Send heartbeat immediately, then every 60s so session_bootstrap
88
- // can reliably detect daemon liveness without waiting for a file scan.
89
- await this.sendHeartbeats();
90
- this.heartbeatTimer = setInterval(() => {
91
- this.sendHeartbeats().catch((err) => {
92
- console.warn('[scanner] Heartbeat failed:', (err as Error).message);
93
- });
94
- }, 60_000);
95
- this.heartbeatTimer.unref();
96
-
97
- console.log('[scanner] Watching for changes.');
98
- }
99
-
100
- async stop(): Promise<void> {
101
- if (this.refreshTimer) {
102
- clearInterval(this.refreshTimer);
103
- this.refreshTimer = null;
104
- }
105
- if (this.heartbeatTimer) {
106
- clearInterval(this.heartbeatTimer);
107
- this.heartbeatTimer = null;
108
- }
109
-
110
- for (const project of this.projects.values()) {
111
- project.watcher.stop();
112
- if (project.retryTimer) clearInterval(project.retryTimer);
113
- }
114
- this.projects.clear();
115
- console.log('[scanner] Daemon stopped');
116
- }
117
-
118
- private async sendHeartbeats(): Promise<void> {
119
- const version = await this.getScannerVersion();
120
- for (const { projectId } of this.projects.values()) {
121
- await this.client
122
- .sendHeartbeat(projectId, {
123
- scope: 'global',
124
- ...(version && { version }),
125
- })
126
- .catch((err) => {
127
- console.warn(`[scanner] Heartbeat failed for ${projectId}:`, (err as Error).message);
128
- });
129
- }
130
- }
131
-
132
- private async getScannerVersion(): Promise<string | undefined> {
133
- if (this.scannerVersion !== undefined) {
134
- return this.scannerVersion ?? undefined;
135
- }
136
- this.scannerVersion = await readScannerPackageVersion();
137
- return this.scannerVersion ?? undefined;
138
- }
139
-
140
- getWatchedProjects(): Array<{ projectId: string; path: string; name?: string }> {
141
- return Array.from(this.projects.values()).map((p) => ({
142
- projectId: p.projectId,
143
- path: p.path,
144
- name: p.name,
145
- }));
146
- }
147
-
148
- /**
149
- * Discover projects from configured roots and reconcile with current watch list.
150
- */
151
- async refreshProjects(): Promise<void> {
152
- const discovered = await this.discovery.discover();
153
- const discoveredMap = new Map(discovered.map((d) => [d.projectId, d]));
154
-
155
- // Remove projects no longer discovered
156
- for (const [projectId, project] of this.projects) {
157
- if (!discoveredMap.has(projectId)) {
158
- console.log(`[scanner] Project removed: ${project.name ?? projectId} (${project.path})`);
159
- project.watcher.stop();
160
- if (project.retryTimer) clearInterval(project.retryTimer);
161
- this.projects.delete(projectId);
162
- }
163
- }
164
-
165
- // Add new projects or handle path changes
166
- for (const discovered_project of discoveredMap.values()) {
167
- const existing = this.projects.get(discovered_project.projectId);
168
-
169
- if (!existing) {
170
- // New project
171
- await this.addProject(discovered_project);
172
- } else if (existing.path !== discovered_project.path) {
173
- // Path changed (folder renamed/moved)
174
- console.log(
175
- `[scanner] Project moved: ${existing.name ?? existing.projectId} ` +
176
- `${existing.path} → ${discovered_project.path}`
177
- );
178
- existing.watcher.stop();
179
- if (existing.retryTimer) clearInterval(existing.retryTimer);
180
- this.projects.delete(discovered_project.projectId);
181
- await this.addProject(discovered_project);
182
- }
183
- }
184
-
185
- // Periodic removed-files diff for already-watched projects. Cheap
186
- // path-only walk (no chunking, no file reads), compare against the
187
- // in-memory snapshot from last tick, send only the disappeared paths.
188
- // Picks up deletions that fs.watch delivered as unlink events but
189
- // that handleChanges couldn't push (it only knows how to push
190
- // chunked content, not "this file no longer exists").
191
- for (const project of this.projects.values()) {
192
- try {
193
- const status = await this.client.getStatus(project.projectId);
194
- if (hasInvalidatedHashes(status.fileHashes)) {
195
- if (project.fullRescanInProgress) {
196
- console.log(
197
- `[scanner] [${project.projectId.slice(0, 8)}] full rescan already running; skipping refresh tick`
198
- );
199
- continue;
200
- }
201
-
202
- console.log(
203
- `[scanner] [${project.projectId.slice(0, 8)}] server hashes invalidated; running full rescan`
204
- );
205
- project.fullRescanInProgress = true;
206
- try {
207
- const scanResult = await this.initialScan(project.projectId, project.path);
208
- project.isGitRepo = scanResult.isGitRepo;
209
- project.gitMiningEnabled = scanResult.gitMiningEnabled;
210
- project.lastMinedCommit = scanResult.lastMinedCommit;
211
- project.lastInventory = new Set(scanResult.currentPaths);
212
- // Re-seed from the post-rescan authoritative view so an invalidated
213
- // hash never leaves a stale "already pushed" entry behind.
214
- project.cachedFileHashes = new Map(scanResult.fileHashes);
215
- } finally {
216
- project.fullRescanInProgress = false;
217
- }
218
- continue;
219
- }
220
-
221
- const scanned = await this.scanner.listFilesWithStatus(project.path);
222
- // I1: include authority file paths so they are never treated as removed
223
- const authority = await this.scanner.collectAuthorityFilesWithStatus(project.path);
224
- // A partial walk (fs error swallowed mid-traversal) reads as mass
225
- // deletion: files that still exist on disk vanish from currentSet
226
- // and would be deprecated server-side. Withhold the diff AND keep
227
- // lastInventory untouched so the next clean tick diffs against the
228
- // trusted baseline.
229
- if (!scanned.complete || !authority.complete) {
230
- console.warn(
231
- `[scanner] [${project.projectId.slice(0, 8)}] incomplete file walk; withholding removed-files diff this tick`
232
- );
233
- continue;
234
- }
235
- const currentSet = new Set([...scanned.paths, ...authority.files.map((a) => a.path)]);
236
- const removedFiles = [...project.lastInventory].filter((p) => !currentSet.has(p));
237
- await this.sendRemovedFiles(project.projectId, removedFiles);
238
- project.lastInventory = currentSet;
239
- } catch (err) {
240
- console.warn(
241
- `[scanner] [${project.projectId.slice(0, 8)}] periodic removed-files report failed: ${(err as Error).message}`
242
- );
243
- }
244
- }
245
-
246
- console.log(`[scanner] Watching ${this.projects.size} project(s)`);
247
- }
248
-
249
- private async addProject(discovered: DiscoveredProject): Promise<void> {
250
- const label = discovered.projectName ?? discovered.projectId.slice(0, 8);
251
- console.log(`[scanner] Adding project: ${label} (${discovered.path})`);
252
-
253
- let scanResult: {
254
- isGitRepo: boolean;
255
- gitMiningEnabled: boolean;
256
- lastMinedCommit: string | null;
257
- currentPaths: string[];
258
- fileHashes: Map<string, string>;
259
- } = {
260
- isGitRepo: false,
261
- gitMiningEnabled: false,
262
- lastMinedCommit: null,
263
- currentPaths: [],
264
- fileHashes: new Map(),
265
- };
266
- try {
267
- scanResult = await this.initialScan(discovered.projectId, discovered.path);
268
- } catch (err) {
269
- console.warn(`[scanner] Initial scan failed for ${label}:`, (err as Error).message);
270
- }
271
-
272
- const watcher = new FileWatcher(
273
- discovered.path,
274
- (changedFiles) => this.handleChanges(discovered.projectId, discovered.path, changedFiles),
275
- 500,
276
- (err) => {
277
- console.warn(
278
- `[scanner] Root watcher error for ${label}: ${err.message}. Removing project.`
279
- );
280
- const project = this.projects.get(discovered.projectId);
281
- if (project) {
282
- project.watcher.stop();
283
- if (project.retryTimer) clearInterval(project.retryTimer);
284
- this.projects.delete(discovered.projectId);
285
- }
286
- }
287
- );
288
-
289
- try {
290
- await watcher.start();
291
- } catch (err) {
292
- console.warn(`[scanner] Failed to start watcher for ${label}:`, (err as Error).message);
293
- return;
294
- }
295
-
296
- this.projects.set(discovered.projectId, {
297
- projectId: discovered.projectId,
298
- path: discovered.path,
299
- name: discovered.projectName,
300
- watcher,
301
- pendingRetries: new Set(),
302
- retryTimer: null,
303
- isGitRepo: scanResult.isGitRepo,
304
- gitMiningEnabled: scanResult.gitMiningEnabled,
305
- lastMinedCommit: scanResult.lastMinedCommit,
306
- fullRescanInProgress: false,
307
- lastInventory: new Set(scanResult.currentPaths),
308
- cachedFileHashes: new Map(scanResult.fileHashes),
309
- });
310
- }
311
-
312
- private async waitForServer(): Promise<void> {
313
- let logged = false;
314
- let delay = 3000;
315
- const maxDelay = 30000;
316
- const maxRetries = 20;
317
-
318
- for (let attempt = 0; attempt < maxRetries; attempt++) {
319
- if (await this.client.healthCheck()) {
320
- if (logged) console.log('[scanner] Server is back');
321
- else console.log('[scanner] Server health check passed');
322
- return;
323
- }
324
- if (!logged) {
325
- console.log('[scanner] Server unreachable, waiting...');
326
- logged = true;
327
- }
328
- const jitter = delay * (0.5 + Math.random());
329
- await new Promise((r) => setTimeout(r, jitter));
330
- delay = Math.min(delay * 1.5, maxDelay);
331
- }
332
-
333
- throw new Error(`Server unreachable after ${maxRetries} attempts`);
334
- }
335
-
336
- private async initialScan(
337
- projectId: string,
338
- projectRoot: string
339
- ): Promise<{
340
- isGitRepo: boolean;
341
- gitMiningEnabled: boolean;
342
- lastMinedCommit: string | null;
343
- currentPaths: string[];
344
- fileHashes: Map<string, string>;
345
- }> {
346
- const startTime = Date.now();
347
-
348
- const scan = await this.scanner.scanDirectoryWithStatus(projectRoot);
349
- const chunks = scan.chunks;
350
- const status = await this.client.getStatus(projectId);
351
-
352
- const files = await this.groupChunksByFile(chunks, projectRoot);
353
- const authority = await this.collectAuthorityPushFiles(projectRoot);
354
- // C1: dedupe — chunk-scanned entries win over authority duplicates
355
- // (e.g. setup.py matches both .py includeExtensions and AUTHORITY_FILE_MATCHERS)
356
- const seen = new Set(files.map((f) => f.path));
357
- const allFiles = [...files, ...authority.files.filter((a) => !seen.has(a.path))];
358
- const filesToPush = allFiles.filter((f) => {
359
- const serverHash = status.fileHashes.get(f.path);
360
- return !serverHash || serverHash !== f.hash;
361
- });
362
-
363
- // collect new commits since last mine when this is a git repo and
364
- // the server has mining enabled for this project (Pro+ tier + not opted out).
365
- const isGitRepo = await probeGit(projectRoot);
366
- const commits =
367
- isGitRepo && status.gitMining.enabled
368
- ? await this.collectCommits(projectRoot, status.gitMining.lastMinedCommit)
369
- : [];
370
-
371
- // push when there's anything to send — file changes OR new
372
- // commits. Previously a stable repo with new commits never pushed,
373
- // silently losing those commits forever.
374
- let watermark = status.gitMining.lastMinedCommit;
375
- let pushOk = false;
376
- if (filesToPush.length > 0 || commits.length > 0) {
377
- console.log(
378
- `[scanner] Pushing ${filesToPush.length} changed files + ${commits.length} commits for ${projectId.slice(0, 8)}...`
379
- );
380
- try {
381
- await this.client.pushFiles(projectId, filesToPush, commits);
382
- pushOk = true;
383
- // Only advance the watermark after a successful push that actually
384
- // carried the commits.
385
- if (commits.length > 0) {
386
- watermark = commits[0]?.sha ?? watermark;
387
- }
388
- } catch (err) {
389
- console.warn(
390
- `[scanner] Initial push failed for ${projectId.slice(0, 8)}: ${(err as Error).message}`
391
- );
392
- }
393
- }
394
-
395
- // Initial-scan removed-files diff: bootstrap "before" from the
396
- // server's known-paths set (status.fileHashes is what the server
397
- // currently has for this project). Anything the server knows that
398
- // the daemon doesn't see on disk now = removed. Closes the
399
- // daemon-was-offline case and the newly-added-ignore-pattern case.
400
- // Gated on walk completeness: adds/changes above are not
401
- // completeness-sensitive (a missing file just isn't pushed this
402
- // pass), but deriving removals from a partial walk deprecates
403
- // memories for files that still exist. Skip the diff entirely when
404
- // either walk was truncated.
405
- const currentPaths = allFiles.map((f) => f.path);
406
- const currentPathSet = new Set(currentPaths);
407
- let removedFiles: string[] = [];
408
- if (scan.complete && authority.complete) {
409
- removedFiles = [...status.fileHashes.keys()].filter((p) => !currentPathSet.has(p));
410
- await this.sendRemovedFiles(projectId, removedFiles).catch((err) => {
411
- console.warn(
412
- `[scanner] [${projectId.slice(0, 8)}] removedFiles report failed (non-blocking): ${(err as Error).message}`
413
- );
414
- });
415
- } else {
416
- console.warn(
417
- `[scanner] [${projectId.slice(0, 8)}] incomplete file walk; withholding removed-files diff for this scan`
418
- );
419
- }
420
-
421
- const duration = ((Date.now() - startTime) / 1000).toFixed(1);
422
- console.log(
423
- `[scanner] Scan complete for ${projectId.slice(0, 8)}: ` +
424
- `${chunks.length} chunks, ${filesToPush.length} pushed, ${commits.length} commits (${duration}s)`
425
- );
426
-
427
- // Build the server's authoritative file_path -> hash view after this scan:
428
- // the server's pre-scan set, overlaid with the files we just pushed (only
429
- // if the push succeeded), minus the files we just reported removed.
430
- const fileHashes = new Map(status.fileHashes);
431
- if (pushOk) {
432
- for (const f of filesToPush) fileHashes.set(f.path, f.hash);
433
- }
434
- for (const p of removedFiles) fileHashes.delete(p);
435
-
436
- return {
437
- isGitRepo,
438
- gitMiningEnabled: status.gitMining.enabled,
439
- lastMinedCommit: watermark,
440
- currentPaths,
441
- fileHashes,
442
- };
443
- }
444
-
445
- /**
446
- * Send the explicit list of paths that vanished since the previous scan.
447
- * Empty lists short-circuit so we don't pay an HTTP round-trip when
448
- * nothing was removed this tick.
449
- */
450
- private async sendRemovedFiles(projectId: string, removedFiles: string[]): Promise<void> {
451
- if (removedFiles.length === 0) return;
452
- const result = await this.client.reportRemovedFiles(projectId, removedFiles);
453
- if (result.deprecated > 0 || result.couplingsRemoved > 0) {
454
- console.log(
455
- `[scanner] [${projectId.slice(0, 8)}] Removed: ` +
456
- `${removedFiles.length} paths sent, ${result.deprecated} memories deprecated, ` +
457
- `${result.couplingsRemoved} couplings removed`
458
- );
459
- }
460
- }
461
-
462
- /**
463
- * Fetch commits since the last mine. Swallows errors — a git failure must
464
- * not block the file scan push.
465
- */
466
- private async collectCommits(
467
- projectRoot: string,
468
- lastMinedCommit: string | null
469
- ): Promise<ScanPushCommit[]> {
470
- try {
471
- const commits = await fetchCommits(projectRoot, lastMinedCommit);
472
- return commits;
473
- } catch (err) {
474
- console.warn(`[scanner] git log failed in ${projectRoot}:`, (err as Error).message);
475
- return [];
476
- }
477
- }
478
-
479
- private async handleChanges(
480
- projectId: string,
481
- projectRoot: string,
482
- changedFiles: string[]
483
- ): Promise<void> {
484
- const project = this.projects.get(projectId);
485
- if (!project) return;
486
-
487
- try {
488
- const absPaths = changedFiles.map((rel) => join(projectRoot, rel));
489
- const chunks = await this.scanner.scanFiles(absPaths, projectRoot);
490
-
491
- if (chunks.length === 0) return;
492
-
493
- const files = await this.groupChunksByFile(chunks, projectRoot);
494
- const authority = await this.collectAuthorityPushFiles(projectRoot);
495
- // C1: dedupe — chunk-scanned entries win over authority duplicates
496
- const seen = new Set(files.map((f) => f.path));
497
- const allFiles = [...files, ...authority.files.filter((a) => !seen.has(a.path))];
498
-
499
- // Skip files whose content hash matches what the server already has —
500
- // fs.watch fires on no-op events (editor flush, chmod, mtime touch) that
501
- // would otherwise re-chunk, re-push, and re-ingest an identical file
502
- // (and re-trigger the server's doc-truth diff pipeline). Files this
503
- // daemon has never confirmed-pushed have no cache entry and are always
504
- // pushed; the cache is updated only on a successful push and reset on a
505
- // full rescan, so a needed push is never dropped.
506
- const toPush = allFiles.filter((f) => project.cachedFileHashes.get(f.path) !== f.hash);
507
- const skipped = allFiles.length - toPush.length;
508
- if (skipped > 0) {
509
- console.log(`[scanner] [${projectId.slice(0, 8)}] Skipped ${skipped} unchanged file(s)`);
510
- }
511
-
512
- // attach any new commits since our cached watermark. Only the
513
- // first push carries them (idempotent jobId on the server collapses
514
- // duplicates anyway, but one payload saves bandwidth).
515
- const commits =
516
- project.isGitRepo && project.gitMiningEnabled
517
- ? await this.collectCommits(projectRoot, project.lastMinedCommit)
518
- : [];
519
-
520
- const batchSize = 25;
521
- const succeededPaths = new Set<string>();
522
- let hadFailure = false;
523
- let firstBatchSucceeded = false;
524
-
525
- if (toPush.length > 0) {
526
- for (let i = 0; i < toPush.length; i += batchSize) {
527
- const batch = toPush.slice(i, i + batchSize);
528
- try {
529
- // Only attach commits to the first batch.
530
- await this.client.pushFiles(projectId, batch, i === 0 ? commits : undefined);
531
- for (const f of batch) {
532
- succeededPaths.add(f.path);
533
- project.cachedFileHashes.set(f.path, f.hash);
534
- }
535
- if (i === 0) firstBatchSucceeded = true;
536
- } catch {
537
- hadFailure = true;
538
- for (const f of batch) project.pendingRetries.add(f.path);
539
- }
540
- }
541
- } else if (commits.length > 0) {
542
- // No file changes but new commits exist — fire a commit-only push so
543
- // they reach the server. 1 schema allows files=[].
544
- try {
545
- await this.client.pushFiles(projectId, [], commits);
546
- firstBatchSucceeded = true;
547
- } catch (err) {
548
- hadFailure = true;
549
- console.warn(
550
- `[scanner] [${projectId.slice(0, 8)}] Commit-only push failed: ${(err as Error).message}`
551
- );
552
- }
553
- }
554
-
555
- // Advance the watermark only if commits actually made it to the server.
556
- if (commits.length > 0 && firstBatchSucceeded) {
557
- project.lastMinedCommit = commits[0]?.sha ?? project.lastMinedCommit;
558
- }
559
-
560
- if (succeededPaths.size > 0) {
561
- console.log(
562
- `[scanner] [${projectId.slice(0, 8)}] Pushed ${succeededPaths.size} file(s): ${[...succeededPaths].join(', ')}`
563
- );
564
- }
565
- if (hadFailure) {
566
- console.warn(
567
- `[scanner] [${projectId.slice(0, 8)}] ${project.pendingRetries.size} file(s) failed, queued for retry`
568
- );
569
- this.startRetryLoop(project);
570
- }
571
- } catch (err) {
572
- console.error(`[scanner] [${projectId.slice(0, 8)}] Error handling changes:`, err);
573
- }
574
- }
575
-
576
- private startRetryLoop(project: WatchedProject): void {
577
- if (project.retryTimer) return;
578
- project.retryTimer = setInterval(async () => {
579
- if (project.pendingRetries.size === 0) {
580
- if (project.retryTimer) clearInterval(project.retryTimer);
581
- project.retryTimer = null;
582
- return;
583
- }
584
- const files = [...project.pendingRetries];
585
- project.pendingRetries.clear();
586
- await this.handleChanges(project.projectId, project.path, files);
587
- }, 10_000);
588
- project.retryTimer.unref();
589
- }
590
-
591
- private async collectAuthorityPushFiles(
592
- projectRoot: string
593
- ): Promise<{ files: ScanPushFile[]; complete: boolean }> {
594
- const authority = await this.scanner.collectAuthorityFilesWithStatus(projectRoot);
595
- return {
596
- // Whole-file `content` must honor the same daemon-side redaction
597
- // invariant as chunk content: no credential leaves this process.
598
- // `hash` stays computed over the RAW bytes so change detection
599
- // against the server's known-hash map is unaffected; the server
600
- // recomputes its own stored content_hash from what arrives.
601
- files: authority.files.map((f) => ({
602
- path: f.path,
603
- hash: f.hash,
604
- chunks: [],
605
- content: f.content === undefined ? undefined : scrubSecrets(f.content),
606
- })),
607
- complete: authority.complete,
608
- };
609
- }
610
-
611
- private async groupChunksByFile(
612
- chunks: CodeChunk[],
613
- projectRoot: string
614
- ): Promise<ScanPushFile[]> {
615
- const fileMap = new Map<string, ScanPushFile>();
616
-
617
- for (const chunk of chunks) {
618
- const key = chunk.filePath;
619
- if (!fileMap.has(key)) {
620
- fileMap.set(key, { path: key, hash: '', chunks: [] });
621
- }
622
- const file = fileMap.get(key)!;
623
- file.chunks.push({
624
- content: chunk.content,
625
- startLine: chunk.startLine,
626
- endLine: chunk.endLine,
627
- chunkType: chunk.chunkType,
628
- language: chunk.language,
629
- contentHash: chunk.contentHash,
630
- metadata: chunk.metadata,
631
- });
632
- }
633
-
634
- for (const file of fileMap.values()) {
635
- try {
636
- const absPath = join(projectRoot, file.path);
637
- const raw = await readFile(absPath, 'utf-8');
638
- // Hash the RAW bytes (change detection against the server's
639
- // known-hash map keys on what's on disk), but never ship them:
640
- // whole-file `content` honors the same daemon-side redaction
641
- // invariant as chunk content. The server recomputes its stored
642
- // content_hash from the scrubbed bytes it receives.
643
- file.hash = createHash('sha256').update(raw).digest('hex');
644
- // C2: drop content if it would exceed the server schema cap (5MB)
645
- file.content = raw.length <= MAX_PUSH_CONTENT_BYTES ? scrubSecrets(raw) : undefined;
646
- } catch (err) {
647
- console.warn(`[scanner] Cannot read ${file.path} for hashing, using chunk-based fallback`, {
648
- error: err instanceof Error ? err.message : String(err),
649
- });
650
- // Chunk content is already scrubbed by CodeScanner; scrubSecrets is
651
- // idempotent so re-applying keeps the invariant explicit.
652
- const allContent = file.chunks.map((c) => c.content).join('\n');
653
- file.hash = createHash('sha256').update(allContent).digest('hex');
654
- // C2: drop content if it would exceed the server schema cap (5MB)
655
- file.content =
656
- allContent.length <= MAX_PUSH_CONTENT_BYTES ? scrubSecrets(allContent) : undefined;
657
- }
658
- }
659
-
660
- return Array.from(fileMap.values());
661
- }
662
- }
663
-
664
- function hasInvalidatedHashes(fileHashes: Map<string, string>): boolean {
665
- for (const hash of fileHashes.values()) {
666
- if (hash === '') return true;
667
- }
668
- return false;
669
- }
670
-
671
- async function readScannerPackageVersion(): Promise<string | null> {
672
- try {
673
- const raw = await readFile(new URL('../package.json', import.meta.url), 'utf-8');
674
- const parsed = JSON.parse(raw) as { version?: unknown };
675
- return typeof parsed.version === 'string' ? parsed.version : null;
676
- } catch {
677
- return null;
678
- }
679
- }