@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.
@@ -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, "sync-journal.acme.json"), ({
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