@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.
- package/dist/bin/area-ledger-migration.d.ts +13 -0
- package/dist/bin/area-ledger-migration.d.ts.map +1 -0
- package/dist/bin/area-ledger-migration.js +64 -0
- package/dist/bin/area-ledger-migration.js.map +1 -0
- package/dist/bin/area-ledger-migration.test.d.ts +2 -0
- package/dist/bin/area-ledger-migration.test.d.ts.map +1 -0
- package/dist/bin/area-ledger-migration.test.js +55 -0
- package/dist/bin/area-ledger-migration.test.js.map +1 -0
- package/dist/bin/sync-runner-company.d.ts +6 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +21 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-company.test.js +112 -0
- package/dist/bin/sync-runner-company.test.js.map +1 -1
- package/dist/bin/sync-runner-events.test.js +20 -0
- package/dist/bin/sync-runner-events.test.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +7 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +308 -20
- package/dist/bin/sync-runner-watch-loop.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/bin/sync-runner.test.js +144 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +8 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +213 -15
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +461 -31
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +30 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +83 -8
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +224 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cli/watch-event-push-conflict.test.js +22 -11
- package/dist/cli/watch-event-push-conflict.test.js.map +1 -1
- package/dist/journal-store.test.js +42 -1
- package/dist/journal-store.test.js.map +1 -1
- package/dist/journal.d.ts +12 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +103 -6
- package/dist/journal.js.map +1 -1
- package/dist/s3.d.ts +62 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +91 -1
- package/dist/s3.js.map +1 -1
- package/dist/s3.rename-retry.test.d.ts +2 -0
- package/dist/s3.rename-retry.test.d.ts.map +1 -0
- package/dist/s3.rename-retry.test.js +153 -0
- package/dist/s3.rename-retry.test.js.map +1 -0
- package/dist/sync/area-ledger-migration.d.ts +116 -0
- package/dist/sync/area-ledger-migration.d.ts.map +1 -0
- package/dist/sync/area-ledger-migration.js +505 -0
- package/dist/sync/area-ledger-migration.js.map +1 -0
- package/dist/sync/area-ledger-migration.test.d.ts +2 -0
- package/dist/sync/area-ledger-migration.test.d.ts.map +1 -0
- package/dist/sync/area-ledger-migration.test.js +274 -0
- package/dist/sync/area-ledger-migration.test.js.map +1 -0
- package/dist/sync/feature-flags.d.ts +9 -0
- package/dist/sync/feature-flags.d.ts.map +1 -1
- package/dist/sync/feature-flags.js +19 -0
- package/dist/sync/feature-flags.js.map +1 -1
- package/dist/sync/feature-flags.test.js +11 -1
- package/dist/sync/feature-flags.test.js.map +1 -1
- package/dist/sync/index.d.ts +2 -0
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +1 -0
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/parallel-sync-coordinator.d.ts.map +1 -1
- package/dist/sync/parallel-sync-coordinator.js +19 -4
- package/dist/sync/parallel-sync-coordinator.js.map +1 -1
- package/dist/sync/parallel-sync-coordinator.test.js +59 -0
- package/dist/sync/parallel-sync-coordinator.test.js.map +1 -1
- package/dist/sync/reconcile-cursor.d.ts +97 -0
- package/dist/sync/reconcile-cursor.d.ts.map +1 -0
- package/dist/sync/reconcile-cursor.js +215 -0
- package/dist/sync/reconcile-cursor.js.map +1 -0
- package/dist/sync/reconcile-cursor.test.d.ts +2 -0
- package/dist/sync/reconcile-cursor.test.d.ts.map +1 -0
- package/dist/sync/reconcile-cursor.test.js +139 -0
- package/dist/sync/reconcile-cursor.test.js.map +1 -0
- package/dist/sync/state-store.d.ts +37 -0
- package/dist/sync/state-store.d.ts.map +1 -1
- package/dist/sync/state-store.js +109 -1
- package/dist/sync/state-store.js.map +1 -1
- package/dist/sync/sync-work.d.ts +2 -1
- package/dist/sync/sync-work.d.ts.map +1 -1
- package/dist/sync/sync-work.js.map +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/vault-client.d.ts +9 -2
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +3 -2
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +26 -0
- package/dist/vault-client.test.js.map +1 -1
- package/package.json +3 -2
package/dist/cli/share.test.js
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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:
|
|
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).
|
|
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("
|
|
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.
|
|
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.
|
|
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
|