@indigoai-us/hq-cloud 6.15.36 → 6.15.37
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-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +28 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-company.test.js +75 -0
- package/dist/bin/sync-runner-company.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/bin/sync-runner.test.js +25 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +21 -3
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +57 -5
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +181 -3
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync-scope.test.js +87 -13
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +19 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +32 -13
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +28 -14
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cli/tombstones.d.ts +17 -6
- package/dist/cli/tombstones.d.ts.map +1 -1
- package/dist/cli/tombstones.js +11 -7
- package/dist/cli/tombstones.js.map +1 -1
- package/package.json +1 -1
package/dist/cli/share.test.js
CHANGED
|
@@ -52,7 +52,7 @@ vi.mock("readline", () => ({
|
|
|
52
52
|
})),
|
|
53
53
|
}));
|
|
54
54
|
import * as readline from "readline";
|
|
55
|
-
import { share, isForbiddenCompanyVaultKey, UnreachablePushPathsError, currentUploadBytes, _testing as shareTesting } from "./share.js";
|
|
55
|
+
import { share, isForbiddenCompanyVaultKey, ServerOwnedPushPathsError, UnreachablePushPathsError, currentUploadBytes, _testing as shareTesting } from "./share.js";
|
|
56
56
|
import { deleteRemoteFile, downloadFile, headRemoteFile, primeObjectTransport, primeUploads, uploadFile, uploadSymlink, WindowsSymlinkPrivilegeError, } from "../s3.js";
|
|
57
57
|
import { VaultAuthError } from "../vault-client.js";
|
|
58
58
|
import { hashFile, readJournal, writeJournal } from "../journal.js";
|
|
@@ -956,8 +956,8 @@ describe("share", () => {
|
|
|
956
956
|
// no-journal) still uploads. Differential proof: reverting the consult in
|
|
957
957
|
// share.ts makes the first test fail (uploadFile IS called → resurrection).
|
|
958
958
|
describe("push-side tombstone consult", () => {
|
|
959
|
-
function seedJournal(key, hash, size) {
|
|
960
|
-
seedJournalAt(path.join(stateDir,
|
|
959
|
+
function seedJournal(key, hash, size, slug = "acme") {
|
|
960
|
+
seedJournalAt(path.join(stateDir, `sync-journal.${slug}.json`), ({
|
|
961
961
|
version: "1",
|
|
962
962
|
lastSync: new Date().toISOString(),
|
|
963
963
|
files: {
|
|
@@ -1088,6 +1088,71 @@ describe("share", () => {
|
|
|
1088
1088
|
expect(uploadFile).not.toHaveBeenCalled();
|
|
1089
1089
|
expect(result.filesSuppressedByTombstone).toBe(1);
|
|
1090
1090
|
});
|
|
1091
|
+
it("personal push uses personal=1 to suppress a stale copy, then uploads a real edit", async () => {
|
|
1092
|
+
const f = path.join(tmpDir, "docs", "shared.md");
|
|
1093
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
1094
|
+
fs.writeFileSync(f, "shared content");
|
|
1095
|
+
seedJournal("docs/shared.md", hashFile(f), 14, "personal");
|
|
1096
|
+
const deletedAt = new Date().toISOString();
|
|
1097
|
+
const fetchMock = vi.fn().mockImplementation(async (url) => {
|
|
1098
|
+
const u = String(url);
|
|
1099
|
+
if (u.includes("/entity/check-slug/me")) {
|
|
1100
|
+
return {
|
|
1101
|
+
ok: true,
|
|
1102
|
+
status: 200,
|
|
1103
|
+
json: async () => ({ available: false, conflictingCompanyUid: mockEntity.uid }),
|
|
1104
|
+
text: async () => "",
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
if (u.includes("/entity/by-slug/") || /\/entity\/cmp_/.test(u)) {
|
|
1108
|
+
return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
|
|
1109
|
+
}
|
|
1110
|
+
if (u.includes("/sts/vend")) {
|
|
1111
|
+
return { ok: true, status: 200, json: async () => mockVendResponse, text: async () => "" };
|
|
1112
|
+
}
|
|
1113
|
+
if (u.includes("/v1/files/tombstones")) {
|
|
1114
|
+
return {
|
|
1115
|
+
ok: true,
|
|
1116
|
+
status: 200,
|
|
1117
|
+
json: async () => ({
|
|
1118
|
+
tombstones: [{ key: "docs/shared.md", deletedAt }],
|
|
1119
|
+
}),
|
|
1120
|
+
text: async () => "",
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
return { ok: false, status: 404, text: async () => "Not found" };
|
|
1124
|
+
});
|
|
1125
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
1126
|
+
const stale = await share({
|
|
1127
|
+
paths: [path.dirname(f)],
|
|
1128
|
+
company: "acme",
|
|
1129
|
+
vaultConfig: mockConfig,
|
|
1130
|
+
hqRoot: tmpDir,
|
|
1131
|
+
personalMode: true,
|
|
1132
|
+
journalSlug: "personal",
|
|
1133
|
+
});
|
|
1134
|
+
expect(stale.filesSuppressedByTombstone).toBe(1);
|
|
1135
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
1136
|
+
const tombstoneUrls = fetchMock.mock.calls
|
|
1137
|
+
.map((call) => String(call[0]))
|
|
1138
|
+
.filter((url) => url.includes("/v1/files/tombstones"));
|
|
1139
|
+
expect(tombstoneUrls).toEqual([
|
|
1140
|
+
"https://vault-api.test/v1/files/tombstones?personal=1",
|
|
1141
|
+
]);
|
|
1142
|
+
fs.writeFileSync(f, "edited after deletion");
|
|
1143
|
+
vi.mocked(uploadFile).mockClear();
|
|
1144
|
+
const recreated = await share({
|
|
1145
|
+
paths: [path.dirname(f)],
|
|
1146
|
+
company: "acme",
|
|
1147
|
+
vaultConfig: mockConfig,
|
|
1148
|
+
hqRoot: tmpDir,
|
|
1149
|
+
personalMode: true,
|
|
1150
|
+
journalSlug: "personal",
|
|
1151
|
+
});
|
|
1152
|
+
expect(recreated.filesSuppressedByTombstone).toBe(0);
|
|
1153
|
+
expect(recreated.filesUploaded).toBe(1);
|
|
1154
|
+
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), f, "docs/shared.md", undefined, expect.anything());
|
|
1155
|
+
});
|
|
1091
1156
|
});
|
|
1092
1157
|
it("populates conflictPaths and emits a conflict event when both local and remote drifted from journal", async () => {
|
|
1093
1158
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
@@ -1157,6 +1222,110 @@ describe("share", () => {
|
|
|
1157
1222
|
expect(fs.readFileSync(path.join(tmpDir, index.conflicts[0].conflictPath), "utf-8"))
|
|
1158
1223
|
.toBe("local edit");
|
|
1159
1224
|
});
|
|
1225
|
+
it.each([
|
|
1226
|
+
["local-only", false],
|
|
1227
|
+
["divergent-remote", true],
|
|
1228
|
+
])("direct source push (%s): emits a machine-readable refusal and fails when it is the only requested path", async (_state, divergentRemote) => {
|
|
1229
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1230
|
+
const testFile = path.join(companyRoot, "sources", "meetings", "m-1.md");
|
|
1231
|
+
fs.mkdirSync(path.dirname(testFile), { recursive: true });
|
|
1232
|
+
fs.writeFileSync(testFile, "local meeting source");
|
|
1233
|
+
if (divergentRemote) {
|
|
1234
|
+
seedJournalAt(path.join(stateDir, "sync-journal.acme.json"), {
|
|
1235
|
+
version: "1",
|
|
1236
|
+
lastSync: new Date().toISOString(),
|
|
1237
|
+
files: {
|
|
1238
|
+
"sources/meetings/m-1.md": {
|
|
1239
|
+
hash: "remote-body-hash",
|
|
1240
|
+
size: 21,
|
|
1241
|
+
syncedAt: new Date().toISOString(),
|
|
1242
|
+
direction: "down",
|
|
1243
|
+
remoteEtag: "remote-etag",
|
|
1244
|
+
localDiverges: true,
|
|
1245
|
+
},
|
|
1246
|
+
},
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
const events = [];
|
|
1250
|
+
await expect(share({
|
|
1251
|
+
paths: [testFile],
|
|
1252
|
+
company: "acme",
|
|
1253
|
+
vaultConfig: mockConfig,
|
|
1254
|
+
hqRoot: tmpDir,
|
|
1255
|
+
skipUnchanged: divergentRemote,
|
|
1256
|
+
onEvent: (event) => events.push(event),
|
|
1257
|
+
})).rejects.toMatchObject({
|
|
1258
|
+
name: "ServerOwnedPushPathsError",
|
|
1259
|
+
paths: ["sources/meetings/m-1.md"],
|
|
1260
|
+
});
|
|
1261
|
+
expect(events).toContainEqual({
|
|
1262
|
+
type: "push-refused-server-owned",
|
|
1263
|
+
path: "sources/meetings/m-1.md",
|
|
1264
|
+
nextStep: "use-source-ingestion",
|
|
1265
|
+
});
|
|
1266
|
+
expect(events.some((event) => event.type === "reconciled")).toBe(false);
|
|
1267
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
1268
|
+
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
1269
|
+
});
|
|
1270
|
+
it.each([
|
|
1271
|
+
["local-only", false],
|
|
1272
|
+
["divergent-remote", true],
|
|
1273
|
+
])("watch-driven source push (%s): explicit skip mode refuses without failing the pass", async (_state, divergentRemote) => {
|
|
1274
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1275
|
+
const testFile = path.join(companyRoot, "sources", "meetings", "m-2.md");
|
|
1276
|
+
fs.mkdirSync(path.dirname(testFile), { recursive: true });
|
|
1277
|
+
fs.writeFileSync(testFile, "local meeting source");
|
|
1278
|
+
if (divergentRemote) {
|
|
1279
|
+
seedJournalAt(path.join(stateDir, "sync-journal.acme.json"), {
|
|
1280
|
+
version: "1",
|
|
1281
|
+
lastSync: new Date().toISOString(),
|
|
1282
|
+
files: {
|
|
1283
|
+
"sources/meetings/m-2.md": {
|
|
1284
|
+
hash: "remote-body-hash",
|
|
1285
|
+
size: 21,
|
|
1286
|
+
syncedAt: new Date().toISOString(),
|
|
1287
|
+
direction: "down",
|
|
1288
|
+
remoteEtag: "remote-etag",
|
|
1289
|
+
localDiverges: true,
|
|
1290
|
+
},
|
|
1291
|
+
},
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
const events = [];
|
|
1295
|
+
const result = await share({
|
|
1296
|
+
paths: [testFile],
|
|
1297
|
+
company: "acme",
|
|
1298
|
+
vaultConfig: mockConfig,
|
|
1299
|
+
hqRoot: tmpDir,
|
|
1300
|
+
skipUnchanged: true,
|
|
1301
|
+
serverOwnedPathPolicy: "skip",
|
|
1302
|
+
onEvent: (event) => events.push(event),
|
|
1303
|
+
});
|
|
1304
|
+
expect(result).toMatchObject({ filesUploaded: 0, filesSkipped: 1 });
|
|
1305
|
+
expect(events).toContainEqual({
|
|
1306
|
+
type: "push-refused-server-owned",
|
|
1307
|
+
path: "sources/meetings/m-2.md",
|
|
1308
|
+
nextStep: "use-source-ingestion",
|
|
1309
|
+
});
|
|
1310
|
+
expect(events.some((event) => event.type === "reconciled")).toBe(false);
|
|
1311
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
1312
|
+
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
1313
|
+
});
|
|
1314
|
+
it("direct source push explains the server-owned refusal and supported next step", async () => {
|
|
1315
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1316
|
+
const testFile = path.join(companyRoot, "sources", "meetings", "m-3.md");
|
|
1317
|
+
fs.mkdirSync(path.dirname(testFile), { recursive: true });
|
|
1318
|
+
fs.writeFileSync(testFile, "local meeting source");
|
|
1319
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
1320
|
+
await expect(share({
|
|
1321
|
+
paths: [testFile],
|
|
1322
|
+
company: "acme",
|
|
1323
|
+
vaultConfig: mockConfig,
|
|
1324
|
+
hqRoot: tmpDir,
|
|
1325
|
+
})).rejects.toThrow(ServerOwnedPushPathsError);
|
|
1326
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("is server-owned and was not uploaded"));
|
|
1327
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("supported integration or ingestion flow"));
|
|
1328
|
+
});
|
|
1160
1329
|
it("cloud-authoritative: never pushes a stale local ontology/.last-run over the server value (one-sided-change clobber guard)", async () => {
|
|
1161
1330
|
// Regression for the gardener watermark clobber. A second HQ root sharing this
|
|
1162
1331
|
// machine's sync journal can leave the local mirror holding a STALE value A while
|
|
@@ -1190,11 +1359,14 @@ describe("share", () => {
|
|
|
1190
1359
|
},
|
|
1191
1360
|
},
|
|
1192
1361
|
}));
|
|
1362
|
+
const events = [];
|
|
1193
1363
|
const result = await share({
|
|
1194
1364
|
paths: [testFile],
|
|
1195
1365
|
company: "acme",
|
|
1196
1366
|
vaultConfig: mockConfig,
|
|
1197
1367
|
hqRoot: tmpDir,
|
|
1368
|
+
serverOwnedPathPolicy: "skip",
|
|
1369
|
+
onEvent: (event) => events.push(event),
|
|
1198
1370
|
});
|
|
1199
1371
|
// Server-owned file: push is skipped BEFORE HEAD, the stale local value is NEVER
|
|
1200
1372
|
// uploaded (the pull leg refreshes local from cloud instead), and it is not a conflict.
|
|
@@ -1203,6 +1375,12 @@ describe("share", () => {
|
|
|
1203
1375
|
expect(result.filesUploaded).toBe(0);
|
|
1204
1376
|
expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
|
|
1205
1377
|
expect(result.conflictPaths).toEqual([]);
|
|
1378
|
+
expect(events).toContainEqual({
|
|
1379
|
+
type: "push-refused-server-owned",
|
|
1380
|
+
path: "ontology/.last-run",
|
|
1381
|
+
nextStep: "change-upstream-and-pull",
|
|
1382
|
+
});
|
|
1383
|
+
expect(events.some((event) => event.type === "reconciled")).toBe(false);
|
|
1206
1384
|
});
|
|
1207
1385
|
it("first-time-upload-with-cloud-collision: emits conflict + writes mirror under --on-conflict keep (Bug #7)", async () => {
|
|
1208
1386
|
// Bug #7 (data-loss class) from the 5.33.0 deep test: when a file has
|