@indigoai-us/hq-cloud 5.13.0 → 5.15.0

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/cli/sync.ts CHANGED
@@ -9,7 +9,7 @@ import * as fs from "fs";
9
9
  import * as path from "path";
10
10
  import type { VaultServiceConfig, SyncJournal } from "../types.js";
11
11
  import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
12
- import { downloadFile, listRemoteFiles } from "../s3.js";
12
+ import { downloadFile, listRemoteFiles, headRemoteFile } from "../s3.js";
13
13
  import type { RemoteFile } from "../s3.js";
14
14
  import { readJournal, writeJournal, hashFile, updateEntry, getEntry, normalizeEtag } from "../journal.js";
15
15
  import { createIgnoreFilter } from "../ignore.js";
@@ -80,6 +80,10 @@ export type SyncProgressEvent =
80
80
  path: string;
81
81
  direction: "pull" | "push";
82
82
  resolution: ConflictResolution;
83
+ }
84
+ | {
85
+ type: "new-files";
86
+ files: Array<{ path: string; bytes: number; addedBy: string | null }>;
83
87
  };
84
88
 
85
89
  export interface SyncOptions {
@@ -125,6 +129,14 @@ export interface SyncResult {
125
129
  */
126
130
  conflictPaths: string[];
127
131
  aborted: boolean;
132
+ /**
133
+ * Files classified as "new" during pull — i.e. the remote file had no
134
+ * local counterpart at classification time. Additive field; empty array
135
+ * when no new files were detected or on push-only syncs.
136
+ */
137
+ newFiles: Array<{ path: string; bytes: number }>;
138
+ /** Convenience count: `newFiles.length`. */
139
+ newFilesCount: number;
128
140
  }
129
141
 
130
142
  /**
@@ -276,6 +288,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
276
288
  }
277
289
 
278
290
  if (resolution === "abort") {
291
+ emit({ type: "new-files", files: [] });
279
292
  writeJournal(journalSlug, journal);
280
293
  return {
281
294
  filesDownloaded,
@@ -284,6 +297,8 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
284
297
  conflicts,
285
298
  conflictPaths,
286
299
  aborted: true,
300
+ newFiles: plan.newFiles,
301
+ newFilesCount: plan.newFilesCount,
287
302
  };
288
303
  }
289
304
  if (resolution === "keep" || resolution === "skip") {
@@ -352,6 +367,41 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
352
367
  }
353
368
  }
354
369
 
370
+ // ── New-files attribution (US-002) ─────────────────────────────────────
371
+ // Enrich plan.newFiles with `addedBy` from S3 user metadata. HeadObject
372
+ // calls are best-effort and capped at 5 concurrent to avoid hammering S3.
373
+ const enrichedNewFiles: Array<{ path: string; bytes: number; addedBy: string | null }> = [];
374
+ const HEAD_CONCURRENCY = 5;
375
+ for (let i = 0; i < plan.newFiles.length; i += HEAD_CONCURRENCY) {
376
+ const batch = plan.newFiles.slice(i, i + HEAD_CONCURRENCY);
377
+ const results = await Promise.all(
378
+ batch.map(async (nf) => {
379
+ let addedBy: string | null = null;
380
+ try {
381
+ const head = await headRemoteFile(ctx, nf.path);
382
+ if (head?.metadata?.["created-by"]) {
383
+ addedBy = head.metadata["created-by"];
384
+ }
385
+ } catch (headErr) {
386
+ // Best-effort: log to console (Sentry captures via global handler)
387
+ // and fall through with addedBy = null.
388
+ try {
389
+ console.error(
390
+ `[hq-sync] HeadObject failed for ${nf.path}: ${
391
+ headErr instanceof Error ? headErr.message : String(headErr)
392
+ }`,
393
+ );
394
+ } catch {
395
+ // Swallow — logging must never break sync.
396
+ }
397
+ }
398
+ return { path: nf.path, bytes: nf.bytes, addedBy };
399
+ }),
400
+ );
401
+ enrichedNewFiles.push(...results);
402
+ }
403
+ emit({ type: "new-files", files: enrichedNewFiles });
404
+
355
405
  // Stamp lastSync on every successful run so the menubar's "Last sync · X ago"
356
406
  // ticks even when nothing transferred. updateEntry only fires on actual
357
407
  // downloads; without this, a no-op sync leaves lastSync at the time of the
@@ -366,6 +416,8 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
366
416
  conflicts,
367
417
  conflictPaths,
368
418
  aborted: false,
419
+ newFiles: plan.newFiles,
420
+ newFilesCount: plan.newFilesCount,
369
421
  };
370
422
  }
371
423
 
@@ -409,7 +461,7 @@ function hasRemoteChanged(
409
461
  * executor can hand it to `resolveConflict` without re-hashing.
410
462
  */
411
463
  type PullPlanItem =
412
- | { action: "download"; remoteFile: RemoteFile; localPath: string }
464
+ | { action: "download"; remoteFile: RemoteFile; localPath: string; isNew: boolean }
413
465
  | { action: "skip-ignored"; remoteFile: RemoteFile; localPath: string }
414
466
  | { action: "skip-personal-mode"; remoteFile: RemoteFile; localPath: string }
415
467
  | { action: "skip-unchanged"; remoteFile: RemoteFile; localPath: string }
@@ -427,6 +479,9 @@ interface PullPlan {
427
479
  bytesToDownload: number;
428
480
  filesToSkip: number;
429
481
  filesToConflict: number;
482
+ /** Files classified as new (no local counterpart at classification time). */
483
+ newFiles: Array<{ path: string; bytes: number }>;
484
+ newFilesCount: number;
430
485
  }
431
486
 
432
487
  /**
@@ -463,8 +518,9 @@ function computePullPlan(
463
518
  }
464
519
 
465
520
  const journalEntry = getEntry(journal, remoteFile.key);
521
+ const localExists = fs.existsSync(localPath);
466
522
 
467
- if (fs.existsSync(localPath)) {
523
+ if (localExists) {
468
524
  const localHash = hashFile(localPath);
469
525
  const localChanged = !!journalEntry && journalEntry.hash !== localHash;
470
526
  const remoteChanged =
@@ -493,17 +549,21 @@ function computePullPlan(
493
549
  // No journal entry, or remote-only changed → fall through to download.
494
550
  }
495
551
 
496
- items.push({ action: "download", remoteFile, localPath });
552
+ items.push({ action: "download", remoteFile, localPath, isNew: !localExists });
497
553
  }
498
554
 
499
555
  let filesToDownload = 0;
500
556
  let bytesToDownload = 0;
501
557
  let filesToSkip = 0;
502
558
  let filesToConflict = 0;
559
+ const newFiles: Array<{ path: string; bytes: number }> = [];
503
560
  for (const item of items) {
504
561
  if (item.action === "download") {
505
562
  filesToDownload++;
506
563
  bytesToDownload += item.remoteFile.size;
564
+ if (item.isNew) {
565
+ newFiles.push({ path: item.remoteFile.key, bytes: item.remoteFile.size });
566
+ }
507
567
  } else if (item.action === "conflict") {
508
568
  filesToConflict++;
509
569
  } else {
@@ -517,6 +577,8 @@ function computePullPlan(
517
577
  bytesToDownload,
518
578
  filesToSkip,
519
579
  filesToConflict,
580
+ newFiles,
581
+ newFilesCount: newFiles.length,
520
582
  };
521
583
  }
522
584
 
@@ -567,5 +629,18 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
567
629
  console.error(
568
630
  ` ⚠ conflict (${event.direction}): ${event.path} — ${event.resolution}`,
569
631
  );
632
+ } else if (event.type === "new-files") {
633
+ if (event.files.length > 0) {
634
+ console.log(`${event.files.length} new file${event.files.length === 1 ? "" : "s"}`);
635
+ const MAX_SHOWN = 20;
636
+ const shown = event.files.slice(0, MAX_SHOWN);
637
+ for (const f of shown) {
638
+ const who = f.addedBy ? ` (added by ${f.addedBy})` : "";
639
+ console.log(` + ${f.path}${who}`);
640
+ }
641
+ if (event.files.length > MAX_SHOWN) {
642
+ console.log(` ... and ${event.files.length - MAX_SHOWN} more`);
643
+ }
644
+ }
570
645
  }
571
646
  }
package/src/s3.ts CHANGED
@@ -214,7 +214,7 @@ export async function deleteRemoteFile(
214
214
  export async function headRemoteFile(
215
215
  ctx: EntityContext,
216
216
  key: string,
217
- ): Promise<{ lastModified: Date; etag: string; size: number } | null> {
217
+ ): Promise<{ lastModified: Date; etag: string; size: number; metadata?: Record<string, string> } | null> {
218
218
  const client = buildClient(ctx);
219
219
  try {
220
220
  const response = await client.send(
@@ -227,6 +227,7 @@ export async function headRemoteFile(
227
227
  lastModified: response.LastModified || new Date(),
228
228
  etag: response.ETag || "",
229
229
  size: response.ContentLength || 0,
230
+ metadata: response.Metadata,
230
231
  };
231
232
  } catch (err: unknown) {
232
233
  if (err && typeof err === "object" && "name" in err && err.name === "NotFound") {