@indigoai-us/hq-cloud 5.4.4 → 5.4.6

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
@@ -10,7 +10,7 @@ import * as path from "path";
10
10
  import type { VaultServiceConfig } from "../types.js";
11
11
  import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
12
12
  import { downloadFile, listRemoteFiles } from "../s3.js";
13
- import { readJournal, writeJournal, hashFile, updateEntry, getEntry } from "../journal.js";
13
+ import { readJournal, writeJournal, hashFile, updateEntry, getEntry, normalizeEtag } from "../journal.js";
14
14
  import { createIgnoreFilter } from "../ignore.js";
15
15
  import { resolveConflict } from "./conflict.js";
16
16
  import type { ConflictStrategy, ConflictResolution } from "./conflict.js";
@@ -147,9 +147,14 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
147
147
 
148
148
  if (fs.existsSync(localPath)) {
149
149
  const localHash = hashFile(localPath);
150
+ const localChanged = !!journalEntry && journalEntry.hash !== localHash;
151
+ const remoteChanged = !!journalEntry && hasRemoteChanged(remoteFile, journalEntry);
150
152
 
151
- // If local file has changed since last sync, it's a conflict
152
- if (journalEntry && journalEntry.hash !== localHash) {
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) {
153
158
  conflicts++;
154
159
  conflictPaths.push(remoteFile.key);
155
160
 
@@ -187,17 +192,18 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
187
192
  continue;
188
193
  }
189
194
  // "overwrite" falls through to download
190
- } else if (journalEntry && journalEntry.hash === localHash) {
191
- // Local unchanged since last sync check if remote changed
192
- // by comparing etag/timestamp
193
- const lastSyncTime = new Date(journalEntry.syncedAt).getTime();
194
- const remoteModTime = remoteFile.lastModified.getTime();
195
- if (remoteModTime <= lastSyncTime) {
196
- // Remote hasn't changed either skip
197
- filesSkipped++;
198
- continue;
199
- }
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 movednothing to do.
202
+ filesSkipped++;
203
+ continue;
200
204
  }
205
+ // Otherwise (no journal entry, or remote-only changed) fall through
206
+ // to download.
201
207
  }
202
208
 
203
209
  // Download
@@ -206,7 +212,9 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
206
212
 
207
213
  const hash = hashFile(localPath);
208
214
  const stat = fs.statSync(localPath);
209
- updateEntry(journal, remoteFile.key, hash, stat.size, "down");
215
+ // Capture the listing's ETag so subsequent syncs can detect remote
216
+ // drift independently of mtime drift.
217
+ updateEntry(journal, remoteFile.key, hash, stat.size, "down", remoteFile.etag);
210
218
 
211
219
  // Attach message from journal entry if present
212
220
  const remoteJournalMessage = (journalEntry as { message?: string } | undefined)?.message;
@@ -262,6 +270,23 @@ function resolveActiveCompany(hqRoot: string): string | undefined {
262
270
  return undefined;
263
271
  }
264
272
 
273
+ /**
274
+ * Returns true when the remote object appears to have moved since the
275
+ * journal entry's last-recorded sync. Prefers ETag equality; falls back to
276
+ * `lastModified > syncedAt` for legacy entries written before remoteEtag
277
+ * was tracked. Conservative on tie (`<=` skews "remote unchanged").
278
+ */
279
+ function hasRemoteChanged(
280
+ remote: { lastModified: Date; etag: string },
281
+ entry: { syncedAt: string; remoteEtag?: string },
282
+ ): boolean {
283
+ if (entry.remoteEtag) {
284
+ return normalizeEtag(remote.etag) !== entry.remoteEtag;
285
+ }
286
+ const syncedAt = new Date(entry.syncedAt).getTime();
287
+ return remote.lastModified.getTime() > syncedAt;
288
+ }
289
+
265
290
  /**
266
291
  * Check if an error is an S3 access denied (expected for filtered guests).
267
292
  */
@@ -61,6 +61,15 @@ describe("createIgnoreFilter", () => {
61
61
  expect(shouldSync(path.join(hqRoot, "company.yaml"))).toBe(false);
62
62
  });
63
63
 
64
+ it("permissive mode: INDEX.md and policies/_digest.md are ignored", () => {
65
+ // Both are auto-generated locally — INDEX.md by the tools indexer and
66
+ // policies/_digest.md by the policies digest builder.
67
+ const shouldSync = createIgnoreFilter(hqRoot);
68
+ expect(shouldSync(path.join(hqRoot, "INDEX.md"))).toBe(false);
69
+ expect(shouldSync(path.join(hqRoot, "companies/ghq/tools/INDEX.md"))).toBe(false);
70
+ expect(shouldSync(path.join(hqRoot, "policies/_digest.md"))).toBe(false);
71
+ });
72
+
64
73
  it("permissive mode: .hq-* internal state is ignored, .hqignore family + .hq/ still sync", () => {
65
74
  const shouldSync = createIgnoreFilter(hqRoot);
66
75
  // Internal state files that must never round-trip through the bucket.
package/src/ignore.ts CHANGED
@@ -81,6 +81,9 @@ export const DEFAULT_IGNORES = [
81
81
  "modules/modules.yaml",
82
82
  // per-company identity file — written locally on first sync, never round-tripped.
83
83
  "company.yaml",
84
+ // auto-generated tool index and policy digest — regenerated locally per-machine.
85
+ "INDEX.md",
86
+ "policies/_digest.md",
84
87
 
85
88
  // HQ repos directory (managed separately, not synced)
86
89
  "repos/",
package/src/journal.ts CHANGED
@@ -80,16 +80,31 @@ export function updateEntry(
80
80
  hash: string,
81
81
  size: number,
82
82
  direction: "up" | "down",
83
+ remoteEtag?: string,
83
84
  ): void {
84
- journal.files[relativePath] = {
85
+ const entry: JournalEntry = {
85
86
  hash,
86
87
  size,
87
88
  syncedAt: new Date().toISOString(),
88
89
  direction,
89
90
  };
91
+ if (remoteEtag !== undefined && remoteEtag !== "") {
92
+ entry.remoteEtag = normalizeEtag(remoteEtag);
93
+ }
94
+ journal.files[relativePath] = entry;
90
95
  journal.lastSync = new Date().toISOString();
91
96
  }
92
97
 
98
+ /**
99
+ * S3 returns ETags wrapped in literal double-quotes (e.g. `"d41d8cd9..."`).
100
+ * Strip them so equality comparisons across HEAD / GET / PUT responses are
101
+ * stable regardless of which AWS SDK call surfaced the value.
102
+ */
103
+ export function normalizeEtag(etag: string): string {
104
+ if (!etag) return "";
105
+ return etag.replace(/^"|"$/g, "");
106
+ }
107
+
93
108
  export function getEntry(
94
109
  journal: SyncJournal,
95
110
  relativePath: string,
package/src/s3.ts CHANGED
@@ -38,11 +38,11 @@ export async function uploadFile(
38
38
  ctx: EntityContext,
39
39
  localPath: string,
40
40
  key: string,
41
- ): Promise<void> {
41
+ ): Promise<{ etag: string }> {
42
42
  const client = buildClient(ctx);
43
43
  const body = fs.readFileSync(localPath);
44
44
 
45
- await client.send(
45
+ const response = await client.send(
46
46
  new PutObjectCommand({
47
47
  Bucket: ctx.bucketName,
48
48
  Key: key,
@@ -50,6 +50,8 @@ export async function uploadFile(
50
50
  ContentType: getMimeType(key),
51
51
  }),
52
52
  );
53
+
54
+ return { etag: response.ETag || "" };
53
55
  }
54
56
 
55
57
  export async function downloadFile(
package/src/types.ts CHANGED
@@ -26,6 +26,14 @@ export interface JournalEntry {
26
26
  size: number;
27
27
  syncedAt: string;
28
28
  direction: "up" | "down";
29
+ /**
30
+ * S3 ETag of the remote object as of last successful sync, normalized (no
31
+ * surrounding quotes). Optional for backwards compatibility: entries
32
+ * written before this field existed won't have it, in which case
33
+ * conflict detection falls back to comparing remote `lastModified`
34
+ * against `syncedAt`.
35
+ */
36
+ remoteEtag?: string;
29
37
  }
30
38
 
31
39
  export interface SyncJournal {
package/src/watcher.ts CHANGED
@@ -113,8 +113,8 @@ export class SyncWatcher {
113
113
  const existing = journal.files[relativePath];
114
114
  if (existing && existing.hash === hash) continue;
115
115
 
116
- await uploadFile(this.ctx, change.absolutePath, relativePath);
117
- updateEntry(journal, relativePath, hash, stat.size, "up");
116
+ const { etag } = await uploadFile(this.ctx, change.absolutePath, relativePath);
117
+ updateEntry(journal, relativePath, hash, stat.size, "up", etag);
118
118
  }
119
119
  } catch (err) {
120
120
  console.error(