@indigoai-us/hq-cloud 5.4.6 → 5.7.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
@@ -7,9 +7,10 @@
7
7
 
8
8
  import * as fs from "fs";
9
9
  import * as path from "path";
10
- import type { VaultServiceConfig } from "../types.js";
10
+ import type { VaultServiceConfig, SyncJournal } from "../types.js";
11
11
  import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
12
12
  import { downloadFile, listRemoteFiles } from "../s3.js";
13
+ import type { RemoteFile } from "../s3.js";
13
14
  import { readJournal, writeJournal, hashFile, updateEntry, getEntry, normalizeEtag } from "../journal.js";
14
15
  import { createIgnoreFilter } from "../ignore.js";
15
16
  import { resolveConflict } from "./conflict.js";
@@ -25,8 +26,31 @@ import type { ConflictStrategy, ConflictResolution } from "./conflict.js";
25
26
  *
26
27
  * The human CLI (`hq sync`) leaves `onEvent` undefined and falls through to
27
28
  * `defaultConsoleLogger` below, which preserves the existing tty output.
29
+ *
30
+ * A single `plan` event is emitted once at the start of every run, before
31
+ * any `progress`/`conflict`/`error` events. It carries the totals derived
32
+ * from a Stage-1 classification pass so consumers can render an accurate
33
+ * progress denominator before transfers begin (the menubar's "Preparing
34
+ * sync…" pre-pass becomes obsolete once the runner forwards this).
28
35
  */
29
36
  export type SyncProgressEvent =
37
+ | {
38
+ type: "plan";
39
+ /** Files this run intends to download (pull-only; 0 from share). */
40
+ filesToDownload: number;
41
+ bytesToDownload: number;
42
+ /** Files this run intends to upload (push-only; 0 from sync). */
43
+ filesToUpload: number;
44
+ bytesToUpload: number;
45
+ /** Files classified as no-op (ignored, unchanged, local-only on pull). */
46
+ filesToSkip: number;
47
+ /**
48
+ * Files known up-front to be conflicts. Pull-side fills this from the
49
+ * 3-way merge against the journal; push-side leaves it 0 because
50
+ * conflict detection requires a remote HEAD that runs in Stage 2.
51
+ */
52
+ filesToConflict: number;
53
+ }
30
54
  | { type: "progress"; path: string; bytes: number; message?: string }
31
55
  | { type: "error"; path: string; message: string }
32
56
  | {
@@ -123,90 +147,91 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
123
147
  // List all remote files (IAM session policy filters at the AWS layer)
124
148
  const remoteFiles = await listRemoteFiles(ctx);
125
149
 
126
- for (const remoteFile of remoteFiles) {
127
- const localPath = path.join(companyRoot, remoteFile.key);
150
+ // Stage 1: classify every remote file against the journal + local disk.
151
+ // Hashing happens here (not in the transfer loop) so the plan event below
152
+ // carries an accurate denominator before any progress events fire.
153
+ const plan = computePullPlan(
154
+ remoteFiles,
155
+ journal,
156
+ companyRoot,
157
+ shouldSync,
158
+ options.personalMode === true,
159
+ );
128
160
 
129
- if (options.personalMode === true && remoteFile.key.startsWith("companies/")) {
130
- filesSkipped++;
131
- continue;
132
- }
161
+ emit({
162
+ type: "plan",
163
+ filesToDownload: plan.filesToDownload,
164
+ bytesToDownload: plan.bytesToDownload,
165
+ // sync() is pull-only; push counts are sourced from share()'s plan event.
166
+ filesToUpload: 0,
167
+ bytesToUpload: 0,
168
+ filesToSkip: plan.filesToSkip,
169
+ filesToConflict: plan.filesToConflict,
170
+ });
133
171
 
134
- // Apply ignore rules
135
- if (!shouldSync(localPath)) {
172
+ // Stage 2: execute the plan. Per-item branching mirrors the pre-refactor
173
+ // inline loop; the only structural change is that classification has
174
+ // already happened (so `localHash` is reused instead of re-hashing).
175
+ for (const item of plan.items) {
176
+ if (
177
+ item.action === "skip-ignored" ||
178
+ item.action === "skip-personal-mode" ||
179
+ item.action === "skip-unchanged" ||
180
+ item.action === "skip-local-only"
181
+ ) {
136
182
  filesSkipped++;
137
183
  continue;
138
184
  }
139
185
 
140
- // Auto-refresh context if credentials expiring
186
+ const { remoteFile, localPath } = item;
187
+
188
+ // Auto-refresh context if credentials expiring (kept in execute phase
189
+ // because Stage 1 is fast — no need to refresh just to classify).
141
190
  if (isExpiringSoon(ctx.expiresAt)) {
142
191
  ctx = await refreshEntityContext(companyRef, vaultConfig);
143
192
  }
144
193
 
145
- // Check for local conflict
146
- const journalEntry = getEntry(journal, remoteFile.key);
147
-
148
- if (fs.existsSync(localPath)) {
149
- const localHash = hashFile(localPath);
150
- const localChanged = !!journalEntry && journalEntry.hash !== localHash;
151
- const remoteChanged = !!journalEntry && hasRemoteChanged(remoteFile, journalEntry);
194
+ if (item.action === "conflict") {
195
+ conflicts++;
196
+ conflictPaths.push(remoteFile.key);
152
197
 
153
- // A real conflict requires BOTH sides to have moved since the last
154
- // sync. If only local changed, push will handle it; pulling here would
155
- // clobber the local edit. If only remote changed, fall through to
156
- // download. If neither moved, skip.
157
- if (localChanged && remoteChanged) {
158
- conflicts++;
159
- conflictPaths.push(remoteFile.key);
160
-
161
- const resolution = await resolveConflict(
162
- {
163
- path: remoteFile.key,
164
- localHash,
165
- remoteModified: remoteFile.lastModified,
166
- localModified: fs.statSync(localPath).mtime,
167
- direction: "pull",
168
- },
169
- onConflict,
170
- );
171
-
172
- emit({
173
- type: "conflict",
198
+ const resolution = await resolveConflict(
199
+ {
174
200
  path: remoteFile.key,
201
+ localHash: item.localHash,
202
+ remoteModified: remoteFile.lastModified,
203
+ localModified: fs.statSync(localPath).mtime,
175
204
  direction: "pull",
176
- resolution,
177
- });
205
+ },
206
+ onConflict,
207
+ );
178
208
 
179
- if (resolution === "abort") {
180
- writeJournal(journalSlug, journal);
181
- return {
182
- filesDownloaded,
183
- bytesDownloaded,
184
- filesSkipped,
185
- conflicts,
186
- conflictPaths,
187
- aborted: true,
188
- };
189
- }
190
- if (resolution === "keep" || resolution === "skip") {
191
- filesSkipped++;
192
- continue;
193
- }
194
- // "overwrite" falls through to download
195
- } else if (journalEntry && localChanged && !remoteChanged) {
196
- // Local-only edit: leave it for the push phase to upload. Pulling
197
- // would silently overwrite the user's work.
198
- filesSkipped++;
199
- continue;
200
- } else if (journalEntry && !localChanged && !remoteChanged) {
201
- // Neither side moved — nothing to do.
209
+ emit({
210
+ type: "conflict",
211
+ path: remoteFile.key,
212
+ direction: "pull",
213
+ resolution,
214
+ });
215
+
216
+ if (resolution === "abort") {
217
+ writeJournal(journalSlug, journal);
218
+ return {
219
+ filesDownloaded,
220
+ bytesDownloaded,
221
+ filesSkipped,
222
+ conflicts,
223
+ conflictPaths,
224
+ aborted: true,
225
+ };
226
+ }
227
+ if (resolution === "keep" || resolution === "skip") {
202
228
  filesSkipped++;
203
229
  continue;
204
230
  }
205
- // Otherwise (no journal entry, or remote-only changed) fall through
206
- // to download.
231
+ // "overwrite" falls through to download
207
232
  }
208
233
 
209
- // Download
234
+ // Download (action === "download" or conflict resolved to "overwrite")
210
235
  try {
211
236
  await downloadFile(ctx, remoteFile.key, localPath);
212
237
 
@@ -216,8 +241,10 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
216
241
  // drift independently of mtime drift.
217
242
  updateEntry(journal, remoteFile.key, hash, stat.size, "down", remoteFile.etag);
218
243
 
219
- // Attach message from journal entry if present
220
- const remoteJournalMessage = (journalEntry as { message?: string } | undefined)?.message;
244
+ // Attach message from the prior journal entry if present (set by a
245
+ // previous `share` operation that included a --message).
246
+ const priorEntry = getEntry(journal, remoteFile.key);
247
+ const remoteJournalMessage = (priorEntry as { message?: string } | undefined)?.message;
221
248
  emit({
222
249
  type: "progress",
223
250
  path: remoteFile.key,
@@ -287,6 +314,124 @@ function hasRemoteChanged(
287
314
  return remote.lastModified.getTime() > syncedAt;
288
315
  }
289
316
 
317
+ /**
318
+ * Stage-1 classification for a single remote object. Each remote file falls
319
+ * into exactly one bucket; the executor in `sync()` switches on `action` to
320
+ * decide what to do. `localHash` is carried on `conflict` items so the
321
+ * executor can hand it to `resolveConflict` without re-hashing.
322
+ */
323
+ type PullPlanItem =
324
+ | { action: "download"; remoteFile: RemoteFile; localPath: string }
325
+ | { action: "skip-ignored"; remoteFile: RemoteFile; localPath: string }
326
+ | { action: "skip-personal-mode"; remoteFile: RemoteFile; localPath: string }
327
+ | { action: "skip-unchanged"; remoteFile: RemoteFile; localPath: string }
328
+ | { action: "skip-local-only"; remoteFile: RemoteFile; localPath: string }
329
+ | {
330
+ action: "conflict";
331
+ remoteFile: RemoteFile;
332
+ localPath: string;
333
+ localHash: string;
334
+ };
335
+
336
+ interface PullPlan {
337
+ items: PullPlanItem[];
338
+ filesToDownload: number;
339
+ bytesToDownload: number;
340
+ filesToSkip: number;
341
+ filesToConflict: number;
342
+ }
343
+
344
+ /**
345
+ * Stage-1 planning pass: classify every remote file into download / skip /
346
+ * conflict buckets without performing any S3 transfers. Local hashes are
347
+ * computed here (not in the transfer loop) so the totals returned reflect
348
+ * the real outcome of the upcoming Stage-2 execution rather than an
349
+ * upper-bound guess.
350
+ *
351
+ * Pure function: no S3 calls, no journal writes, no event emission. The
352
+ * caller (`sync()`) is responsible for emitting the resulting plan event
353
+ * before iterating `items`.
354
+ */
355
+ function computePullPlan(
356
+ remoteFiles: RemoteFile[],
357
+ journal: SyncJournal,
358
+ companyRoot: string,
359
+ shouldSync: (filePath: string) => boolean,
360
+ personalMode: boolean,
361
+ ): PullPlan {
362
+ const items: PullPlanItem[] = [];
363
+
364
+ for (const remoteFile of remoteFiles) {
365
+ const localPath = path.join(companyRoot, remoteFile.key);
366
+
367
+ if (personalMode && remoteFile.key.startsWith("companies/")) {
368
+ items.push({ action: "skip-personal-mode", remoteFile, localPath });
369
+ continue;
370
+ }
371
+
372
+ if (!shouldSync(localPath)) {
373
+ items.push({ action: "skip-ignored", remoteFile, localPath });
374
+ continue;
375
+ }
376
+
377
+ const journalEntry = getEntry(journal, remoteFile.key);
378
+
379
+ if (fs.existsSync(localPath)) {
380
+ const localHash = hashFile(localPath);
381
+ const localChanged = !!journalEntry && journalEntry.hash !== localHash;
382
+ const remoteChanged =
383
+ !!journalEntry && hasRemoteChanged(remoteFile, journalEntry);
384
+
385
+ // Mirror the original 3-way merge from the inline loop. Tested by
386
+ // `does NOT flag a pull conflict when only local changed since last
387
+ // sync` and `detects conflicts with local changes…`.
388
+ if (localChanged && remoteChanged) {
389
+ items.push({
390
+ action: "conflict",
391
+ remoteFile,
392
+ localPath,
393
+ localHash,
394
+ });
395
+ continue;
396
+ }
397
+ if (journalEntry && localChanged && !remoteChanged) {
398
+ items.push({ action: "skip-local-only", remoteFile, localPath });
399
+ continue;
400
+ }
401
+ if (journalEntry && !localChanged && !remoteChanged) {
402
+ items.push({ action: "skip-unchanged", remoteFile, localPath });
403
+ continue;
404
+ }
405
+ // No journal entry, or remote-only changed → fall through to download.
406
+ }
407
+
408
+ items.push({ action: "download", remoteFile, localPath });
409
+ }
410
+
411
+ let filesToDownload = 0;
412
+ let bytesToDownload = 0;
413
+ let filesToSkip = 0;
414
+ let filesToConflict = 0;
415
+ for (const item of items) {
416
+ if (item.action === "download") {
417
+ filesToDownload++;
418
+ bytesToDownload += item.remoteFile.size;
419
+ } else if (item.action === "conflict") {
420
+ filesToConflict++;
421
+ } else {
422
+ filesToSkip++;
423
+ }
424
+ }
425
+
426
+ return {
427
+ items,
428
+ filesToDownload,
429
+ bytesToDownload,
430
+ filesToSkip,
431
+ filesToConflict,
432
+ };
433
+ }
434
+
290
435
  /**
291
436
  * Check if an error is an S3 access denied (expected for filtered guests).
292
437
  */
@@ -303,7 +448,26 @@ function isAccessDenied(err: unknown): boolean {
303
448
  * without an `onEvent` see no behavioral change.
304
449
  */
305
450
  function defaultConsoleLogger(event: SyncProgressEvent): void {
306
- if (event.type === "progress") {
451
+ if (event.type === "plan") {
452
+ // Terse single line so humans see what's about to happen without
453
+ // drowning the per-file output that follows. Skip when there's
454
+ // nothing to do — no signal, no noise.
455
+ const movement = event.filesToDownload + event.filesToUpload + event.filesToConflict;
456
+ if (movement > 0) {
457
+ const parts: string[] = [];
458
+ if (event.filesToDownload > 0) {
459
+ parts.push(`${event.filesToDownload} to download (${event.bytesToDownload} bytes)`);
460
+ }
461
+ if (event.filesToUpload > 0) {
462
+ parts.push(`${event.filesToUpload} to upload (${event.bytesToUpload} bytes)`);
463
+ }
464
+ if (event.filesToConflict > 0) {
465
+ parts.push(`${event.filesToConflict} conflict(s)`);
466
+ }
467
+ parts.push(`${event.filesToSkip} unchanged`);
468
+ console.log(`Plan: ${parts.join(", ")}`);
469
+ }
470
+ } else if (event.type === "progress") {
307
471
  if (event.message) {
308
472
  console.log(` ✓ ${event.path} — "${event.message}"`);
309
473
  } else {