@indigoai-us/hq-cloud 6.18.16-alpha.main2 → 6.18.17

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 (75) hide show
  1. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  2. package/dist/bin/sync-runner-company.js +9 -1
  3. package/dist/bin/sync-runner-company.js.map +1 -1
  4. package/dist/bin/sync-runner-company.test.js +184 -3
  5. package/dist/bin/sync-runner-company.test.js.map +1 -1
  6. package/dist/bin/sync-runner.d.ts +8 -0
  7. package/dist/bin/sync-runner.d.ts.map +1 -1
  8. package/dist/bin/sync-runner.js.map +1 -1
  9. package/dist/bin/sync-runner.test.js +57 -2
  10. package/dist/bin/sync-runner.test.js.map +1 -1
  11. package/dist/cli/rescue-core.d.ts.map +1 -1
  12. package/dist/cli/rescue-core.js +21 -6
  13. package/dist/cli/rescue-core.js.map +1 -1
  14. package/dist/cli/rescue-rsync-status.test.js +23 -0
  15. package/dist/cli/rescue-rsync-status.test.js.map +1 -1
  16. package/dist/cli/share-plan-limit-presign.test.d.ts +2 -0
  17. package/dist/cli/share-plan-limit-presign.test.d.ts.map +1 -0
  18. package/dist/cli/share-plan-limit-presign.test.js +271 -0
  19. package/dist/cli/share-plan-limit-presign.test.js.map +1 -0
  20. package/dist/cli/share.d.ts +22 -0
  21. package/dist/cli/share.d.ts.map +1 -1
  22. package/dist/cli/share.js +151 -25
  23. package/dist/cli/share.js.map +1 -1
  24. package/dist/cli/share.test.js +262 -18
  25. package/dist/cli/share.test.js.map +1 -1
  26. package/dist/cli/sync.d.ts +38 -1
  27. package/dist/cli/sync.d.ts.map +1 -1
  28. package/dist/cli/sync.js +2 -1
  29. package/dist/cli/sync.js.map +1 -1
  30. package/dist/index.d.ts +5 -2
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +5 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/object-io.d.ts +40 -0
  35. package/dist/object-io.d.ts.map +1 -1
  36. package/dist/object-io.js +122 -5
  37. package/dist/object-io.js.map +1 -1
  38. package/dist/object-io.test.js +144 -1
  39. package/dist/object-io.test.js.map +1 -1
  40. package/dist/plan-limit-pause.d.ts +61 -0
  41. package/dist/plan-limit-pause.d.ts.map +1 -0
  42. package/dist/plan-limit-pause.js +252 -0
  43. package/dist/plan-limit-pause.js.map +1 -0
  44. package/dist/plan-limit-pause.test.d.ts +2 -0
  45. package/dist/plan-limit-pause.test.d.ts.map +1 -0
  46. package/dist/plan-limit-pause.test.js +150 -0
  47. package/dist/plan-limit-pause.test.js.map +1 -0
  48. package/dist/plan-limit.d.ts +87 -0
  49. package/dist/plan-limit.d.ts.map +1 -0
  50. package/dist/plan-limit.fixtures.d.ts +34 -0
  51. package/dist/plan-limit.fixtures.d.ts.map +1 -0
  52. package/dist/plan-limit.fixtures.js +96 -0
  53. package/dist/plan-limit.fixtures.js.map +1 -0
  54. package/dist/plan-limit.js +157 -0
  55. package/dist/plan-limit.js.map +1 -0
  56. package/dist/plan-limit.test.d.ts +2 -0
  57. package/dist/plan-limit.test.d.ts.map +1 -0
  58. package/dist/plan-limit.test.js +132 -0
  59. package/dist/plan-limit.test.js.map +1 -0
  60. package/dist/sync/index.d.ts +1 -1
  61. package/dist/sync/index.d.ts.map +1 -1
  62. package/dist/sync/index.js.map +1 -1
  63. package/dist/sync/mutation-client.d.ts +14 -2
  64. package/dist/sync/mutation-client.d.ts.map +1 -1
  65. package/dist/sync/mutation-client.js +34 -2
  66. package/dist/sync/mutation-client.js.map +1 -1
  67. package/dist/sync/mutation-client.test.js +49 -0
  68. package/dist/sync/mutation-client.test.js.map +1 -1
  69. package/dist/vault-client.d.ts +49 -6
  70. package/dist/vault-client.d.ts.map +1 -1
  71. package/dist/vault-client.js +115 -43
  72. package/dist/vault-client.js.map +1 -1
  73. package/dist/vault-client.test.js +86 -10
  74. package/dist/vault-client.test.js.map +1 -1
  75. package/package.json +1 -1
@@ -103,7 +103,9 @@ vi.mock("readline", () => ({
103
103
  import * as readline from "readline";
104
104
  import { share, ServerOwnedPushPathsError, UnreachablePushPathsError, UnregisteredCompanySkillError, currentUploadBytes, FULL_PUSH_COOPERATIVE_CHECKPOINT_BATCH_SIZE, parseRegisteredCompanySkillUid, setPushCollectCooperativeYieldMsForTesting, _testing as shareTesting, } from "./share.js";
105
105
  import { deleteRemoteFile, downloadFile, headRemoteFile, primeObjectTransport, primeUploads, setUploadContentHash, uploadFile, uploadSymlink, WindowsSymlinkPrivilegeError, } from "../s3.js";
106
- import { VaultAuthError, VaultClientError, VaultConflictError, VaultPermissionDeniedError, } from "../vault-client.js";
106
+ import { VaultAuthError, VaultClientError, VaultConflictError, VaultPermissionDeniedError, vaultErrorFromResponse, } from "../vault-client.js";
107
+ import { PLAN_LIMIT_UPGRADE_URL, canonicalPlanLimitBody, legacyMembershipPlanLimitBody, } from "../plan-limit.fixtures.js";
108
+ import { PLAN_LIMIT_PAUSE_RETRY_MS, PlanLimitPauseStore, setPlanLimitPauseClockForTesting, } from "../plan-limit-pause.js";
107
109
  import { CognitoRefreshError } from "../cognito-auth.js";
108
110
  import { RateLimitedError } from "../object-io.js";
109
111
  import { resolveSkillRegisterLandedBytes } from "../lib/skill-register-landed-bytes.js";
@@ -2132,36 +2134,278 @@ describe("share", () => {
2132
2134
  });
2133
2135
  expect(journalAt(journalPath).files[secretKey]?.removedAt).toBeUndefined();
2134
2136
  });
2135
- it("emits one server-linked plan-limit notice for a multi-file upload pass", async () => {
2136
- const companyRoot = path.join(tmpDir, "companies", "acme");
2137
- const paths = ["first.md", "second.md"].map((name) => {
2138
- const file = path.join(companyRoot, name);
2139
- fs.mkdirSync(path.dirname(file), { recursive: true });
2140
- fs.writeFileSync(file, "# paused by plan limit");
2141
- return file;
2137
+ describe("plan-limit refusals", () => {
2138
+ const T0 = Date.parse("2026-09-28T12:00:00.000Z");
2139
+ let clock = T0;
2140
+ beforeEach(() => {
2141
+ clock = T0;
2142
+ setPlanLimitPauseClockForTesting(() => clock);
2142
2143
  });
2143
- const upgradeUrl = "https://billing.example.test/company/acme";
2144
- vi.mocked(uploadFile).mockReset();
2145
- vi.mocked(uploadFile).mockRejectedValue(new VaultClientError("Plan limit reached", 402, JSON.stringify({ code: "plan_limit_reached", upgradeUrl }), { code: "plan_limit_reached", upgradeUrl }));
2146
- const events = [];
2147
- try {
2144
+ afterEach(() => {
2145
+ setPlanLimitPauseClockForTesting(null);
2146
+ vi.mocked(uploadFile).mockReset();
2147
+ vi.mocked(uploadFile).mockResolvedValue({ etag: '"upload-etag"' });
2148
+ });
2149
+ function writeCompanyFiles(names) {
2150
+ const companyRoot = path.join(tmpDir, "companies", "acme");
2151
+ return names.map((name) => {
2152
+ const file = path.join(companyRoot, name);
2153
+ fs.mkdirSync(path.dirname(file), { recursive: true });
2154
+ fs.writeFileSync(file, `# ${name} paused by plan limit`);
2155
+ return file;
2156
+ });
2157
+ }
2158
+ /** The error the vault client throws for the server's canonical 402 body. */
2159
+ function canonical402() {
2160
+ return vaultErrorFromResponse(402, JSON.stringify(canonicalPlanLimitBody()));
2161
+ }
2162
+ function collect() {
2163
+ const events = [];
2164
+ return { events, onEvent: (event) => events.push(event) };
2165
+ }
2166
+ it("reports one notice with the refused files, the server message and the upgrade link", async () => {
2167
+ const paths = writeCompanyFiles(["first.md", "second.md"]);
2168
+ vi.mocked(uploadFile).mockReset();
2169
+ vi.mocked(uploadFile).mockRejectedValue(canonical402());
2170
+ const { events, onEvent } = collect();
2148
2171
  const result = await share({
2149
2172
  paths,
2150
2173
  company: "acme",
2151
2174
  vaultConfig: mockConfig,
2152
2175
  hqRoot: tmpDir,
2153
- onEvent: (event) => events.push(event),
2176
+ onEvent,
2154
2177
  });
2155
2178
  const notices = events.filter((event) => event.type === "plan-limit");
2156
- expect(notices).toEqual([{ type: "plan-limit", upgradeUrl }]);
2179
+ expect(notices).toEqual([
2180
+ {
2181
+ type: "plan-limit",
2182
+ upgradeUrl: PLAN_LIMIT_UPGRADE_URL,
2183
+ message: "New files are paused while Acme is over its Starter limits.",
2184
+ code: "PLAN_LIMIT_EXCEEDED",
2185
+ error: "plan_limit_reached",
2186
+ blocked: "files.create",
2187
+ resources: [
2188
+ { resource: "storageBytes", used: 11_811_160_064, limit: 10_737_418_240 },
2189
+ ],
2190
+ fixOptions: { storageBytes: 10_737_418_240 },
2191
+ freeMonth: {
2192
+ eligible: true,
2193
+ trialDays: 30,
2194
+ url: "https://billing.example.test/companies/acme/billing?offer=free-month",
2195
+ },
2196
+ count: 2,
2197
+ paths: expect.arrayContaining(["first.md", "second.md"]),
2198
+ pausedUntil: new Date(T0 + PLAN_LIMIT_PAUSE_RETRY_MS).toISOString(),
2199
+ fromPause: false,
2200
+ },
2201
+ ]);
2157
2202
  expect(events.some((event) => event.type === "error")).toBe(false);
2158
2203
  expect(result.filesUploaded).toBe(0);
2159
2204
  expect(result.filesSkipped).toBe(2);
2160
- }
2161
- finally {
2205
+ expect(result.filesPlanLimited).toBe(2);
2206
+ expect(result.planLimitedPaths?.sort()).toEqual(["first.md", "second.md"]);
2207
+ expect(result.planLimit?.upgradeUrl).toBe(PLAN_LIMIT_UPGRADE_URL);
2208
+ // The refusal is recorded so the runner does not presign these again.
2209
+ const pauses = PlanLimitPauseStore.open("cmp_01ABCDEF");
2210
+ expect(pauses.isPaused("first.md", T0)).toBe(true);
2211
+ expect(pauses.isPaused("second.md", T0)).toBe(true);
2212
+ });
2213
+ it("recognises the legacy membership 402 shape the same way", async () => {
2214
+ const paths = writeCompanyFiles(["legacy.md"]);
2215
+ vi.mocked(uploadFile).mockReset();
2216
+ vi.mocked(uploadFile).mockRejectedValue(vaultErrorFromResponse(402, JSON.stringify(legacyMembershipPlanLimitBody())));
2217
+ const { events, onEvent } = collect();
2218
+ const result = await share({ paths, company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir, onEvent });
2219
+ expect(events.filter((e) => e.type === "plan-limit")).toEqual([
2220
+ expect.objectContaining({ upgradeUrl: PLAN_LIMIT_UPGRADE_URL, count: 1, paths: ["legacy.md"] }),
2221
+ ]);
2222
+ expect(events.some((event) => event.type === "error")).toBe(false);
2223
+ expect(result.filesPlanLimited).toBe(1);
2224
+ });
2225
+ it("with planLimitPause, skips paused files without uploading until the hour lapses", async () => {
2226
+ const paths = writeCompanyFiles(["a.md", "b.md", "c.md"]);
2227
+ vi.mocked(uploadFile).mockReset();
2228
+ vi.mocked(uploadFile).mockRejectedValue(canonical402());
2229
+ const pass = async () => {
2230
+ const { events, onEvent } = collect();
2231
+ const result = await share({
2232
+ paths,
2233
+ company: "acme",
2234
+ vaultConfig: mockConfig,
2235
+ hqRoot: tmpDir,
2236
+ skipUnchanged: true,
2237
+ planLimitPause: true,
2238
+ onEvent,
2239
+ });
2240
+ return { events, result };
2241
+ };
2242
+ const first = await pass();
2243
+ expect(vi.mocked(uploadFile)).toHaveBeenCalledTimes(3);
2244
+ expect(first.events.filter((e) => e.type === "plan-limit")).toHaveLength(1);
2245
+ for (let i = 0; i < 4; i++) {
2246
+ clock += 10 * 60 * 1000;
2247
+ const next = await pass();
2248
+ // Still announced every pass, from the stored refusal.
2249
+ expect(next.events.filter((e) => e.type === "plan-limit")).toEqual([
2250
+ expect.objectContaining({
2251
+ fromPause: true,
2252
+ count: 3,
2253
+ upgradeUrl: PLAN_LIMIT_UPGRADE_URL,
2254
+ message: "New files are paused while Acme is over its Starter limits.",
2255
+ pausedUntil: new Date(T0 + PLAN_LIMIT_PAUSE_RETRY_MS).toISOString(),
2256
+ }),
2257
+ ]);
2258
+ expect(next.events.some((e) => e.type === "error")).toBe(false);
2259
+ expect(next.result.filesUploaded).toBe(0);
2260
+ expect(next.result.filesPlanLimited).toBe(3);
2261
+ expect(next.result.planLimit?.upgradeUrl).toBe(PLAN_LIMIT_UPGRADE_URL);
2262
+ }
2263
+ // Four more passes inside the hour: no further upload attempts.
2264
+ expect(vi.mocked(uploadFile)).toHaveBeenCalledTimes(3);
2265
+ clock = T0 + PLAN_LIMIT_PAUSE_RETRY_MS;
2266
+ const retry = await pass();
2267
+ expect(vi.mocked(uploadFile)).toHaveBeenCalledTimes(6);
2268
+ expect(retry.events.filter((e) => e.type === "plan-limit")).toEqual([
2269
+ expect.objectContaining({ count: 3, fromPause: false }),
2270
+ ]);
2271
+ });
2272
+ it("announces files a direct push paused when the runner then skips them (fromPause)", async () => {
2273
+ const paths = writeCompanyFiles(["a.md", "b.md"]);
2274
+ vi.mocked(uploadFile).mockReset();
2275
+ vi.mocked(uploadFile).mockRejectedValue(canonical402());
2276
+ // `hq sync push` (no planLimitPause) refuses a.md and b.md and records
2277
+ // the pauses, but does not obey them itself.
2278
+ await share({ paths, company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir, onEvent: () => { } });
2279
+ expect(vi.mocked(uploadFile)).toHaveBeenCalledTimes(2);
2280
+ // Twenty minutes later the runner (or a restarted desktop) skips them.
2281
+ // It must say so: one plan-limit event built from the stored refusal.
2282
+ clock = T0 + 20 * 60 * 1000;
2283
+ const { events, onEvent } = collect();
2284
+ const result = await share({
2285
+ paths,
2286
+ company: "acme",
2287
+ vaultConfig: mockConfig,
2288
+ hqRoot: tmpDir,
2289
+ skipUnchanged: true,
2290
+ planLimitPause: true,
2291
+ onEvent,
2292
+ });
2293
+ expect(vi.mocked(uploadFile)).toHaveBeenCalledTimes(2);
2294
+ expect(result.filesPlanLimited).toBe(2);
2295
+ expect(events.filter((e) => e.type === "plan-limit")).toEqual([
2296
+ {
2297
+ type: "plan-limit",
2298
+ upgradeUrl: PLAN_LIMIT_UPGRADE_URL,
2299
+ message: "New files are paused while Acme is over its Starter limits.",
2300
+ code: "PLAN_LIMIT_EXCEEDED",
2301
+ error: "plan_limit_reached",
2302
+ blocked: "files.create",
2303
+ resources: [
2304
+ { resource: "storageBytes", used: 11_811_160_064, limit: 10_737_418_240 },
2305
+ ],
2306
+ fixOptions: { storageBytes: 10_737_418_240 },
2307
+ freeMonth: {
2308
+ eligible: true,
2309
+ trialDays: 30,
2310
+ url: "https://billing.example.test/companies/acme/billing?offer=free-month",
2311
+ },
2312
+ count: 2,
2313
+ paths: expect.arrayContaining(["a.md", "b.md"]),
2314
+ pausedUntil: new Date(T0 + PLAN_LIMIT_PAUSE_RETRY_MS).toISOString(),
2315
+ fromPause: true,
2316
+ },
2317
+ ]);
2318
+ expect(events.some((e) => e.type === "error")).toBe(false);
2319
+ });
2320
+ it("reports fresh refusals and paused skips together in one event", async () => {
2321
+ const [first] = writeCompanyFiles(["first.md"]);
2322
+ vi.mocked(uploadFile).mockReset();
2323
+ vi.mocked(uploadFile).mockRejectedValue(canonical402());
2324
+ await share({
2325
+ paths: [first],
2326
+ company: "acme",
2327
+ vaultConfig: mockConfig,
2328
+ hqRoot: tmpDir,
2329
+ skipUnchanged: true,
2330
+ planLimitPause: true,
2331
+ onEvent: () => { },
2332
+ });
2333
+ clock = T0 + 30 * 60 * 1000;
2334
+ const [second] = writeCompanyFiles(["second.md"]);
2335
+ const { events, onEvent } = collect();
2336
+ await share({
2337
+ paths: [first, second],
2338
+ company: "acme",
2339
+ vaultConfig: mockConfig,
2340
+ hqRoot: tmpDir,
2341
+ skipUnchanged: true,
2342
+ planLimitPause: true,
2343
+ onEvent,
2344
+ });
2345
+ expect(events.filter((e) => e.type === "plan-limit")).toEqual([
2346
+ expect.objectContaining({
2347
+ fromPause: false,
2348
+ count: 2,
2349
+ paths: ["second.md", "first.md"],
2350
+ // The earliest retry: first.md's pause from T0.
2351
+ pausedUntil: new Date(T0 + PLAN_LIMIT_PAUSE_RETRY_MS).toISOString(),
2352
+ }),
2353
+ ]);
2354
+ });
2355
+ it("without planLimitPause (a direct push), retries and reports refused files every time", async () => {
2356
+ const paths = writeCompanyFiles(["a.md"]);
2357
+ vi.mocked(uploadFile).mockReset();
2358
+ vi.mocked(uploadFile).mockRejectedValue(canonical402());
2359
+ for (let i = 0; i < 2; i++) {
2360
+ const { events, onEvent } = collect();
2361
+ await share({ paths, company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir, skipUnchanged: true, onEvent });
2362
+ expect(events.filter((e) => e.type === "plan-limit")).toHaveLength(1);
2363
+ }
2364
+ expect(vi.mocked(uploadFile)).toHaveBeenCalledTimes(2);
2365
+ });
2366
+ it("drops every pause once an upload to the same vault succeeds", async () => {
2367
+ const [paused] = writeCompanyFiles(["paused.md"]);
2368
+ vi.mocked(uploadFile).mockReset();
2369
+ vi.mocked(uploadFile).mockRejectedValue(canonical402());
2370
+ await share({
2371
+ paths: [paused],
2372
+ company: "acme",
2373
+ vaultConfig: mockConfig,
2374
+ hqRoot: tmpDir,
2375
+ skipUnchanged: true,
2376
+ planLimitPause: true,
2377
+ onEvent: () => { },
2378
+ });
2379
+ expect(PlanLimitPauseStore.open("cmp_01ABCDEF").isPaused("paused.md", clock)).toBe(true);
2380
+ // The plan changed: the server accepts writes again. A new file uploads,
2381
+ // which clears the record so the paused file is retried next pass.
2162
2382
  vi.mocked(uploadFile).mockReset();
2163
2383
  vi.mocked(uploadFile).mockResolvedValue({ etag: '"upload-etag"' });
2164
- }
2384
+ const [fresh] = writeCompanyFiles(["fresh.md"]);
2385
+ const accepted = await share({
2386
+ paths: [paused, fresh],
2387
+ company: "acme",
2388
+ vaultConfig: mockConfig,
2389
+ hqRoot: tmpDir,
2390
+ skipUnchanged: true,
2391
+ planLimitPause: true,
2392
+ onEvent: () => { },
2393
+ });
2394
+ expect(accepted.filesUploaded).toBe(1);
2395
+ expect(accepted.filesPlanLimited).toBe(1);
2396
+ expect(PlanLimitPauseStore.open("cmp_01ABCDEF").size).toBe(0);
2397
+ const next = await share({
2398
+ paths: [paused, fresh],
2399
+ company: "acme",
2400
+ vaultConfig: mockConfig,
2401
+ hqRoot: tmpDir,
2402
+ skipUnchanged: true,
2403
+ planLimitPause: true,
2404
+ onEvent: () => { },
2405
+ });
2406
+ expect(next.filesUploaded).toBe(1);
2407
+ expect(next.filesPlanLimited).toBe(0);
2408
+ });
2165
2409
  });
2166
2410
  it("hands queued realtime work a settled full-push boundary without losing journal rows", async () => {
2167
2411
  const companyRoot = path.join(tmpDir, "companies", "acme");