@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.
- package/dist/bin/sync-mutation.d.ts +1 -3
- package/dist/bin/sync-mutation.d.ts.map +1 -1
- package/dist/bin/sync-mutation.js +5 -5
- package/dist/bin/sync-mutation.js.map +1 -1
- package/dist/bin/sync-mutation.test.js +36 -137
- package/dist/bin/sync-mutation.test.js.map +1 -1
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +15 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-company.test.js +71 -0
- package/dist/bin/sync-runner-company.test.js.map +1 -1
- package/dist/bin/sync-runner-events.test.js +21 -0
- package/dist/bin/sync-runner-events.test.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +5 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +23 -6
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +54 -1
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +31 -6
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +58 -38
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +180 -47
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/s3.d.ts +36 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +61 -1
- package/dist/s3.js.map +1 -1
- package/dist/s3.symlink-materialize.test.js +161 -1
- package/dist/s3.symlink-materialize.test.js.map +1 -1
- package/dist/sync/base-version-resolver.d.ts +11 -0
- package/dist/sync/base-version-resolver.d.ts.map +1 -0
- package/dist/sync/base-version-resolver.js +48 -0
- package/dist/sync/base-version-resolver.js.map +1 -0
- package/dist/sync/base-version-resolver.test.d.ts +2 -0
- package/dist/sync/base-version-resolver.test.d.ts.map +1 -0
- package/dist/sync/base-version-resolver.test.js +24 -0
- package/dist/sync/base-version-resolver.test.js.map +1 -0
- package/dist/sync/lease-client.d.ts +17 -0
- package/dist/sync/lease-client.d.ts.map +1 -1
- package/dist/sync/lease-client.js +46 -1
- package/dist/sync/lease-client.js.map +1 -1
- package/dist/sync/mutation-client.d.ts +2 -0
- package/dist/sync/mutation-client.d.ts.map +1 -1
- package/dist/sync/mutation-client.js +2 -0
- package/dist/sync/mutation-client.js.map +1 -1
- package/dist/sync/mutation-session.d.ts +49 -0
- package/dist/sync/mutation-session.d.ts.map +1 -0
- package/dist/sync/mutation-session.js +282 -0
- package/dist/sync/mutation-session.js.map +1 -0
- package/dist/sync/mutation-session.test.d.ts +2 -0
- package/dist/sync/mutation-session.test.d.ts.map +1 -0
- package/dist/sync/mutation-session.test.js +190 -0
- package/dist/sync/mutation-session.test.js.map +1 -0
- package/dist/sync/uploader-finalization.d.ts +20 -0
- package/dist/sync/uploader-finalization.d.ts.map +1 -1
- package/dist/sync/uploader-finalization.js +48 -0
- package/dist/sync/uploader-finalization.js.map +1 -1
- package/dist/sync/uploader-finalization.test.js +15 -0
- package/dist/sync/uploader-finalization.test.js.map +1 -1
- package/package.json +1 -1
package/dist/cli/sync.test.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
expect(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
input,
|
|
42
|
+
canonicalPath: "notes/bytes.md",
|
|
43
|
+
op: "upsert",
|
|
66
44
|
contentBase64: bytes.toString("base64"),
|
|
67
|
-
}), { submit
|
|
45
|
+
}), { submit })).resolves.toContain('"action":"submit"');
|
|
68
46
|
const submitted = submit.mock.calls[0]?.[0];
|
|
69
|
-
expect(submitted).toMatchObject({
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}), { submit
|
|
87
|
-
|
|
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",
|