@indigoai-us/hq-cloud 6.15.79 → 6.15.81

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.
Files changed (100) hide show
  1. package/dist/bin/area-ledger-migration.d.ts +13 -0
  2. package/dist/bin/area-ledger-migration.d.ts.map +1 -0
  3. package/dist/bin/area-ledger-migration.js +64 -0
  4. package/dist/bin/area-ledger-migration.js.map +1 -0
  5. package/dist/bin/area-ledger-migration.test.d.ts +2 -0
  6. package/dist/bin/area-ledger-migration.test.d.ts.map +1 -0
  7. package/dist/bin/area-ledger-migration.test.js +55 -0
  8. package/dist/bin/area-ledger-migration.test.js.map +1 -0
  9. package/dist/bin/sync-runner-company.d.ts +6 -0
  10. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  11. package/dist/bin/sync-runner-company.js +21 -0
  12. package/dist/bin/sync-runner-company.js.map +1 -1
  13. package/dist/bin/sync-runner-company.test.js +112 -0
  14. package/dist/bin/sync-runner-company.test.js.map +1 -1
  15. package/dist/bin/sync-runner-events.test.js +20 -0
  16. package/dist/bin/sync-runner-events.test.js.map +1 -1
  17. package/dist/bin/sync-runner-watch-loop.d.ts +7 -0
  18. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  19. package/dist/bin/sync-runner-watch-loop.js +308 -20
  20. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  21. package/dist/bin/sync-runner.d.ts +5 -0
  22. package/dist/bin/sync-runner.d.ts.map +1 -1
  23. package/dist/bin/sync-runner.js.map +1 -1
  24. package/dist/bin/sync-runner.test.js +144 -1
  25. package/dist/bin/sync-runner.test.js.map +1 -1
  26. package/dist/cli/share.d.ts +8 -0
  27. package/dist/cli/share.d.ts.map +1 -1
  28. package/dist/cli/share.js +213 -15
  29. package/dist/cli/share.js.map +1 -1
  30. package/dist/cli/share.test.js +461 -31
  31. package/dist/cli/share.test.js.map +1 -1
  32. package/dist/cli/sync.d.ts +30 -0
  33. package/dist/cli/sync.d.ts.map +1 -1
  34. package/dist/cli/sync.js +83 -8
  35. package/dist/cli/sync.js.map +1 -1
  36. package/dist/cli/sync.test.js +224 -0
  37. package/dist/cli/sync.test.js.map +1 -1
  38. package/dist/cli/watch-event-push-conflict.test.js +22 -11
  39. package/dist/cli/watch-event-push-conflict.test.js.map +1 -1
  40. package/dist/journal-store.test.js +42 -1
  41. package/dist/journal-store.test.js.map +1 -1
  42. package/dist/journal.d.ts +12 -1
  43. package/dist/journal.d.ts.map +1 -1
  44. package/dist/journal.js +103 -6
  45. package/dist/journal.js.map +1 -1
  46. package/dist/s3.d.ts +62 -0
  47. package/dist/s3.d.ts.map +1 -1
  48. package/dist/s3.js +91 -1
  49. package/dist/s3.js.map +1 -1
  50. package/dist/s3.rename-retry.test.d.ts +2 -0
  51. package/dist/s3.rename-retry.test.d.ts.map +1 -0
  52. package/dist/s3.rename-retry.test.js +153 -0
  53. package/dist/s3.rename-retry.test.js.map +1 -0
  54. package/dist/sync/area-ledger-migration.d.ts +116 -0
  55. package/dist/sync/area-ledger-migration.d.ts.map +1 -0
  56. package/dist/sync/area-ledger-migration.js +505 -0
  57. package/dist/sync/area-ledger-migration.js.map +1 -0
  58. package/dist/sync/area-ledger-migration.test.d.ts +2 -0
  59. package/dist/sync/area-ledger-migration.test.d.ts.map +1 -0
  60. package/dist/sync/area-ledger-migration.test.js +274 -0
  61. package/dist/sync/area-ledger-migration.test.js.map +1 -0
  62. package/dist/sync/feature-flags.d.ts +9 -0
  63. package/dist/sync/feature-flags.d.ts.map +1 -1
  64. package/dist/sync/feature-flags.js +19 -0
  65. package/dist/sync/feature-flags.js.map +1 -1
  66. package/dist/sync/feature-flags.test.js +11 -1
  67. package/dist/sync/feature-flags.test.js.map +1 -1
  68. package/dist/sync/index.d.ts +2 -0
  69. package/dist/sync/index.d.ts.map +1 -1
  70. package/dist/sync/index.js +1 -0
  71. package/dist/sync/index.js.map +1 -1
  72. package/dist/sync/parallel-sync-coordinator.d.ts.map +1 -1
  73. package/dist/sync/parallel-sync-coordinator.js +19 -4
  74. package/dist/sync/parallel-sync-coordinator.js.map +1 -1
  75. package/dist/sync/parallel-sync-coordinator.test.js +59 -0
  76. package/dist/sync/parallel-sync-coordinator.test.js.map +1 -1
  77. package/dist/sync/reconcile-cursor.d.ts +97 -0
  78. package/dist/sync/reconcile-cursor.d.ts.map +1 -0
  79. package/dist/sync/reconcile-cursor.js +215 -0
  80. package/dist/sync/reconcile-cursor.js.map +1 -0
  81. package/dist/sync/reconcile-cursor.test.d.ts +2 -0
  82. package/dist/sync/reconcile-cursor.test.d.ts.map +1 -0
  83. package/dist/sync/reconcile-cursor.test.js +139 -0
  84. package/dist/sync/reconcile-cursor.test.js.map +1 -0
  85. package/dist/sync/state-store.d.ts +37 -0
  86. package/dist/sync/state-store.d.ts.map +1 -1
  87. package/dist/sync/state-store.js +109 -1
  88. package/dist/sync/state-store.js.map +1 -1
  89. package/dist/sync/sync-work.d.ts +2 -1
  90. package/dist/sync/sync-work.d.ts.map +1 -1
  91. package/dist/sync/sync-work.js.map +1 -1
  92. package/dist/types.d.ts +6 -0
  93. package/dist/types.d.ts.map +1 -1
  94. package/dist/vault-client.d.ts +9 -2
  95. package/dist/vault-client.d.ts.map +1 -1
  96. package/dist/vault-client.js +3 -2
  97. package/dist/vault-client.js.map +1 -1
  98. package/dist/vault-client.test.js +26 -0
  99. package/dist/vault-client.test.js.map +1 -1
  100. package/package.json +3 -2
@@ -17,30 +17,40 @@ vi.mock("fs", async (importOriginal) => {
17
17
  // response shape is `{ ETag: '"<hex>"' }`. uploadSymlink is the symlink-
18
18
  // preserving sibling that puts a zero-byte object with target metadata
19
19
  // instead of dereferencing the link.
20
- vi.mock("../s3.js", () => ({
21
- toPosixKey: (key) => key.split("\\").join("/"),
22
- uploadFile: vi.fn().mockResolvedValue({ etag: '"upload-etag"' }),
23
- uploadSymlink: vi.fn().mockResolvedValue({ etag: '"upload-symlink-etag"' }),
24
- downloadFile: vi.fn().mockResolvedValue(undefined),
25
- listRemoteFiles: vi.fn().mockResolvedValue([]),
26
- deleteRemoteFile: vi.fn().mockResolvedValue(undefined),
27
- headRemoteFile: vi.fn().mockResolvedValue(null),
28
- readVerifiedRemoteSymlinkTarget: vi.fn().mockResolvedValue(null),
29
- primeObjectTransport: vi.fn().mockResolvedValue(undefined),
30
- primeUploads: vi.fn().mockResolvedValue(undefined),
31
- // Real exports share.ts now imports for the HQ-DESKTOP-54 privilege skip.
32
- WINDOWS_SYMLINK_PRIVILEGE_REMEDY: "Enable Windows Developer Mode (Settings > Privacy & security > For developers) or run HQ sync once from an elevated terminal, then sync again.",
33
- WindowsSymlinkPrivilegeError: class WindowsSymlinkPrivilegeError extends Error {
34
- target;
35
- code;
36
- constructor(target, code) {
37
- super(`symlink skipped: creating a file symlink to ${target} requires a Windows privilege`);
38
- this.name = "WindowsSymlinkPrivilegeError";
39
- this.target = target;
40
- this.code = code;
41
- }
42
- },
43
- }));
20
+ vi.mock("../s3.js", async (importOriginal) => {
21
+ const actual = await importOriginal();
22
+ return {
23
+ toPosixKey: (key) => key.split("\\").join("/"),
24
+ uploadFile: vi.fn().mockResolvedValue({ etag: '"upload-etag"' }),
25
+ uploadSymlink: vi.fn().mockResolvedValue({ etag: '"upload-symlink-etag"' }),
26
+ downloadFile: vi.fn().mockResolvedValue(undefined),
27
+ listRemoteFiles: vi.fn().mockResolvedValue([]),
28
+ deleteRemoteFile: vi.fn().mockResolvedValue(undefined),
29
+ headRemoteFile: vi.fn().mockResolvedValue(null),
30
+ readVerifiedRemoteSymlinkTarget: vi.fn().mockResolvedValue(null),
31
+ primeObjectTransport: vi.fn().mockResolvedValue(undefined),
32
+ primeUploads: vi.fn().mockResolvedValue(undefined),
33
+ // Real exports share.ts now imports for the HQ-DESKTOP-54 privilege skip.
34
+ WINDOWS_SYMLINK_PRIVILEGE_REMEDY: "Enable Windows Developer Mode (Settings > Privacy & security > For developers) or run HQ sync once from an elevated terminal, then sync again.",
35
+ WindowsSymlinkPrivilegeError: class WindowsSymlinkPrivilegeError extends Error {
36
+ target;
37
+ code;
38
+ constructor(target, code) {
39
+ super(`symlink skipped: creating a file symlink to ${target} requires a Windows privilege`);
40
+ this.name = "WindowsSymlinkPrivilegeError";
41
+ this.target = target;
42
+ this.code = code;
43
+ }
44
+ },
45
+ // HQ-DESKTOP-54 (second path): the real win32 rename guard, typed error, and
46
+ // remedy that share.ts now imports. Real so `instanceof` matches the error the
47
+ // real helper throws, and the POSIX pass-through keeps existing push tests
48
+ // byte-identical.
49
+ renameOrRaiseWindowsBlocked: actual.renameOrRaiseWindowsBlocked,
50
+ WindowsRenameBlockedError: actual.WindowsRenameBlockedError,
51
+ WINDOWS_RENAME_BLOCKED_REMEDY: actual.WINDOWS_RENAME_BLOCKED_REMEDY,
52
+ };
53
+ });
44
54
  // Mock readline so the interactive-conflict serialization test can observe
45
55
  // prompt lifecycle (createInterface + close) without ever touching stdin.
46
56
  // Default impl never resolves; conflict-prompt tests below override per-test
@@ -56,6 +66,7 @@ import { share, ServerOwnedPushPathsError, UnreachablePushPathsError, Unregister
56
66
  import { deleteRemoteFile, downloadFile, headRemoteFile, primeObjectTransport, primeUploads, uploadFile, uploadSymlink, WindowsSymlinkPrivilegeError, } from "../s3.js";
57
67
  import { VaultAuthError, } from "../vault-client.js";
58
68
  import { hashFile, readJournal, writeJournal } from "../journal.js";
69
+ import { ReconcileCursorStore } from "../sync/reconcile-cursor.js";
59
70
  /**
60
71
  * Read a journal the way production does.
61
72
  *
@@ -144,6 +155,8 @@ describe("company skill identity preflight", () => {
144
155
  stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-share-skill-state-"));
145
156
  process.env.HQ_STATE_DIR = stateDir;
146
157
  vi.mocked(uploadFile).mockClear();
158
+ vi.mocked(headRemoteFile).mockReset();
159
+ vi.mocked(headRemoteFile).mockResolvedValue(null);
147
160
  });
148
161
  afterEach(() => {
149
162
  fs.rmSync(hqRoot, { recursive: true, force: true });
@@ -157,13 +170,16 @@ describe("company skill identity preflight", () => {
157
170
  return file;
158
171
  }
159
172
  function registered(input, uid = "skl_PREFLIGHT1") {
173
+ const stamped = parseRegisteredCompanySkillUid(input.content)
174
+ ? input.content
175
+ : input.content.replace("---\n", `---\nskill_uid: ${uid}\n`);
160
176
  return {
161
177
  ok: true,
162
178
  skillUid: uid,
163
179
  path: input.path,
164
180
  vaultPath: input.path,
165
- content: input.content.replace("---\n", `---\nskill_uid: ${uid}\n`),
166
- action: "created",
181
+ content: stamped,
182
+ action: parseRegisteredCompanySkillUid(input.content) ? "reused" : "created",
167
183
  accessPolicy: "open",
168
184
  skill: { name: "Call Prep", description: "Prepare calls", tags: [] },
169
185
  };
@@ -177,7 +193,7 @@ describe("company skill identity preflight", () => {
177
193
  entityContext: makeEntityContext(),
178
194
  registerCompanySkill: registrar,
179
195
  });
180
- expect(registrar).toHaveBeenCalledWith({
196
+ expect(registrar).toHaveBeenNthCalledWith(1, {
181
197
  companySlug: "acme",
182
198
  path: "skills/call-prep/SKILL.md",
183
199
  content: expect.stringContaining("name: Call Prep"),
@@ -185,6 +201,12 @@ describe("company skill identity preflight", () => {
185
201
  expect(parseRegisteredCompanySkillUid(fs.readFileSync(file, "utf8"))).toBe("skl_PREFLIGHT1");
186
202
  expect(result.filesUploaded).toBe(1);
187
203
  expect(uploadFile).toHaveBeenCalledWith(expect.anything(), file, "skills/call-prep/SKILL.md", undefined, expect.anything());
204
+ expect(registrar).toHaveBeenNthCalledWith(2, {
205
+ companySlug: "acme",
206
+ path: "skills/call-prep/SKILL.md",
207
+ content: expect.stringContaining("skill_uid: skl_PREFLIGHT1"),
208
+ landed: true,
209
+ });
188
210
  });
189
211
  it("fails closed without an authenticated registration seam", async () => {
190
212
  const file = createSkill("manual", "---\nname: Manual\ndescription: Missing id\n---\n");
@@ -196,17 +218,276 @@ describe("company skill identity preflight", () => {
196
218
  expect(uploadFile).not.toHaveBeenCalled();
197
219
  expect(parseRegisteredCompanySkillUid(fs.readFileSync(file, "utf8"))).toBeUndefined();
198
220
  });
199
- it("does not re-register an already stamped canonical skill", async () => {
221
+ it("reconciles an already stamped canonical skill after upload", async () => {
200
222
  const file = createSkill("existing", "---\nname: Existing\ndescription: Ready\nskill_uid: skl_EXISTING1\n---\n");
201
- const registrar = vi.fn();
223
+ const registrar = vi.fn(async (input) => registered(input, "skl_EXISTING1"));
224
+ const result = await share({
225
+ paths: [file],
226
+ hqRoot,
227
+ entityContext: makeEntityContext(),
228
+ registerCompanySkill: registrar,
229
+ });
230
+ expect(registrar).toHaveBeenCalledOnce();
231
+ expect(registrar).toHaveBeenCalledWith({
232
+ companySlug: "acme",
233
+ path: "skills/existing/SKILL.md",
234
+ content: expect.stringContaining("skill_uid: skl_EXISTING1"),
235
+ landed: true,
236
+ });
237
+ expect(result.filesUploaded).toBe(1);
238
+ });
239
+ it("does not reconcile bytes edited while the upload is in flight", async () => {
240
+ const original = "---\nname: Existing\ndescription: Ready\nskill_uid: skl_EXISTING1\ntags: [sales]\n---\n";
241
+ const file = createSkill("existing", original);
242
+ const registrar = vi.fn(async (input) => registered(input, "skl_EXISTING1"));
243
+ vi.mocked(uploadFile).mockImplementationOnce(async () => {
244
+ fs.writeFileSync(file, original.replace("sales", "reporting"));
245
+ return { etag: '"upload-etag"' };
246
+ });
202
247
  const result = await share({
203
248
  paths: [file],
204
249
  hqRoot,
205
250
  entityContext: makeEntityContext(),
206
251
  registerCompanySkill: registrar,
207
252
  });
253
+ expect(result.filesUploaded).toBe(1);
208
254
  expect(registrar).not.toHaveBeenCalled();
255
+ });
256
+ it("retries a failed post-upload metadata reconciliation without re-uploading", async () => {
257
+ const file = createSkill("existing", "---\nname: Existing\ndescription: Ready\nskill_uid: skl_EXISTING1\ntags: [sales]\n---\n");
258
+ const failedRegistrar = vi.fn(async () => {
259
+ throw new Error("registry temporarily unavailable");
260
+ });
261
+ const first = await share({
262
+ paths: [file],
263
+ hqRoot,
264
+ entityContext: makeEntityContext(),
265
+ registerCompanySkill: failedRegistrar,
266
+ });
267
+ expect(first.filesUploaded).toBe(1);
268
+ expect(readJournal("acme").files["skills/existing/SKILL.md"])
269
+ .toMatchObject({ skillMetadataPending: true });
270
+ vi.mocked(uploadFile).mockClear();
271
+ vi.mocked(headRemoteFile).mockResolvedValue({
272
+ lastModified: new Date(),
273
+ etag: '"upload-etag"',
274
+ size: fs.statSync(file).size,
275
+ });
276
+ const healingRegistrar = vi.fn(async (input) => registered(input, "skl_EXISTING1"));
277
+ const second = await share({
278
+ paths: [file],
279
+ hqRoot,
280
+ entityContext: makeEntityContext(),
281
+ registerCompanySkill: healingRegistrar,
282
+ skipUnchanged: true,
283
+ });
284
+ expect(second.filesUploaded).toBe(0);
285
+ expect(uploadFile).not.toHaveBeenCalled();
286
+ expect(healingRegistrar).toHaveBeenCalledWith(expect.objectContaining({ landed: true }));
287
+ expect(readJournal("acme").files["skills/existing/SKILL.md"]
288
+ ?.skillMetadataPending).toBeUndefined();
289
+ });
290
+ it("durably records pending skill metadata before starting reconciliation", async () => {
291
+ const file = createSkill("existing", "---\nname: Existing\ndescription: Ready\nskill_uid: skl_EXISTING1\ntags: [sales]\n---\n");
292
+ let pendingAtReconcile;
293
+ const registrar = vi.fn(async (input) => {
294
+ pendingAtReconcile = readJournal("acme").files["skills/existing/SKILL.md"];
295
+ return registered(input, "skl_EXISTING1");
296
+ });
297
+ await share({
298
+ paths: [file],
299
+ hqRoot,
300
+ entityContext: makeEntityContext(),
301
+ registerCompanySkill: registrar,
302
+ });
303
+ expect(registrar).toHaveBeenCalledOnce();
304
+ expect(pendingAtReconcile)
305
+ .toMatchObject({ skillMetadataPending: true, remoteEtag: "upload-etag" });
306
+ });
307
+ it("reconciles skill metadata when restart finds identical remote bytes without a journal", async () => {
308
+ const content = "---\nname: Existing\ndescription: Ready\nskill_uid: skl_EXISTING1\ntags: [sales]\n---\n";
309
+ const file = createSkill("existing", content);
310
+ vi.mocked(headRemoteFile).mockResolvedValueOnce({
311
+ lastModified: new Date(),
312
+ etag: '"remote-etag"',
313
+ size: Buffer.byteLength(content),
314
+ });
315
+ vi.mocked(downloadFile).mockImplementationOnce(async (_ctx, _key, destination) => {
316
+ fs.writeFileSync(destination, content);
317
+ return undefined;
318
+ });
319
+ const registrar = vi.fn(async (input) => registered(input, "skl_EXISTING1"));
320
+ const result = await share({
321
+ paths: [file],
322
+ hqRoot,
323
+ entityContext: makeEntityContext(),
324
+ registerCompanySkill: registrar,
325
+ });
326
+ expect(result.filesUploaded).toBe(0);
327
+ expect(uploadFile).not.toHaveBeenCalled();
328
+ expect(registrar).toHaveBeenCalledWith(expect.objectContaining({
329
+ path: "skills/existing/SKILL.md",
330
+ content,
331
+ landed: true,
332
+ }));
333
+ expect(readJournal("acme").files["skills/existing/SKILL.md"])
334
+ .not.toHaveProperty("skillMetadataPending", true);
335
+ });
336
+ it("restarts instead of reconciling skill bytes edited after the equality proof", async () => {
337
+ const original = "---\nname: Existing\ndescription: Ready\nskill_uid: skl_EXISTING1\ntags: [sales]\n---\n";
338
+ const changed = original.replace("[sales]", "[reporting]");
339
+ const file = createSkill("existing", original);
340
+ vi.mocked(headRemoteFile).mockResolvedValue({
341
+ lastModified: new Date(),
342
+ etag: '"remote-etag"',
343
+ size: Buffer.byteLength(original),
344
+ });
345
+ vi.mocked(downloadFile).mockImplementation(async (_ctx, _key, destination) => {
346
+ fs.writeFileSync(destination, original);
347
+ return undefined;
348
+ });
349
+ const realReadFileSync = fs.readFileSync.bind(fs);
350
+ let skillReads = 0;
351
+ const readSpy = vi.spyOn(fs, "readFileSync").mockImplementation(((pathname, options) => {
352
+ if (pathname === file && options === undefined) {
353
+ skillReads++;
354
+ if (skillReads === 2)
355
+ fs.writeFileSync(file, changed);
356
+ }
357
+ return realReadFileSync(pathname, options);
358
+ }));
359
+ const registrar = vi.fn(async (input) => registered(input, "skl_EXISTING1"));
360
+ let result;
361
+ try {
362
+ result = await share({
363
+ paths: [file],
364
+ hqRoot,
365
+ entityContext: makeEntityContext(),
366
+ registerCompanySkill: registrar,
367
+ onConflict: "overwrite",
368
+ });
369
+ }
370
+ finally {
371
+ readSpy.mockRestore();
372
+ }
209
373
  expect(result.filesUploaded).toBe(1);
374
+ expect(uploadFile).toHaveBeenCalledOnce();
375
+ expect(registrar).toHaveBeenCalledOnce();
376
+ expect(registrar).toHaveBeenCalledWith(expect.objectContaining({
377
+ content: changed,
378
+ landed: true,
379
+ }));
380
+ });
381
+ it("reconciles current remote metadata when the landed upload was replaced", async () => {
382
+ const file = createSkill("existing", "---\nname: Existing\ndescription: Ready\nskill_uid: skl_EXISTING1\ntags: [sales]\n---\n");
383
+ const failedRegistrar = vi.fn(async () => {
384
+ throw new Error("registry temporarily unavailable");
385
+ });
386
+ await share({
387
+ paths: [file],
388
+ hqRoot,
389
+ entityContext: makeEntityContext(),
390
+ registerCompanySkill: failedRegistrar,
391
+ });
392
+ expect(readJournal("acme").files["skills/existing/SKILL.md"])
393
+ .toMatchObject({ skillMetadataPending: true, remoteEtag: "upload-etag" });
394
+ vi.mocked(uploadFile).mockClear();
395
+ vi.mocked(headRemoteFile).mockResolvedValue({
396
+ lastModified: new Date(),
397
+ etag: '"peer-overwrite"',
398
+ size: fs.statSync(file).size,
399
+ });
400
+ const remote = fs.readFileSync(file, "utf8").replace("[sales]", "[remote]");
401
+ vi.mocked(downloadFile).mockImplementation(async (_ctx, _key, destination) => {
402
+ fs.writeFileSync(destination, remote);
403
+ return {};
404
+ });
405
+ const healingRegistrar = vi.fn(async (input) => registered(input, "skl_EXISTING1"));
406
+ const result = await share({
407
+ paths: [file],
408
+ hqRoot,
409
+ entityContext: makeEntityContext(),
410
+ registerCompanySkill: healingRegistrar,
411
+ skipUnchanged: true,
412
+ onConflict: "keep",
413
+ });
414
+ expect(healingRegistrar).toHaveBeenCalledWith(expect.objectContaining({ content: remote, landed: true }));
415
+ expect(readJournal("acme").files["skills/existing/SKILL.md"]
416
+ ?.skillMetadataPending).toBeUndefined();
417
+ expect(result.conflictPaths).toEqual(["skills/existing/SKILL.md"]);
418
+ expect(fs.readFileSync(file, "utf8")).toBe(remote);
419
+ });
420
+ it("preserves entityContext-only uploads of already registered skills", async () => {
421
+ const file = createSkill("existing", "---\nname: Existing\nskill_uid: skl_EXISTING1\n---\n");
422
+ const result = await share({
423
+ paths: [file],
424
+ hqRoot,
425
+ entityContext: makeEntityContext(),
426
+ });
427
+ expect(result.filesUploaded).toBe(1);
428
+ expect(readJournal("acme").files["skills/existing/SKILL.md"])
429
+ .not.toHaveProperty("skillMetadataPending", true);
430
+ });
431
+ it("invalidates a replaced remote baseline even when metadata recovery fails", async () => {
432
+ const file = createSkill("existing", "---\nname: Existing\nskill_uid: skl_EXISTING1\n---\n");
433
+ await share({
434
+ paths: [file],
435
+ hqRoot,
436
+ entityContext: makeEntityContext(),
437
+ registerCompanySkill: vi.fn(async () => {
438
+ throw new Error("registry unavailable");
439
+ }),
440
+ });
441
+ vi.mocked(headRemoteFile).mockResolvedValue({
442
+ lastModified: new Date(),
443
+ etag: '"peer-overwrite"',
444
+ size: fs.statSync(file).size,
445
+ });
446
+ vi.mocked(downloadFile).mockRejectedValueOnce(new Error("download failed"));
447
+ await expect(share({
448
+ paths: [file],
449
+ hqRoot,
450
+ entityContext: makeEntityContext(),
451
+ registerCompanySkill: vi.fn(async (input) => registered(input, "skl_EXISTING1")),
452
+ skipUnchanged: true,
453
+ })).rejects.toThrow("download failed");
454
+ expect(readJournal("acme").files["skills/existing/SKILL.md"])
455
+ .toBeUndefined();
456
+ });
457
+ it("uploads a local change before reconciling metadata when it lands during remote verification", async () => {
458
+ const original = "---\nname: Existing\ndescription: Ready\nskill_uid: skl_EXISTING1\ntags: [sales]\n---\n";
459
+ const file = createSkill("existing", original);
460
+ const failedRegistrar = vi.fn(async () => {
461
+ throw new Error("registry temporarily unavailable");
462
+ });
463
+ await share({
464
+ paths: [file],
465
+ hqRoot,
466
+ entityContext: makeEntityContext(),
467
+ registerCompanySkill: failedRegistrar,
468
+ });
469
+ vi.mocked(uploadFile).mockClear();
470
+ vi.mocked(headRemoteFile).mockImplementationOnce(async () => {
471
+ fs.writeFileSync(file, original.replace("[sales]", "[reporting]"));
472
+ return {
473
+ lastModified: new Date(),
474
+ etag: '"upload-etag"',
475
+ size: Buffer.byteLength(original),
476
+ };
477
+ });
478
+ const healingRegistrar = vi.fn(async (input) => registered(input, "skl_EXISTING1"));
479
+ await share({
480
+ paths: [file],
481
+ hqRoot,
482
+ entityContext: makeEntityContext(),
483
+ registerCompanySkill: healingRegistrar,
484
+ skipUnchanged: true,
485
+ });
486
+ expect(uploadFile).toHaveBeenCalledTimes(1);
487
+ expect(healingRegistrar).toHaveBeenCalledTimes(1);
488
+ expect(vi.mocked(uploadFile).mock.invocationCallOrder[0]).toBeLessThan(healingRegistrar.mock.invocationCallOrder[0]);
489
+ expect(readJournal("acme").files["skills/existing/SKILL.md"]
490
+ ?.skillMetadataPending).toBeUndefined();
210
491
  });
211
492
  it("recognizes quoted identities in CRLF frontmatter", () => {
212
493
  expect(parseRegisteredCompanySkillUid("---\r\nname: Existing\r\nskill_uid: \"skl_WINDOWS1\"\r\n---\r\n")).toBe("skl_WINDOWS1");
@@ -1710,11 +1991,108 @@ describe("share", () => {
1710
1991
  path: "v7b-priv.txt",
1711
1992
  target: "../target.md",
1712
1993
  }));
1713
- expect(events.some((e) => e.type === "error")).toBe(false);
1994
+ expect(events.filter((e) => e.type === "error")).toEqual([]);
1714
1995
  // The conflict is still surfaced; the failed adoption restores local.
1715
1996
  expect(result.conflictPaths).toEqual(["v7b-priv.txt"]);
1716
1997
  expect(fs.readFileSync(testFile, "utf-8")).toBe("local-collision-content");
1717
1998
  });
1999
+ it("HQ-DESKTOP-54 (second path): a blocked push conflict-keep move-aside rename is a skip, not an error, and does not touch the local body", async () => {
2000
+ // Push keep moves the local body aside before adopting the remote. On a
2001
+ // Windows host where a program is holding that file open, the move-aside
2002
+ // rename EPERMs; s3 raises the typed WindowsRenameBlockedError
2003
+ // immediately (no retry). The push leg must downgrade it to the
2004
+ // deliberate skip (never type:"error") so the company stays complete and the
2005
+ // runner exits 0 — mirroring the symlink-privilege skip above.
2006
+ const companyRoot = path.join(tmpDir, "companies", "acme");
2007
+ fs.mkdirSync(companyRoot, { recursive: true });
2008
+ const testFile = path.join(companyRoot, "v7b-rename.txt");
2009
+ fs.writeFileSync(testFile, "local-collision-content");
2010
+ vi.mocked(headRemoteFile).mockResolvedValueOnce({
2011
+ lastModified: new Date(),
2012
+ etag: '"remote-version-etag"',
2013
+ size: 40,
2014
+ });
2015
+ // The reconciled probe fetches the (divergent) remote so a conflict is
2016
+ // detected; the move-aside rename below is what fails.
2017
+ vi.mocked(downloadFile).mockImplementation(async (_ctx, _key, dest) => {
2018
+ fs.writeFileSync(dest, "peer remote content");
2019
+ return undefined;
2020
+ });
2021
+ const realRenameSync = fs.renameSync;
2022
+ const renameSpy = vi.spyOn(fs, "renameSync").mockImplementation((from, to) => {
2023
+ if (String(from) === testFile && String(to).includes(".conflict-")) {
2024
+ const err = new Error("EPERM: operation not permitted, rename");
2025
+ err.code = "EPERM";
2026
+ throw err;
2027
+ }
2028
+ return realRenameSync(from, to);
2029
+ });
2030
+ const realPlatform = process.platform;
2031
+ Object.defineProperty(process, "platform", { value: "win32", configurable: true });
2032
+ const events = [];
2033
+ let result;
2034
+ try {
2035
+ result = await share({
2036
+ paths: [testFile],
2037
+ company: "acme",
2038
+ vaultConfig: mockConfig,
2039
+ hqRoot: tmpDir,
2040
+ onConflict: "keep",
2041
+ onEvent: (e) => events.push(e),
2042
+ });
2043
+ }
2044
+ finally {
2045
+ Object.defineProperty(process, "platform", { value: realPlatform, configurable: true });
2046
+ renameSpy.mockRestore();
2047
+ }
2048
+ expect(events).toContainEqual(expect.objectContaining({ type: "skip-rename-blocked", path: "v7b-rename.txt" }));
2049
+ expect(events.some((e) => e.type === "error")).toBe(false);
2050
+ // Nothing was moved or overwritten: the local body is intact and no upload
2051
+ // happened, so the conflict simply re-fires next pass.
2052
+ expect(fs.readFileSync(testFile, "utf-8")).toBe("local-collision-content");
2053
+ expect(result.filesUploaded).toBe(0);
2054
+ });
2055
+ it("HQ-DESKTOP-54 (second path): the push default console logger renders the rename-blocked skip and its remedy (a plain `hq sync` is not silent)", async () => {
2056
+ const companyRoot = path.join(tmpDir, "companies", "acme");
2057
+ fs.mkdirSync(companyRoot, { recursive: true });
2058
+ const testFile = path.join(companyRoot, "v7b-render.txt");
2059
+ fs.writeFileSync(testFile, "local-collision-content");
2060
+ vi.mocked(headRemoteFile).mockResolvedValueOnce({
2061
+ lastModified: new Date(),
2062
+ etag: '"remote-version-etag"',
2063
+ size: 40,
2064
+ });
2065
+ vi.mocked(downloadFile).mockImplementation(async (_ctx, _key, dest) => {
2066
+ fs.writeFileSync(dest, "peer remote content");
2067
+ return undefined;
2068
+ });
2069
+ const realRenameSync = fs.renameSync;
2070
+ const renameSpy = vi.spyOn(fs, "renameSync").mockImplementation((from, to) => {
2071
+ if (String(from) === testFile && String(to).includes(".conflict-")) {
2072
+ const err = new Error("EPERM: operation not permitted, rename");
2073
+ err.code = "EPERM";
2074
+ throw err;
2075
+ }
2076
+ return realRenameSync(from, to);
2077
+ });
2078
+ const realPlatform = process.platform;
2079
+ Object.defineProperty(process, "platform", { value: "win32", configurable: true });
2080
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => { });
2081
+ const log = vi.spyOn(console, "log").mockImplementation(() => { });
2082
+ let rendered = [];
2083
+ try {
2084
+ // No onEvent → the push defaultConsoleLogger renders the skip.
2085
+ await share({ paths: [testFile], company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir, onConflict: "keep" });
2086
+ rendered = warn.mock.calls.map((c) => String(c[0]));
2087
+ }
2088
+ finally {
2089
+ Object.defineProperty(process, "platform", { value: realPlatform, configurable: true });
2090
+ warn.mockRestore();
2091
+ log.mockRestore();
2092
+ renameSpy.mockRestore();
2093
+ }
2094
+ expect(rendered.some((line) => /v7b-render\.txt skipped/.test(line) && /holding the file open/i.test(line))).toBe(true);
2095
+ });
1718
2096
  it("fresh-install SSE-KMS etag: byte-identical remote is reconciled without a conflict", async () => {
1719
2097
  const companyRoot = path.join(tmpDir, "companies", "acme");
1720
2098
  fs.mkdirSync(companyRoot, { recursive: true });
@@ -7697,12 +8075,23 @@ describe("nested companies directories in a company vault", () => {
7697
8075
  const midPath = path.join(companyRoot, "audit", "companies", "notes.md");
7698
8076
  fs.mkdirSync(path.dirname(midPath), { recursive: true });
7699
8077
  fs.writeFileSync(midPath, "ordinary segment");
8078
+ const skillRegistrar = async (input) => ({
8079
+ ok: true,
8080
+ skillUid: "skl_SIGNALS1",
8081
+ path: input.path,
8082
+ vaultPath: input.path,
8083
+ content: input.content,
8084
+ action: "reused",
8085
+ accessPolicy: "preserved",
8086
+ skill: { name: "Signals", description: "", tags: [] },
8087
+ });
7700
8088
  const events = [];
7701
8089
  const result = await share({
7702
8090
  paths: [companyRoot],
7703
8091
  company: "acme",
7704
8092
  vaultConfig: mockConfig,
7705
8093
  hqRoot: tmpDir,
8094
+ registerCompanySkill: skillRegistrar,
7706
8095
  onEvent: (e) => events.push(e),
7707
8096
  });
7708
8097
  expect(result.aborted).toBe(false);
@@ -7716,7 +8105,7 @@ describe("nested companies directories in a company vault", () => {
7716
8105
  "audit/companies/notes.md",
7717
8106
  ]));
7718
8107
  expect(events.filter((e) => e.type === "skip-invalid-scoped-key")).toEqual([]);
7719
- expect(events.some((e) => e.type === "error")).toBe(false);
8108
+ expect(events.filter((e) => e.type === "error")).toEqual([]);
7720
8109
  });
7721
8110
  it("never plans an upload for macOS Finder Icon\\r files — silent skip, valid files still ship", async () => {
7722
8111
  // The incident: Finder writes an `Icon\r` file into every directory it
@@ -7993,4 +8382,45 @@ describe("defaultConsoleLogger — delete refusal wording per reason", () => {
7993
8382
  expect(lines.join("\n")).toContain("ignore-filtered=2, excluded-top-level=1");
7994
8383
  });
7995
8384
  });
8385
+ describe("share reconcile apply checkpoint", () => {
8386
+ let hqRoot;
8387
+ let stateDir;
8388
+ beforeEach(() => {
8389
+ hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-share-reconcile-"));
8390
+ stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-share-reconcile-state-"));
8391
+ process.env.HQ_STATE_DIR = stateDir;
8392
+ vi.mocked(uploadFile).mockReset();
8393
+ vi.mocked(uploadFile).mockRejectedValue(new Error("temporary upload failure"));
8394
+ vi.mocked(headRemoteFile).mockResolvedValue(null);
8395
+ vi.mocked(primeUploads).mockResolvedValue(undefined);
8396
+ vi.mocked(primeObjectTransport).mockResolvedValue(undefined);
8397
+ });
8398
+ afterEach(() => {
8399
+ fs.rmSync(hqRoot, { recursive: true, force: true });
8400
+ fs.rmSync(stateDir, { recursive: true, force: true });
8401
+ delete process.env.HQ_STATE_DIR;
8402
+ });
8403
+ it("withholds an apply checkpoint after a per-path upload failure", async () => {
8404
+ const localPath = path.join(hqRoot, "companies", "acme", "notes", "retry.md");
8405
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
8406
+ fs.writeFileSync(localPath, "retry me");
8407
+ const store = ReconcileCursorStore.open({
8408
+ rootDir: stateDir,
8409
+ journalSlug: "acme",
8410
+ layoutEpoch: 1,
8411
+ areaId: "root:notes",
8412
+ });
8413
+ const result = await share({
8414
+ paths: [localPath],
8415
+ hqRoot,
8416
+ entityContext: makeEntityContext(),
8417
+ reconcileApply: {
8418
+ store,
8419
+ cursor: { routerVersion: 1, epoch: "epoch-1", areaId: "root:notes", phase: "apply", journalAfterKey: "notes/retry.md" },
8420
+ },
8421
+ });
8422
+ expect(result.filesUploaded).toBe(0);
8423
+ expect(store.read().cursor).toBeUndefined();
8424
+ });
8425
+ });
7996
8426
  //# sourceMappingURL=share.test.js.map