@indigoai-us/hq-cloud 5.4.5 → 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/share.ts CHANGED
@@ -7,15 +7,109 @@
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 { uploadFile, headRemoteFile } from "../s3.js";
13
- import { readJournal, writeJournal, hashFile, updateEntry } from "../journal.js";
13
+ import { readJournal, writeJournal, hashFile, updateEntry, normalizeEtag } from "../journal.js";
14
14
  import { createIgnoreFilter, isWithinSizeLimit } from "../ignore.js";
15
15
  import { resolveConflict } from "./conflict.js";
16
16
  import type { ConflictStrategy } from "./conflict.js";
17
17
  import type { SyncProgressEvent } from "./sync.js";
18
18
 
19
+ /**
20
+ * Stage-1 classification for a single local file in a push run. Pre-HEAD —
21
+ * only inputs we can evaluate locally (size limit, journal hash, optional
22
+ * skip-unchanged) determine the action. Files that pass classification as
23
+ * `upload` are still subject to a per-file HEAD + 3-way conflict check in
24
+ * Stage 2 before the actual PUT, so the `filesToUpload` count in the plan
25
+ * event is an upper bound: it includes files that may turn out to be
26
+ * conflicts. V1.5 follow-up: replace per-file HEAD with a single LIST so
27
+ * conflicts can be classified up-front and reported in the plan.
28
+ */
29
+ type PushPlanItem =
30
+ | {
31
+ action: "upload";
32
+ absolutePath: string;
33
+ relativePath: string;
34
+ localHash: string;
35
+ size: number;
36
+ }
37
+ | {
38
+ action: "skip-size-limit";
39
+ absolutePath: string;
40
+ relativePath: string;
41
+ }
42
+ | {
43
+ action: "skip-unchanged";
44
+ absolutePath: string;
45
+ relativePath: string;
46
+ };
47
+
48
+ interface PushPlan {
49
+ items: PushPlanItem[];
50
+ filesToUpload: number;
51
+ bytesToUpload: number;
52
+ filesToSkip: number;
53
+ }
54
+
55
+ /**
56
+ * Pure Stage-1 pass for push: walk the candidate file list, hash each one,
57
+ * apply the size-limit and skip-unchanged gates, and return a classified
58
+ * plan plus aggregate counts. No S3 calls, no journal writes, no event
59
+ * emission.
60
+ *
61
+ * The conflict count is intentionally absent from the returned `PushPlan` —
62
+ * detecting a push conflict requires a remote HEAD that we defer to Stage 2.
63
+ * Consumers that want a conflict count get it from the `complete` event.
64
+ */
65
+ function computePushPlan(
66
+ filesToShare: { absolutePath: string; relativePath: string }[],
67
+ journal: SyncJournal,
68
+ skipUnchanged: boolean,
69
+ ): PushPlan {
70
+ const items: PushPlanItem[] = [];
71
+
72
+ for (const { absolutePath, relativePath } of filesToShare) {
73
+ if (!isWithinSizeLimit(absolutePath)) {
74
+ items.push({ action: "skip-size-limit", absolutePath, relativePath });
75
+ continue;
76
+ }
77
+
78
+ const localHash = hashFile(absolutePath);
79
+
80
+ if (skipUnchanged) {
81
+ const existing = journal.files[relativePath];
82
+ if (existing && existing.hash === localHash) {
83
+ items.push({ action: "skip-unchanged", absolutePath, relativePath });
84
+ continue;
85
+ }
86
+ }
87
+
88
+ const size = fs.statSync(absolutePath).size;
89
+ items.push({
90
+ action: "upload",
91
+ absolutePath,
92
+ relativePath,
93
+ localHash,
94
+ size,
95
+ });
96
+ }
97
+
98
+ let filesToUpload = 0;
99
+ let bytesToUpload = 0;
100
+ let filesToSkip = 0;
101
+ for (const item of items) {
102
+ if (item.action === "upload") {
103
+ filesToUpload++;
104
+ bytesToUpload += item.size;
105
+ } else {
106
+ filesToSkip++;
107
+ }
108
+ }
109
+
110
+ return { items, filesToUpload, bytesToUpload, filesToSkip };
111
+ }
112
+
19
113
  export interface ShareOptions {
20
114
  /** Path(s) to share (files or directories) */
21
115
  paths: string[];
@@ -94,45 +188,68 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
94
188
  // Collect all files to share
95
189
  const filesToShare = collectFiles(paths, hqRoot, syncRoot, shouldSync);
96
190
 
97
- for (const { absolutePath, relativePath } of filesToShare) {
98
- if (!isWithinSizeLimit(absolutePath)) {
191
+ // Stage 1: classify each file. Pre-HEAD only inputs we can evaluate
192
+ // locally (size limit, journal hash, optional skip-unchanged) are
193
+ // considered. The plan event below carries an upper-bound `filesToUpload`
194
+ // (true conflicts emerge from the per-file HEAD in Stage 2 and aren't
195
+ // knowable here). The final `complete` event reports authoritative counts.
196
+ const plan = computePushPlan(filesToShare, journal, skipUnchanged === true);
197
+
198
+ emit({
199
+ type: "plan",
200
+ // share() is push-only; pull counts are sourced from sync()'s plan event.
201
+ filesToDownload: 0,
202
+ bytesToDownload: 0,
203
+ filesToUpload: plan.filesToUpload,
204
+ bytesToUpload: plan.bytesToUpload,
205
+ filesToSkip: plan.filesToSkip,
206
+ // Push conflicts require a remote HEAD; we don't yet do that in Stage 1,
207
+ // so this stays 0. V1.5 (single LIST) will let us classify them up-front.
208
+ filesToConflict: 0,
209
+ });
210
+
211
+ // Stage 2: execute. Skip items pre-classified as no-ops, then for each
212
+ // upload candidate run the HEAD + 3-way conflict check + actual PUT.
213
+ for (const item of plan.items) {
214
+ if (item.action === "skip-size-limit") {
99
215
  emit({
100
216
  type: "error",
101
- path: relativePath,
217
+ path: item.relativePath,
102
218
  message: "file exceeds size limit",
103
219
  });
104
220
  filesSkipped++;
105
221
  continue;
106
222
  }
107
-
108
- // Skip-if-unchanged gate: the hot path for bidirectional Sync Now. When
109
- // walking an entire company folder, this is what keeps us from re-uploading
110
- // every file every tick. Off by default so `hq share <file>` keeps its
111
- // explicit-intent semantics (user named it, user wants it sent).
112
- const localHash = hashFile(absolutePath);
113
- if (skipUnchanged) {
114
- const existing = journal.files[relativePath];
115
- if (existing && existing.hash === localHash) {
116
- filesSkipped++;
117
- continue;
118
- }
223
+ if (item.action === "skip-unchanged") {
224
+ filesSkipped++;
225
+ continue;
119
226
  }
120
227
 
228
+ const { absolutePath, relativePath, localHash } = item;
229
+
121
230
  // Auto-refresh context if credentials expiring
122
231
  if (isExpiringSoon(ctx.expiresAt)) {
123
232
  ctx = await refreshEntityContext(companyRef, vaultConfig);
124
233
  }
125
234
 
126
- // Check for remote conflict — refuse to overwrite newer remote version
235
+ // Check for remote conflict — refuse to overwrite newer remote version.
236
+ //
237
+ // A real conflict requires BOTH sides to have moved since the last sync.
238
+ // The previous predicate only checked `journalEntry.hash !== localHash`,
239
+ // which mislabelled every local edit as a conflict and (combined with
240
+ // `--on-conflict keep`) silently dropped the user's edit. We now compare
241
+ // the current remote ETag against the one captured at last sync; when
242
+ // missing (legacy entries), we fall back to the same `lastModified >
243
+ // syncedAt` heuristic the pull side uses.
127
244
  const remoteMeta = await headRemoteFile(ctx, relativePath);
128
245
  if (remoteMeta) {
129
246
  const journalEntry = journal.files[relativePath];
247
+ const localChanged = !!journalEntry && journalEntry.hash !== localHash;
248
+ const remoteChanged = !!journalEntry && hasRemoteChanged(remoteMeta, journalEntry);
130
249
 
131
- // If remote has changed since our last sync, it's a conflict
132
- if (journalEntry && journalEntry.hash !== localHash) {
250
+ if (localChanged && remoteChanged) {
133
251
  conflictPaths.push(relativePath);
134
252
 
135
- // Local has changes — check if remote also changed
136
253
  const resolution = await resolveConflict(
137
254
  {
138
255
  path: relativePath,
@@ -171,10 +288,12 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
171
288
  try {
172
289
  const stat = fs.statSync(absolutePath);
173
290
 
174
- await uploadFile(ctx, absolutePath, relativePath);
291
+ const { etag } = await uploadFile(ctx, absolutePath, relativePath);
175
292
 
176
- // Update journal with optional message
177
- updateEntry(journal, relativePath, localHash, stat.size, "up");
293
+ // Update journal with optional message; capture the post-upload ETag
294
+ // so the next sync can distinguish "remote moved since we last wrote"
295
+ // from "user edited locally" without conflating the two.
296
+ updateEntry(journal, relativePath, localHash, stat.size, "up", etag);
178
297
  if (message) {
179
298
  journal.files[relativePath] = {
180
299
  ...journal.files[relativePath],
@@ -215,7 +334,13 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
215
334
  * emitted before `onEvent` was added — tty users see no change.
216
335
  */
217
336
  function defaultConsoleLogger(event: SyncProgressEvent): void {
218
- if (event.type === "progress") {
337
+ if (event.type === "plan") {
338
+ if (event.filesToUpload > 0) {
339
+ console.log(
340
+ `Plan: ${event.filesToUpload} to upload (${event.bytesToUpload} bytes), ${event.filesToSkip} unchanged`,
341
+ );
342
+ }
343
+ } else if (event.type === "progress") {
219
344
  if (event.message) {
220
345
  console.log(` ✓ ${event.path} — "${event.message}"`);
221
346
  } else {
@@ -225,7 +350,7 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
225
350
  console.error(
226
351
  ` ⚠ conflict (${event.direction}): ${event.path} — ${event.resolution}`,
227
352
  );
228
- } else {
353
+ } else if (event.type === "error") {
229
354
  console.error(` ✗ ${event.path} — ${event.message}`);
230
355
  }
231
356
  }
@@ -318,3 +443,21 @@ function isWithin(parent: string, child: string): boolean {
318
443
  const rel = path.relative(parent, child);
319
444
  return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
320
445
  }
446
+
447
+ /**
448
+ * Returns true when the remote object appears to have moved since the
449
+ * journal entry's last-recorded sync. Prefers ETag equality; falls back to
450
+ * `lastModified > syncedAt` for legacy entries written before remoteEtag
451
+ * was tracked. Conservative on tie (`<=` skews "remote unchanged") so an
452
+ * S3-side mtime that exactly equals our syncedAt is not treated as drift.
453
+ */
454
+ function hasRemoteChanged(
455
+ remote: { lastModified: Date; etag: string },
456
+ entry: { syncedAt: string; remoteEtag?: string },
457
+ ): boolean {
458
+ if (entry.remoteEtag) {
459
+ return normalizeEtag(remote.etag) !== entry.remoteEtag;
460
+ }
461
+ const syncedAt = new Date(entry.syncedAt).getTime();
462
+ return remote.lastModified.getTime() > syncedAt;
463
+ }
@@ -330,4 +330,155 @@ describe("sync", () => {
330
330
  // File should be overwritten with mock content
331
331
  expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("mock file content");
332
332
  });
333
+
334
+ it("does NOT flag a pull conflict when only local changed since last sync", async () => {
335
+ // Regression: previously, any local edit to a file that also existed on
336
+ // S3 produced a pull conflict because the predicate only checked
337
+ // `journalEntry.hash !== localHash`. With `--on-conflict keep` this
338
+ // silently dropped local edits during the round-trip. With remoteEtag
339
+ // matching the journal, the remote is known unchanged and the pull
340
+ // phase should leave the local edit alone for the push phase to upload.
341
+ const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
342
+ fs.mkdirSync(companyDocs, { recursive: true });
343
+ fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local edit");
344
+
345
+ fs.writeFileSync(
346
+ journalPath,
347
+ JSON.stringify({
348
+ version: "1",
349
+ lastSync: new Date().toISOString(),
350
+ files: {
351
+ "docs/handoff.md": {
352
+ hash: "stale-hash-from-pre-edit",
353
+ size: 20,
354
+ syncedAt: new Date(Date.now() - 3600000).toISOString(),
355
+ direction: "down",
356
+ // Matches the listRemoteFiles mock's etag for handoff.md.
357
+ remoteEtag: "abc123",
358
+ },
359
+ },
360
+ }),
361
+ );
362
+
363
+ const result = await sync({
364
+ company: "acme",
365
+ onConflict: "keep",
366
+ vaultConfig: mockConfig,
367
+ hqRoot: tmpDir,
368
+ });
369
+
370
+ expect(result.conflicts).toBe(0);
371
+ expect(result.conflictPaths).toEqual([]);
372
+ // Local edit must be preserved (not clobbered by download)
373
+ expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("local edit");
374
+ });
375
+
376
+ it("records remoteEtag from listRemoteFiles on the journal entry after download", async () => {
377
+ await sync({
378
+ company: "acme",
379
+ vaultConfig: mockConfig,
380
+ hqRoot: tmpDir,
381
+ });
382
+
383
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
384
+ expect(journal.files["docs/handoff.md"].remoteEtag).toBe("abc123");
385
+ expect(journal.files["knowledge/readme.md"].remoteEtag).toBe("def456");
386
+ });
387
+
388
+ // ── Stage-1 plan event ─────────────────────────────────────────────────
389
+
390
+ it("emits a plan event before any progress events", async () => {
391
+ const events: { type: string }[] = [];
392
+ await sync({
393
+ company: "acme",
394
+ vaultConfig: mockConfig,
395
+ hqRoot: tmpDir,
396
+ onEvent: (e) => events.push({ type: e.type }),
397
+ });
398
+
399
+ // Plan must be the first event so consumers can use its totals as
400
+ // the progress denominator before any per-file events arrive.
401
+ expect(events.length).toBeGreaterThan(0);
402
+ expect(events[0].type).toBe("plan");
403
+ const planIndex = events.findIndex((e) => e.type === "plan");
404
+ const firstProgressIndex = events.findIndex((e) => e.type === "progress");
405
+ expect(firstProgressIndex).toBeGreaterThan(planIndex);
406
+ });
407
+
408
+ it("plan event totals reflect the upcoming Stage-2 work (all-new case)", async () => {
409
+ // Both mock remote files are new locally → both counted as downloads,
410
+ // bytes summed from listRemoteFiles, no conflicts, no skips.
411
+ const planEvents: unknown[] = [];
412
+ await sync({
413
+ company: "acme",
414
+ vaultConfig: mockConfig,
415
+ hqRoot: tmpDir,
416
+ onEvent: (e) => {
417
+ if (e.type === "plan") {
418
+ planEvents.push(e);
419
+ }
420
+ },
421
+ });
422
+
423
+ expect(planEvents).toHaveLength(1);
424
+ expect(planEvents[0]).toMatchObject({
425
+ type: "plan",
426
+ filesToDownload: 2,
427
+ bytesToDownload: 142, // 42 + 100 from the s3 mock
428
+ filesToUpload: 0, // sync() never plans uploads
429
+ bytesToUpload: 0,
430
+ filesToSkip: 0,
431
+ filesToConflict: 0,
432
+ });
433
+ });
434
+
435
+ it("plan event counts a 3-way conflict separately from downloads", async () => {
436
+ // Local edit + journal-tracked + remote ETag drifted → conflict.
437
+ const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
438
+ fs.mkdirSync(companyDocs, { recursive: true });
439
+ fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local edit");
440
+
441
+ fs.writeFileSync(
442
+ journalPath,
443
+ JSON.stringify({
444
+ version: "1",
445
+ lastSync: new Date().toISOString(),
446
+ files: {
447
+ "docs/handoff.md": {
448
+ hash: "stale-hash-from-pre-edit",
449
+ size: 20,
450
+ syncedAt: new Date(Date.now() - 3600000).toISOString(),
451
+ direction: "down",
452
+ // Mismatched ETag — listRemoteFiles mock returns "abc123",
453
+ // we record a stale one so remoteChanged is true.
454
+ remoteEtag: "stale-remote-etag",
455
+ },
456
+ },
457
+ }),
458
+ );
459
+
460
+ const planEvents: Array<{
461
+ type: string;
462
+ filesToDownload?: number;
463
+ filesToConflict?: number;
464
+ filesToSkip?: number;
465
+ }> = [];
466
+ await sync({
467
+ company: "acme",
468
+ onConflict: "keep",
469
+ vaultConfig: mockConfig,
470
+ hqRoot: tmpDir,
471
+ onEvent: (e) => {
472
+ if (e.type === "plan") planEvents.push(e);
473
+ },
474
+ });
475
+
476
+ expect(planEvents).toHaveLength(1);
477
+ // Conflict is counted separately; only the new file is in toDownload.
478
+ expect(planEvents[0]).toMatchObject({
479
+ filesToDownload: 1,
480
+ filesToConflict: 1,
481
+ filesToSkip: 0,
482
+ });
483
+ });
333
484
  });