@indigoai-us/hq-cloud 6.15.2 → 6.15.4

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 (64) hide show
  1. package/dist/bin/sync-mutation.d.ts +1 -3
  2. package/dist/bin/sync-mutation.d.ts.map +1 -1
  3. package/dist/bin/sync-mutation.js +5 -5
  4. package/dist/bin/sync-mutation.js.map +1 -1
  5. package/dist/bin/sync-mutation.test.js +36 -137
  6. package/dist/bin/sync-mutation.test.js.map +1 -1
  7. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  8. package/dist/bin/sync-runner-company.js +15 -0
  9. package/dist/bin/sync-runner-company.js.map +1 -1
  10. package/dist/bin/sync-runner-company.test.js +71 -0
  11. package/dist/bin/sync-runner-company.test.js.map +1 -1
  12. package/dist/bin/sync-runner-events.test.js +21 -0
  13. package/dist/bin/sync-runner-events.test.js.map +1 -1
  14. package/dist/bin/sync-runner.d.ts +5 -0
  15. package/dist/bin/sync-runner.d.ts.map +1 -1
  16. package/dist/bin/sync-runner.js.map +1 -1
  17. package/dist/cli/share.d.ts.map +1 -1
  18. package/dist/cli/share.js +23 -6
  19. package/dist/cli/share.js.map +1 -1
  20. package/dist/cli/share.test.js +54 -1
  21. package/dist/cli/share.test.js.map +1 -1
  22. package/dist/cli/sync.d.ts +31 -6
  23. package/dist/cli/sync.d.ts.map +1 -1
  24. package/dist/cli/sync.js +58 -38
  25. package/dist/cli/sync.js.map +1 -1
  26. package/dist/cli/sync.test.js +180 -47
  27. package/dist/cli/sync.test.js.map +1 -1
  28. package/dist/s3.d.ts +36 -0
  29. package/dist/s3.d.ts.map +1 -1
  30. package/dist/s3.js +61 -1
  31. package/dist/s3.js.map +1 -1
  32. package/dist/s3.symlink-materialize.test.js +161 -1
  33. package/dist/s3.symlink-materialize.test.js.map +1 -1
  34. package/dist/sync/base-version-resolver.d.ts +11 -0
  35. package/dist/sync/base-version-resolver.d.ts.map +1 -0
  36. package/dist/sync/base-version-resolver.js +48 -0
  37. package/dist/sync/base-version-resolver.js.map +1 -0
  38. package/dist/sync/base-version-resolver.test.d.ts +2 -0
  39. package/dist/sync/base-version-resolver.test.d.ts.map +1 -0
  40. package/dist/sync/base-version-resolver.test.js +24 -0
  41. package/dist/sync/base-version-resolver.test.js.map +1 -0
  42. package/dist/sync/lease-client.d.ts +17 -0
  43. package/dist/sync/lease-client.d.ts.map +1 -1
  44. package/dist/sync/lease-client.js +46 -1
  45. package/dist/sync/lease-client.js.map +1 -1
  46. package/dist/sync/mutation-client.d.ts +2 -0
  47. package/dist/sync/mutation-client.d.ts.map +1 -1
  48. package/dist/sync/mutation-client.js +2 -0
  49. package/dist/sync/mutation-client.js.map +1 -1
  50. package/dist/sync/mutation-session.d.ts +49 -0
  51. package/dist/sync/mutation-session.d.ts.map +1 -0
  52. package/dist/sync/mutation-session.js +282 -0
  53. package/dist/sync/mutation-session.js.map +1 -0
  54. package/dist/sync/mutation-session.test.d.ts +2 -0
  55. package/dist/sync/mutation-session.test.d.ts.map +1 -0
  56. package/dist/sync/mutation-session.test.js +190 -0
  57. package/dist/sync/mutation-session.test.js.map +1 -0
  58. package/dist/sync/uploader-finalization.d.ts +20 -0
  59. package/dist/sync/uploader-finalization.d.ts.map +1 -1
  60. package/dist/sync/uploader-finalization.js +48 -0
  61. package/dist/sync/uploader-finalization.js.map +1 -1
  62. package/dist/sync/uploader-finalization.test.js +15 -0
  63. package/dist/sync/uploader-finalization.test.js.map +1 -1
  64. package/package.json +1 -1
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Unit tests for hq sync command (VLT-5 US-002).
3
3
  */
4
- import { createHash } from "node:crypto";
5
4
  import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
6
5
  import * as fs from "fs";
7
6
  import * as path from "path";
@@ -15,58 +14,37 @@ vi.mock("fs", async (importOriginal) => {
15
14
  return { ...actual };
16
15
  });
17
16
  describe("sync mutation stdin boundary", () => {
18
- it("accepts only the exact sidecar command and emits one discriminated recovery result", async () => {
17
+ it("accepts only the exact sidecar command and emits one discriminated upsert result", async () => {
19
18
  expect(SYNC_MUTATION_STDIN_COMMAND).toEqual(["sync", "mutation", "--stdin-json"]);
20
19
  expect(isSyncMutationStdinCommand(["sync", "mutation", "--stdin-json"])).toBe(true);
21
20
  expect(isSyncMutationStdinCommand(["sync", "mutation"])).toBe(false);
22
- const recover = vi.fn().mockResolvedValue({ status: "committed", attemptId: "attempt_1", versionId: "version_1", deltaId: "delta_1" });
21
+ const bytes = Buffer.from("real immutable bytes\0", "utf8");
22
+ const submit = vi.fn().mockResolvedValue({ requestDigest: "digest_1", status: { status: "committed", attemptId: "attempt_1", versionId: "version_1", deltaId: "delta_1" } });
23
23
  const output = await runSyncMutationStdinJson(JSON.stringify({
24
- contractVersion: 2,
25
- action: "recover",
26
- authorization: { bearer: "token", deviceId: "device_a", capabilityVector: {}, lease: {} },
27
- attemptId: "attempt_1",
28
- }), { submit: vi.fn(), recover });
29
- expect(JSON.parse(output)).toEqual({ contractVersion: 2, action: "recover", status: { status: "committed", attemptId: "attempt_1", versionId: "version_1", deltaId: "delta_1" } });
30
- expect(recover).toHaveBeenCalledWith(expect.any(Object), "attempt_1");
31
- });
32
- it("rejects unknown stdin fields rather than accepting raw S3 coordinates", async () => {
33
- await expect(runSyncMutationStdinJson(JSON.stringify({ contractVersion: 2, action: "recover", authorization: {}, attemptId: "attempt_1", bucket: "raw-bucket" }), { submit: vi.fn(), recover: vi.fn() }))
24
+ canonicalPath: "notes/bytes.md",
25
+ op: "upsert",
26
+ contentBase64: bytes.toString("base64"),
27
+ }), { submit });
28
+ expect(JSON.parse(output)).toEqual({ contractVersion: 2, action: "submit", requestDigest: "digest_1", status: { status: "committed", attemptId: "attempt_1", versionId: "version_1", deltaId: "delta_1" } });
29
+ expect(submit).toHaveBeenCalledWith({ canonicalPath: "notes/bytes.md", op: "upsert", bytes: Uint8Array.from(bytes) });
30
+ });
31
+ it("rejects unknown stdin fields rather than accepting authorization or raw S3 coordinates", async () => {
32
+ await expect(runSyncMutationStdinJson(JSON.stringify({ canonicalPath: "notes/a.md", op: "delete", authorization: {}, bucket: "raw-bucket" }), { submit: vi.fn() }))
34
33
  .rejects.toThrow("unknown fields");
35
34
  });
36
- it("decodes contentBase64 into the immutable upsert bytes without changing the V2 request", async () => {
35
+ it("decodes contentBase64 into an immutable upsert byte snapshot", async () => {
37
36
  const bytes = Buffer.from("real immutable bytes\0", "utf8");
38
37
  const submit = vi.fn().mockResolvedValue({
39
38
  requestDigest: "digest_1",
40
39
  status: { status: "committed", attemptId: "attempt_1", versionId: "version_1", deltaId: "delta_1" },
41
40
  });
42
- const input = {
43
- authorization: { bearer: "token", deviceId: "device_a", capabilityVector: {}, lease: {} },
44
- request: {
45
- contractVersion: 2,
46
- deviceId: "device_a",
47
- clientMutationId: "mutation_1",
48
- canonicalPath: "notes/bytes.md",
49
- operation: "upsert",
50
- baseVersionId: "version_previous",
51
- generation: 7,
52
- membershipRevision: "member_1",
53
- scopeRevision: "scope_1",
54
- source: {
55
- kind: "regular",
56
- mode: 0o644,
57
- sha256: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
58
- size: bytes.byteLength,
59
- },
60
- },
61
- };
62
41
  await expect(runSyncMutationStdinJson(JSON.stringify({
63
- contractVersion: 2,
64
- action: "submit",
65
- input,
42
+ canonicalPath: "notes/bytes.md",
43
+ op: "upsert",
66
44
  contentBase64: bytes.toString("base64"),
67
- }), { submit, recover: vi.fn() })).resolves.toContain('"action":"submit"');
45
+ }), { submit })).resolves.toContain('"action":"submit"');
68
46
  const submitted = submit.mock.calls[0]?.[0];
69
- expect(submitted).toMatchObject({ authorization: input.authorization, request: input.request });
47
+ expect(submitted).toMatchObject({ canonicalPath: "notes/bytes.md", op: "upsert" });
70
48
  expect(submitted.bytes).toBeInstanceOf(Uint8Array);
71
49
  expect(Buffer.isBuffer(submitted.bytes)).toBe(false);
72
50
  expect(Buffer.from(submitted.bytes)).toEqual(bytes);
@@ -77,14 +55,16 @@ describe("sync mutation stdin boundary", () => {
77
55
  status: { status: "committed", attemptId: "attempt_delete", versionId: null, deltaId: "delta_delete" },
78
56
  });
79
57
  await runSyncMutationStdinJson(JSON.stringify({
80
- contractVersion: 2,
81
- action: "submit",
82
- input: {
83
- authorization: { bearer: "token", deviceId: "device_a", capabilityVector: {}, lease: {} },
84
- request: { operation: "delete" },
85
- },
86
- }), { submit, recover: vi.fn() });
87
- expect(submit.mock.calls[0]?.[0]).not.toHaveProperty("bytes");
58
+ canonicalPath: "notes/bytes.md",
59
+ op: "delete",
60
+ }), { submit });
61
+ expect(submit).toHaveBeenCalledWith({ canonicalPath: "notes/bytes.md", op: "delete" });
62
+ });
63
+ it("requires bytes only for upserts", async () => {
64
+ await expect(runSyncMutationStdinJson(JSON.stringify({ canonicalPath: "notes/a.md", op: "upsert" }), { submit: vi.fn() }))
65
+ .rejects.toThrow("upsert requires contentBase64");
66
+ await expect(runSyncMutationStdinJson(JSON.stringify({ canonicalPath: "notes/a.md", op: "delete", contentBase64: "" }), { submit: vi.fn() }))
67
+ .rejects.toThrow("delete must not include contentBase64");
88
68
  });
89
69
  });
90
70
  describe("realtime durable apply boundary", () => {
@@ -138,6 +118,7 @@ import * as s3Module from "../s3.js";
138
118
  import { reindex } from "./reindex.js";
139
119
  import { readSyncProgress } from "../sync-progress.js";
140
120
  import { VaultAuthError } from "../vault-client.js";
121
+ import { readJournal, getEntry } from "../journal.js";
141
122
  const mockConfig = {
142
123
  apiUrl: "https://vault-api.test",
143
124
  authToken: "test-jwt-token",
@@ -286,6 +267,158 @@ describe("sync", () => {
286
267
  expect(result.filesDownloaded).toBe(2);
287
268
  expect(reindex).not.toHaveBeenCalled();
288
269
  });
270
+ it("HQ-DESKTOP-54: a Windows symlink-privilege refusal is a deliberate skip (never type:error) and is left unjournaled", async () => {
271
+ // On a Windows host without SeCreateSymbolicLinkPrivilege, materializing a
272
+ // file-flavored vault symlink throws EPERM, which s3 surfaces as a typed
273
+ // WindowsSymlinkPrivilegeError. downloadOne must downgrade it to the
274
+ // dedicated skip event — NOT type:"error" — so the runner never counts it
275
+ // toward errors[] and never exits 2. The refused key must also stay
276
+ // unjournaled so a later privileged pass retries it.
277
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
278
+ { key: "docs/skill-link.md", size: 10, lastModified: new Date(), etag: '"link"' },
279
+ ]);
280
+ vi.mocked(s3Module.downloadFile).mockRejectedValueOnce(new s3Module.WindowsSymlinkPrivilegeError("../target.md", "EPERM"));
281
+ const events = [];
282
+ const result = await sync({
283
+ company: "acme",
284
+ vaultConfig: mockConfig,
285
+ hqRoot: tmpDir,
286
+ skipReindex: true,
287
+ onEvent: (e) => events.push(e),
288
+ });
289
+ const skip = events.find((e) => e.type === "skip-symlink-privilege");
290
+ expect(skip).toMatchObject({
291
+ type: "skip-symlink-privilege",
292
+ path: "docs/skill-link.md",
293
+ target: "../target.md",
294
+ });
295
+ expect(skip.remedy).toMatch(/Developer Mode/i);
296
+ // Crucially, no error event — this is what keeps the runner at exit 0.
297
+ expect(events.some((e) => e.type === "error")).toBe(false);
298
+ expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
299
+ expect(result.filesDownloaded).toBe(0);
300
+ // Convergence: the refused key is NOT journaled.
301
+ expect(getEntry(readJournal("acme"), "docs/skill-link.md")).toBeUndefined();
302
+ });
303
+ it("HQ-DESKTOP-54 convergence: after a refused pass, the next pass that CAN materialize the link journals it (no manual reset)", async () => {
304
+ const remote = [
305
+ { key: "docs/skill-link.md", size: 10, lastModified: new Date(), etag: '"link"' },
306
+ ];
307
+ // Pass 1 — privilege refusal: skipped, not journaled.
308
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce(remote);
309
+ vi.mocked(s3Module.downloadFile).mockRejectedValueOnce(new s3Module.WindowsSymlinkPrivilegeError("../target.md", "EPERM"));
310
+ await sync({
311
+ company: "acme",
312
+ vaultConfig: mockConfig,
313
+ hqRoot: tmpDir,
314
+ skipReindex: true,
315
+ });
316
+ expect(getEntry(readJournal("acme"), "docs/skill-link.md")).toBeUndefined();
317
+ // Pass 2 — the privilege now exists: the symlink materializes and journals.
318
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce(remote);
319
+ vi.mocked(s3Module.downloadFile).mockImplementationOnce(async (_ctx, _key, localPath) => {
320
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
321
+ fs.symlinkSync("../target.md", localPath);
322
+ return { metadata: {} };
323
+ });
324
+ const result = await sync({
325
+ company: "acme",
326
+ vaultConfig: mockConfig,
327
+ hqRoot: tmpDir,
328
+ skipReindex: true,
329
+ });
330
+ expect(result.filesDownloaded).toBe(1);
331
+ const entry = getEntry(readJournal("acme"), "docs/skill-link.md");
332
+ expect(entry).toBeDefined();
333
+ expect(entry.kind).toBe("symlink");
334
+ });
335
+ it("an unrelated download failure still surfaces as type:error — only the privilege refusal is downgraded", async () => {
336
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
337
+ { key: "docs/x.md", size: 10, lastModified: new Date(), etag: '"x"' },
338
+ ]);
339
+ vi.mocked(s3Module.downloadFile).mockRejectedValueOnce(new Error("network boom"));
340
+ const events = [];
341
+ await sync({
342
+ company: "acme",
343
+ vaultConfig: mockConfig,
344
+ hqRoot: tmpDir,
345
+ skipReindex: true,
346
+ onEvent: (e) => events.push(e),
347
+ });
348
+ const errs = events.filter((e) => e.type === "error");
349
+ expect(errs).toHaveLength(1);
350
+ expect(errs[0].path).toBe("docs/x.md");
351
+ expect(events.some((e) => e.type === "skip-symlink-privilege")).toBe(false);
352
+ });
353
+ it("HQ-DESKTOP-54: a privilege refusal during the conflict-convergence probe is a skip, not an error or a counted conflict", async () => {
354
+ // The conflict path also materializes the remote object (to compare it
355
+ // against the diverged local copy). On an unprivileged Windows host that
356
+ // materialization throws the same typed error; it must be downgraded to the
357
+ // deliberate skip so the run stays complete and exits 0 — never type:error,
358
+ // and not counted as an unresolved conflict.
359
+ const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
360
+ fs.mkdirSync(companyDocs, { recursive: true });
361
+ fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
362
+ fs.writeFileSync(journalPath, JSON.stringify({
363
+ version: "1",
364
+ lastSync: new Date().toISOString(),
365
+ files: {
366
+ "docs/handoff.md": {
367
+ hash: "old-hash-from-last-sync",
368
+ size: 20,
369
+ syncedAt: new Date(Date.now() - 3600000).toISOString(),
370
+ direction: "down",
371
+ },
372
+ },
373
+ }));
374
+ // Remote changed too → the planner classifies this as a conflict, so the
375
+ // convergence probe (a downloadFile) runs and refuses with the typed error.
376
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
377
+ { key: "docs/handoff.md", size: 20, lastModified: new Date(), etag: '"remote-changed"' },
378
+ ]);
379
+ vi.mocked(s3Module.downloadFile).mockRejectedValueOnce(new s3Module.WindowsSymlinkPrivilegeError("../target.md", "EPERM"));
380
+ const events = [];
381
+ const result = await sync({
382
+ company: "acme",
383
+ onConflict: "keep",
384
+ vaultConfig: mockConfig,
385
+ hqRoot: tmpDir,
386
+ skipReindex: true,
387
+ onEvent: (e) => events.push(e),
388
+ });
389
+ expect(events).toContainEqual(expect.objectContaining({
390
+ type: "skip-symlink-privilege",
391
+ path: "docs/handoff.md",
392
+ target: "../target.md",
393
+ }));
394
+ expect(events.some((e) => e.type === "error")).toBe(false);
395
+ // Skipped, so it is NOT tallied as an unresolved conflict.
396
+ expect(result.conflicts).toBe(0);
397
+ expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
398
+ });
399
+ it("HQ-DESKTOP-54: the default CLI console logger renders the symlink-privilege skip and its remedy", async () => {
400
+ // With no onEvent (ordinary `hq sync`), the run uses defaultConsoleLogger.
401
+ // The skipped path and remediation must reach the user, not vanish.
402
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
403
+ { key: "docs/skill-link.md", size: 10, lastModified: new Date(), etag: '"link"' },
404
+ ]);
405
+ vi.mocked(s3Module.downloadFile).mockRejectedValueOnce(new s3Module.WindowsSymlinkPrivilegeError("../target.md", "EPERM"));
406
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { });
407
+ try {
408
+ await sync({
409
+ company: "acme",
410
+ vaultConfig: mockConfig,
411
+ hqRoot: tmpDir,
412
+ skipReindex: true,
413
+ });
414
+ const rendered = warnSpy.mock.calls.map((c) => String(c[0])).join("\n");
415
+ expect(rendered).toContain("docs/skill-link.md");
416
+ expect(rendered).toMatch(/Developer Mode/i);
417
+ }
418
+ finally {
419
+ warnSpy.mockRestore();
420
+ }
421
+ });
289
422
  it("downloads remote files under companies/{slug}/ so two companies don't collide", async () => {
290
423
  const result = await sync({
291
424
  company: "acme",