@indigoai-us/hq-cloud 5.3.0 → 5.4.1

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
@@ -52,6 +52,12 @@ export interface ShareResult {
52
52
  filesUploaded: number;
53
53
  bytesUploaded: number;
54
54
  filesSkipped: number;
55
+ /**
56
+ * Paths (company-relative) that were detected as push conflicts. Mirrors
57
+ * `SyncResult.conflictPaths` so push and pull surface conflicts the same
58
+ * way to runner/UI consumers.
59
+ */
60
+ conflictPaths: string[];
55
61
  aborted: boolean;
56
62
  }
57
63
 
@@ -83,6 +89,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
83
89
  let filesUploaded = 0;
84
90
  let bytesUploaded = 0;
85
91
  let filesSkipped = 0;
92
+ const conflictPaths: string[] = [];
86
93
 
87
94
  // Collect all files to share
88
95
  const filesToShare = collectFiles(paths, hqRoot, syncRoot, shouldSync);
@@ -123,6 +130,8 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
123
130
 
124
131
  // If remote has changed since our last sync, it's a conflict
125
132
  if (journalEntry && journalEntry.hash !== localHash) {
133
+ conflictPaths.push(relativePath);
134
+
126
135
  // Local has changes — check if remote also changed
127
136
  const resolution = await resolveConflict(
128
137
  {
@@ -134,8 +143,21 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
134
143
  onConflict,
135
144
  );
136
145
 
146
+ emit({
147
+ type: "conflict",
148
+ path: relativePath,
149
+ direction: "push",
150
+ resolution,
151
+ });
152
+
137
153
  if (resolution === "abort") {
138
- return { filesUploaded, bytesUploaded, filesSkipped, aborted: true };
154
+ return {
155
+ filesUploaded,
156
+ bytesUploaded,
157
+ filesSkipped,
158
+ conflictPaths,
159
+ aborted: true,
160
+ };
139
161
  }
140
162
  if (resolution === "keep" || resolution === "skip") {
141
163
  filesSkipped++;
@@ -179,7 +201,13 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
179
201
 
180
202
  writeJournal(ctx.slug, journal);
181
203
 
182
- return { filesUploaded, bytesUploaded, filesSkipped, aborted: false };
204
+ return {
205
+ filesUploaded,
206
+ bytesUploaded,
207
+ filesSkipped,
208
+ conflictPaths,
209
+ aborted: false,
210
+ };
183
211
  }
184
212
 
185
213
  /**
@@ -193,6 +221,10 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
193
221
  } else {
194
222
  console.log(` ✓ ${event.path}`);
195
223
  }
224
+ } else if (event.type === "conflict") {
225
+ console.error(
226
+ ` ⚠ conflict (${event.direction}): ${event.path} — ${event.resolution}`,
227
+ );
196
228
  } else {
197
229
  console.error(` ✗ ${event.path} — ${event.message}`);
198
230
  }
@@ -176,10 +176,54 @@ describe("sync", () => {
176
176
  });
177
177
 
178
178
  expect(result.conflicts).toBe(1);
179
+ expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
179
180
  expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
180
181
  expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("local version");
181
182
  });
182
183
 
184
+ it("emits a conflict event with path + resolution on hash mismatch", async () => {
185
+ const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
186
+ fs.mkdirSync(companyDocs, { recursive: true });
187
+ fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
188
+
189
+ fs.writeFileSync(
190
+ journalPath,
191
+ JSON.stringify({
192
+ version: "1",
193
+ lastSync: new Date().toISOString(),
194
+ files: {
195
+ "docs/handoff.md": {
196
+ hash: "stale-hash",
197
+ size: 20,
198
+ syncedAt: new Date(Date.now() - 3600000).toISOString(),
199
+ direction: "down",
200
+ },
201
+ },
202
+ }),
203
+ );
204
+
205
+ const events: unknown[] = [];
206
+ await sync({
207
+ company: "acme",
208
+ onConflict: "keep",
209
+ vaultConfig: mockConfig,
210
+ hqRoot: tmpDir,
211
+ onEvent: (e) => events.push(e),
212
+ });
213
+
214
+ const conflicts = events.filter(
215
+ (e): e is { type: "conflict"; path: string; direction: "pull"; resolution: string } =>
216
+ typeof e === "object" && e !== null && (e as { type?: string }).type === "conflict",
217
+ );
218
+ expect(conflicts).toHaveLength(1);
219
+ expect(conflicts[0]).toMatchObject({
220
+ type: "conflict",
221
+ path: "docs/handoff.md",
222
+ direction: "pull",
223
+ resolution: "keep",
224
+ });
225
+ });
226
+
183
227
  it("aborts on --on-conflict abort", async () => {
184
228
  const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
185
229
  fs.mkdirSync(companyDocs, { recursive: true });
package/src/cli/sync.ts CHANGED
@@ -13,7 +13,7 @@ import { downloadFile, listRemoteFiles } from "../s3.js";
13
13
  import { readJournal, writeJournal, hashFile, updateEntry, getEntry } from "../journal.js";
14
14
  import { createIgnoreFilter } from "../ignore.js";
15
15
  import { resolveConflict } from "./conflict.js";
16
- import type { ConflictStrategy } from "./conflict.js";
16
+ import type { ConflictStrategy, ConflictResolution } from "./conflict.js";
17
17
 
18
18
  /**
19
19
  * Per-file events emitted by `sync()` as it progresses.
@@ -28,7 +28,13 @@ import type { ConflictStrategy } from "./conflict.js";
28
28
  */
29
29
  export type SyncProgressEvent =
30
30
  | { type: "progress"; path: string; bytes: number; message?: string }
31
- | { type: "error"; path: string; message: string };
31
+ | { type: "error"; path: string; message: string }
32
+ | {
33
+ type: "conflict";
34
+ path: string;
35
+ direction: "pull" | "push";
36
+ resolution: ConflictResolution;
37
+ };
32
38
 
33
39
  export interface SyncOptions {
34
40
  /** Company slug or UID (defaults to active company from config) */
@@ -66,6 +72,12 @@ export interface SyncResult {
66
72
  bytesDownloaded: number;
67
73
  filesSkipped: number;
68
74
  conflicts: number;
75
+ /**
76
+ * Paths (remote keys) that were detected as conflicts during this run.
77
+ * Always populated when `conflicts > 0` so callers can surface them in UI
78
+ * or logs without re-streaming the per-file events.
79
+ */
80
+ conflictPaths: string[];
69
81
  aborted: boolean;
70
82
  }
71
83
 
@@ -106,6 +118,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
106
118
  let bytesDownloaded = 0;
107
119
  let filesSkipped = 0;
108
120
  let conflicts = 0;
121
+ const conflictPaths: string[] = [];
109
122
 
110
123
  // List all remote files (IAM session policy filters at the AWS layer)
111
124
  const remoteFiles = await listRemoteFiles(ctx);
@@ -138,6 +151,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
138
151
  // If local file has changed since last sync, it's a conflict
139
152
  if (journalEntry && journalEntry.hash !== localHash) {
140
153
  conflicts++;
154
+ conflictPaths.push(remoteFile.key);
141
155
 
142
156
  const resolution = await resolveConflict(
143
157
  {
@@ -150,9 +164,23 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
150
164
  onConflict,
151
165
  );
152
166
 
167
+ emit({
168
+ type: "conflict",
169
+ path: remoteFile.key,
170
+ direction: "pull",
171
+ resolution,
172
+ });
173
+
153
174
  if (resolution === "abort") {
154
175
  writeJournal(journalSlug, journal);
155
- return { filesDownloaded, bytesDownloaded, filesSkipped, conflicts, aborted: true };
176
+ return {
177
+ filesDownloaded,
178
+ bytesDownloaded,
179
+ filesSkipped,
180
+ conflicts,
181
+ conflictPaths,
182
+ aborted: true,
183
+ };
156
184
  }
157
185
  if (resolution === "keep" || resolution === "skip") {
158
186
  filesSkipped++;
@@ -208,7 +236,14 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
208
236
 
209
237
  writeJournal(journalSlug, journal);
210
238
 
211
- return { filesDownloaded, bytesDownloaded, filesSkipped, conflicts, aborted: false };
239
+ return {
240
+ filesDownloaded,
241
+ bytesDownloaded,
242
+ filesSkipped,
243
+ conflicts,
244
+ conflictPaths,
245
+ aborted: false,
246
+ };
212
247
  }
213
248
 
214
249
  /**
@@ -251,5 +286,9 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
251
286
  }
252
287
  } else if (event.type === "error") {
253
288
  console.error(` ✗ ${event.path} — ${event.message}`);
289
+ } else if (event.type === "conflict") {
290
+ console.error(
291
+ ` ⚠ conflict (${event.direction}): ${event.path} — ${event.resolution}`,
292
+ );
254
293
  }
255
294
  }
@@ -38,6 +38,22 @@ describe("createIgnoreFilter", () => {
38
38
  expect(shouldSync(path.join(hqRoot, "companies/indigo/notes.md"))).toBe(true);
39
39
  });
40
40
 
41
+ it("permissive mode: .hq-* internal state is ignored, .hqignore family + .hq/ still sync", () => {
42
+ const shouldSync = createIgnoreFilter(hqRoot);
43
+ // Internal state files that must never round-trip through the bucket.
44
+ expect(shouldSync(path.join(hqRoot, ".hq-sync.pid"))).toBe(false);
45
+ expect(shouldSync(path.join(hqRoot, ".hq-sync-journal.json"))).toBe(false);
46
+ expect(shouldSync(path.join(hqRoot, ".hq-sync-state.json"))).toBe(false);
47
+ expect(shouldSync(path.join(hqRoot, ".hq-embeddings-pending.json"))).toBe(false);
48
+ expect(shouldSync(path.join(hqRoot, "companies/indigo/.hq-foo.json"))).toBe(false);
49
+ expect(shouldSync(path.join(hqRoot, ".hq-cache/blob.bin"))).toBe(false);
50
+ // Sync-config files and the .hq/ directory still sync.
51
+ expect(shouldSync(path.join(hqRoot, ".hqignore"))).toBe(true);
52
+ expect(shouldSync(path.join(hqRoot, ".hqsyncignore"))).toBe(true);
53
+ expect(shouldSync(path.join(hqRoot, ".hqinclude"))).toBe(true);
54
+ expect(shouldSync(path.join(hqRoot, "companies/indigo/.hq/config.json"))).toBe(true);
55
+ });
56
+
41
57
  it("allowlist mode: presence of .hqinclude switches to opt-in", () => {
42
58
  fs.writeFileSync(
43
59
  path.join(hqRoot, ".hqinclude"),
package/src/ignore.ts CHANGED
@@ -67,11 +67,13 @@ export const DEFAULT_IGNORES = [
67
67
  "tmp/",
68
68
  ".tmp/",
69
69
 
70
- // HQ sync internal state (never round-trip these)
70
+ // HQ sync internal state (never round-trip these). The `.hq-*` wildcard
71
+ // covers `.hq-sync.pid`, `.hq-sync-journal.json`, `.hq-sync-state.json`,
72
+ // `.hq-embeddings-pending.json`, and any future internal-state file. The
73
+ // `.hqignore` / `.hqsyncignore` / `.hqinclude` config files don't match
74
+ // (no hyphen) and the `.hq/` directory is unaffected.
71
75
  "*.pid",
72
- ".hq-sync.pid",
73
- ".hq-sync-journal.json",
74
- ".hq-sync-state.json",
76
+ ".hq-*",
75
77
  "modules.lock",
76
78
 
77
79
  // HQ repos directory (managed separately, not synced)