@indigoai-us/hq-cloud 6.14.12 → 6.14.14
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/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +22 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +6 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +3 -1
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +10 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +42 -3
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts +1 -0
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-routes.js +3 -0
- package/dist/bin/sync-runner-watch-routes.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +17 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +83 -33
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +193 -5
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +23 -60
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.d.ts +2 -2
- package/dist/cli/reindex.test.js +60 -109
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/rescue.reindex.test.js +3 -2
- package/dist/cli/rescue.reindex.test.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +12 -1
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +14 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +9 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +8 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cognito-auth.d.ts +44 -12
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +440 -76
- package/dist/cognito-auth.js.map +1 -1
- package/dist/cognito-auth.test.js +174 -0
- package/dist/cognito-auth.test.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/machine-auth.test.js +683 -5
- package/dist/machine-auth.test.js.map +1 -1
- package/dist/vault-client.d.ts +2 -1
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +45 -9
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +77 -7
- package/dist/vault-client.test.js.map +1 -1
- package/package.json +2 -1
- package/src/bin/sync-runner-company.ts +5 -0
- package/src/bin/sync-runner-planning.ts +2 -1
- package/src/bin/sync-runner-watch-loop.ts +51 -4
- package/src/bin/sync-runner-watch-routes.ts +2 -0
- package/src/bin/sync-runner.test.ts +244 -4
- package/src/bin/sync-runner.ts +99 -32
- package/src/cli/reindex.test.ts +63 -120
- package/src/cli/reindex.ts +23 -62
- package/src/cli/rescue.reindex.test.ts +3 -2
- package/src/cli/share.test.ts +20 -0
- package/src/cli/share.ts +7 -1
- package/src/cli/sync.test.ts +14 -0
- package/src/cli/sync.ts +5 -1
- package/src/cognito-auth.test.ts +227 -0
- package/src/cognito-auth.ts +533 -71
- package/src/index.ts +5 -1
- package/src/machine-auth.test.ts +808 -5
- package/src/vault-client.test.ts +100 -11
- package/src/vault-client.ts +55 -10
- package/test/share-sync.integration.test.ts +12 -9
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
readPinnedPrefixes,
|
|
24
24
|
SESSIONS_SCOPE_PREFIX,
|
|
25
25
|
defaultCollectTelemetry,
|
|
26
|
+
AUTH_REQUIRED_PASS_EXIT,
|
|
26
27
|
} from "./sync-runner.js";
|
|
27
28
|
import { PresignObjectIO, S3SdkObjectIO } from "../object-io.js";
|
|
28
29
|
import type { EntityContext, VaultServiceConfig } from "../types.js";
|
|
@@ -33,6 +34,7 @@ import type {
|
|
|
33
34
|
WatcherSurface,
|
|
34
35
|
} from "./sync-runner.js";
|
|
35
36
|
import { FakeClock } from "../watcher.js";
|
|
37
|
+
import { adaptivePollMs } from "./sync-runner-watch-loop.js";
|
|
36
38
|
import { PERSONAL_VAULT_JOURNAL_SLUG } from "../journal.js";
|
|
37
39
|
import { HQ_CLOUD_VERSION } from "../version.js";
|
|
38
40
|
import { lockPathFor, OPERATION_LOCKED_EXIT } from "../operation-lock.js";
|
|
@@ -48,6 +50,7 @@ import type {
|
|
|
48
50
|
TelemetryEventsBatch,
|
|
49
51
|
} from "../vault-client.js";
|
|
50
52
|
import { VaultAuthError, VaultNotFoundError } from "../vault-client.js";
|
|
53
|
+
import { CognitoRefreshError } from "../cognito-auth.js";
|
|
51
54
|
import type { PushEvent } from "../sync/push-event.js";
|
|
52
55
|
|
|
53
56
|
// ---------------------------------------------------------------------------
|
|
@@ -209,6 +212,11 @@ function makeDeps(overrides: Partial<RunnerDeps> = {}): TestDeps {
|
|
|
209
212
|
// deps without each override re-wrapping.
|
|
210
213
|
return {
|
|
211
214
|
getAccessToken: vi.fn().mockResolvedValue("test-access-token"),
|
|
215
|
+
clearSession: vi.fn(),
|
|
216
|
+
// Keep tests isolated from the developer/agent token cache. Without this,
|
|
217
|
+
// an agent-shaped real idToken enables the post-sync AGENTS materializer
|
|
218
|
+
// and adds ambient diagnostic events to otherwise-hermetic assertions.
|
|
219
|
+
getIdTokenClaims: vi.fn().mockReturnValue(null),
|
|
212
220
|
createVaultClient: vi.fn().mockImplementation(() => makeVaultStub()),
|
|
213
221
|
sync: vi.fn().mockResolvedValue(defaultSyncResult()),
|
|
214
222
|
reindex: vi.fn(() => ({ status: 0 })),
|
|
@@ -288,11 +296,37 @@ describe("auth", () => {
|
|
|
288
296
|
expect(code).toBe(0);
|
|
289
297
|
// auth-error is an error-class event → stderr, not stdout
|
|
290
298
|
expect(deps.stderr.events()).toEqual([
|
|
291
|
-
{
|
|
299
|
+
{
|
|
300
|
+
type: "auth-error",
|
|
301
|
+
message:
|
|
302
|
+
"Your HQ session needs a quick refresh. Sign in again to keep sync moving.",
|
|
303
|
+
},
|
|
292
304
|
]);
|
|
293
305
|
expect(deps.stdout.events()).toEqual([]);
|
|
294
306
|
});
|
|
295
307
|
|
|
308
|
+
it("keeps the watch pass retryable when initial refresh fails transiently", async () => {
|
|
309
|
+
const deps = makeDeps({
|
|
310
|
+
getAccessToken: vi
|
|
311
|
+
.fn()
|
|
312
|
+
.mockRejectedValue(
|
|
313
|
+
new CognitoRefreshError("Cognito refresh request failed: ECONNRESET", false),
|
|
314
|
+
),
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const code = await runRunner(["--companies"], deps);
|
|
318
|
+
|
|
319
|
+
expect(code).toBe(TRANSIENT_NETWORK_EXIT);
|
|
320
|
+
expect(deps.stderr.events()).toEqual([
|
|
321
|
+
{
|
|
322
|
+
type: "error",
|
|
323
|
+
path: "(auth)",
|
|
324
|
+
message: "Cognito refresh request failed: ECONNRESET",
|
|
325
|
+
},
|
|
326
|
+
]);
|
|
327
|
+
expect(deps.clearSession).not.toHaveBeenCalled();
|
|
328
|
+
});
|
|
329
|
+
|
|
296
330
|
it("emits auth-error on stderr when VaultAuthError thrown during discovery", async () => {
|
|
297
331
|
const deps = makeDeps({
|
|
298
332
|
createVaultClient: () => ({
|
|
@@ -304,11 +338,38 @@ describe("auth", () => {
|
|
|
304
338
|
const code = await runRunner(["--companies"], deps);
|
|
305
339
|
expect(code).toBe(0);
|
|
306
340
|
expect(deps.stderr.events()).toEqual([
|
|
307
|
-
{
|
|
341
|
+
{
|
|
342
|
+
type: "auth-error",
|
|
343
|
+
message:
|
|
344
|
+
"Your HQ session needs a quick refresh. Sign in again to keep sync moving.",
|
|
345
|
+
},
|
|
308
346
|
]);
|
|
347
|
+
expect(deps.clearSession).toHaveBeenCalledTimes(1);
|
|
309
348
|
expect(deps.stdout.events()).toEqual([]);
|
|
310
349
|
});
|
|
311
350
|
|
|
351
|
+
it("handles a typed 401 while planning the personal target", async () => {
|
|
352
|
+
const deps = makeDeps({
|
|
353
|
+
createVaultClient: () =>
|
|
354
|
+
makeVaultStub({
|
|
355
|
+
listPersons: () =>
|
|
356
|
+
Promise.reject(new VaultAuthError("personal lookup unauthorized")),
|
|
357
|
+
}),
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const code = await runRunner(["--personal"], deps);
|
|
361
|
+
|
|
362
|
+
expect(code).toBe(0);
|
|
363
|
+
expect(deps.clearSession).toHaveBeenCalledTimes(1);
|
|
364
|
+
expect(deps.stderr.events()).toEqual([
|
|
365
|
+
{
|
|
366
|
+
type: "auth-error",
|
|
367
|
+
message:
|
|
368
|
+
"Your HQ session needs a quick refresh. Sign in again to keep sync moving.",
|
|
369
|
+
},
|
|
370
|
+
]);
|
|
371
|
+
});
|
|
372
|
+
|
|
312
373
|
it("emits error event on stderr and returns 1 on non-auth discovery failure (after 3 attempts)", async () => {
|
|
313
374
|
let calls = 0;
|
|
314
375
|
const deps = makeDeps({
|
|
@@ -459,8 +520,13 @@ describe("memberships retry", () => {
|
|
|
459
520
|
// Auth failures don't retry — re-vending creds is the caller's job.
|
|
460
521
|
expect(listCalls).toBe(1);
|
|
461
522
|
expect(deps.stderr.events()).toEqual([
|
|
462
|
-
{
|
|
523
|
+
{
|
|
524
|
+
type: "auth-error",
|
|
525
|
+
message:
|
|
526
|
+
"Your HQ session needs a quick refresh. Sign in again to keep sync moving.",
|
|
527
|
+
},
|
|
463
528
|
]);
|
|
529
|
+
expect(deps.clearSession).toHaveBeenCalledTimes(1);
|
|
464
530
|
});
|
|
465
531
|
|
|
466
532
|
it("single-company mode bypasses listMyMemberships entirely (no retry path triggered)", async () => {
|
|
@@ -1512,6 +1578,31 @@ describe("per-company fanout", () => {
|
|
|
1512
1578
|
expect(betaComplete?.filesDownloaded).toBe(1);
|
|
1513
1579
|
});
|
|
1514
1580
|
|
|
1581
|
+
it("turns a typed mid-fanout 401 into one terminal auth-error", async () => {
|
|
1582
|
+
const deps = makeDeps({
|
|
1583
|
+
createVaultClient: () =>
|
|
1584
|
+
makeVaultStub({
|
|
1585
|
+
memberships: [{ companyUid: "cmp_auth" }],
|
|
1586
|
+
entityGet: (uid: string) =>
|
|
1587
|
+
Promise.resolve({ uid, slug: "auth-co" } as unknown as EntityInfo),
|
|
1588
|
+
}),
|
|
1589
|
+
sync: vi.fn().mockRejectedValue(new VaultAuthError("expired access token")),
|
|
1590
|
+
});
|
|
1591
|
+
|
|
1592
|
+
const code = await runRunner(["--companies"], deps);
|
|
1593
|
+
|
|
1594
|
+
expect(code).toBe(0);
|
|
1595
|
+
expect(deps.clearSession).toHaveBeenCalledTimes(1);
|
|
1596
|
+
expect(deps.stderr.events()).toEqual([
|
|
1597
|
+
{
|
|
1598
|
+
type: "auth-error",
|
|
1599
|
+
message:
|
|
1600
|
+
"Your HQ session needs a quick refresh. Sign in again to keep sync moving.",
|
|
1601
|
+
},
|
|
1602
|
+
]);
|
|
1603
|
+
expect(deps.stdout.events().some((event) => event.type === "all-complete")).toBe(false);
|
|
1604
|
+
});
|
|
1605
|
+
|
|
1515
1606
|
/**
|
|
1516
1607
|
* Regression test: AWS SDK v3 raises an opaque `UnknownError` (name and
|
|
1517
1608
|
* message both literally "UnknownError") when it cannot match a Node-side
|
|
@@ -3375,6 +3466,12 @@ describe("routeChangeToTarget", () => {
|
|
|
3375
3466
|
expect(routeChangeToTarget("companies")).toBeNull();
|
|
3376
3467
|
expect(routeChangeToTarget("companies/")).toBeNull();
|
|
3377
3468
|
});
|
|
3469
|
+
|
|
3470
|
+
it("does not route the company scaffold to cloud sync", () => {
|
|
3471
|
+
expect(
|
|
3472
|
+
routeChangeToTarget("companies/_template/knowledge/README.md"),
|
|
3473
|
+
).toBeNull();
|
|
3474
|
+
});
|
|
3378
3475
|
});
|
|
3379
3476
|
|
|
3380
3477
|
describe("buildTargetedPushArgv", () => {
|
|
@@ -3481,6 +3578,127 @@ function makeSteppableSleep() {
|
|
|
3481
3578
|
};
|
|
3482
3579
|
}
|
|
3483
3580
|
|
|
3581
|
+
// ---------------------------------------------------------------------------
|
|
3582
|
+
// Adaptive remote-poll backoff — whole-box CPU aware (no --poll-remote-ms)
|
|
3583
|
+
// ---------------------------------------------------------------------------
|
|
3584
|
+
|
|
3585
|
+
describe("adaptivePollMs (whole-box CPU backoff)", () => {
|
|
3586
|
+
const FLOOR = 60_000;
|
|
3587
|
+
const CEIL = 600_000;
|
|
3588
|
+
|
|
3589
|
+
it("returns the floor when the box is idle (load 0)", () => {
|
|
3590
|
+
expect(adaptivePollMs(0, 8)).toBe(FLOOR);
|
|
3591
|
+
});
|
|
3592
|
+
|
|
3593
|
+
it("returns the ceiling when load equals the core count (fully saturated)", () => {
|
|
3594
|
+
expect(adaptivePollMs(8, 8)).toBe(CEIL);
|
|
3595
|
+
});
|
|
3596
|
+
|
|
3597
|
+
it("clamps to the ceiling when load exceeds the core count", () => {
|
|
3598
|
+
expect(adaptivePollMs(64, 8)).toBe(CEIL);
|
|
3599
|
+
});
|
|
3600
|
+
|
|
3601
|
+
it("scales linearly between floor and ceiling at half saturation", () => {
|
|
3602
|
+
// load 4 on 8 cores → ratio 0.5 → midpoint of [60s, 600s] = 330s.
|
|
3603
|
+
expect(adaptivePollMs(4, 8)).toBe(330_000);
|
|
3604
|
+
});
|
|
3605
|
+
|
|
3606
|
+
it("treats a negative load sample as idle (defensive)", () => {
|
|
3607
|
+
expect(adaptivePollMs(-1, 8)).toBe(FLOOR);
|
|
3608
|
+
});
|
|
3609
|
+
|
|
3610
|
+
it("guards a zero/absent core count without dividing by zero", () => {
|
|
3611
|
+
// cores coerced to 1; load 1 → fully saturated → ceil.
|
|
3612
|
+
expect(adaptivePollMs(1, 0)).toBe(CEIL);
|
|
3613
|
+
});
|
|
3614
|
+
|
|
3615
|
+
it("honors custom floor/ceil bounds", () => {
|
|
3616
|
+
expect(adaptivePollMs(0, 4, 30_000, 300_000)).toBe(30_000);
|
|
3617
|
+
expect(adaptivePollMs(4, 4, 30_000, 300_000)).toBe(300_000);
|
|
3618
|
+
});
|
|
3619
|
+
});
|
|
3620
|
+
|
|
3621
|
+
describe("runRunnerWithLoop — adaptive poll interval (no --poll-remote-ms)", () => {
|
|
3622
|
+
// Capture the ms handed to sleep on the FIRST poll, then hang so the loop
|
|
3623
|
+
// parks until shutdown (mirrors the never-resolving sleep the other loop
|
|
3624
|
+
// tests use).
|
|
3625
|
+
function makeCapturingSleep() {
|
|
3626
|
+
const calls: number[] = [];
|
|
3627
|
+
return {
|
|
3628
|
+
calls,
|
|
3629
|
+
sleep: (ms: number) => {
|
|
3630
|
+
calls.push(ms);
|
|
3631
|
+
return new Promise<void>(() => {});
|
|
3632
|
+
},
|
|
3633
|
+
};
|
|
3634
|
+
}
|
|
3635
|
+
|
|
3636
|
+
it("sleeps the floor interval when the box is idle", async () => {
|
|
3637
|
+
const { calls, sleep } = makeCapturingSleep();
|
|
3638
|
+
let triggerShutdown = () => {};
|
|
3639
|
+
const loop = runRunnerWithLoop(
|
|
3640
|
+
["--companies", "--watch", "--hq-root", "/tmp/hq"],
|
|
3641
|
+
{
|
|
3642
|
+
runPass: vi.fn().mockResolvedValue(0),
|
|
3643
|
+
sleep,
|
|
3644
|
+
sampleLoadAvg: () => 0,
|
|
3645
|
+
onShutdownSignal: (handler) => {
|
|
3646
|
+
triggerShutdown = handler;
|
|
3647
|
+
return () => {};
|
|
3648
|
+
},
|
|
3649
|
+
},
|
|
3650
|
+
);
|
|
3651
|
+
await flushLoopMicrotasks();
|
|
3652
|
+
expect(calls[0]).toBe(60_000);
|
|
3653
|
+
triggerShutdown();
|
|
3654
|
+
await loop;
|
|
3655
|
+
});
|
|
3656
|
+
|
|
3657
|
+
it("backs off to the ceiling interval when the box is fully loaded", async () => {
|
|
3658
|
+
const { calls, sleep } = makeCapturingSleep();
|
|
3659
|
+
let triggerShutdown = () => {};
|
|
3660
|
+
const loop = runRunnerWithLoop(
|
|
3661
|
+
["--companies", "--watch", "--hq-root", "/tmp/hq"],
|
|
3662
|
+
{
|
|
3663
|
+
runPass: vi.fn().mockResolvedValue(0),
|
|
3664
|
+
sleep,
|
|
3665
|
+
sampleLoadAvg: () => 1_000, // far above any core count → clamps to ceil
|
|
3666
|
+
onShutdownSignal: (handler) => {
|
|
3667
|
+
triggerShutdown = handler;
|
|
3668
|
+
return () => {};
|
|
3669
|
+
},
|
|
3670
|
+
},
|
|
3671
|
+
);
|
|
3672
|
+
await flushLoopMicrotasks();
|
|
3673
|
+
expect(calls[0]).toBe(600_000);
|
|
3674
|
+
triggerShutdown();
|
|
3675
|
+
await loop;
|
|
3676
|
+
});
|
|
3677
|
+
|
|
3678
|
+
it("an explicit --poll-remote-ms pins a fixed interval and ignores load", async () => {
|
|
3679
|
+
const { calls, sleep } = makeCapturingSleep();
|
|
3680
|
+
const sampleLoadAvg = vi.fn(() => 1_000);
|
|
3681
|
+
let triggerShutdown = () => {};
|
|
3682
|
+
const loop = runRunnerWithLoop(
|
|
3683
|
+
["--companies", "--watch", "--poll-remote-ms", "123456", "--hq-root", "/tmp/hq"],
|
|
3684
|
+
{
|
|
3685
|
+
runPass: vi.fn().mockResolvedValue(0),
|
|
3686
|
+
sleep,
|
|
3687
|
+
sampleLoadAvg,
|
|
3688
|
+
onShutdownSignal: (handler) => {
|
|
3689
|
+
triggerShutdown = handler;
|
|
3690
|
+
return () => {};
|
|
3691
|
+
},
|
|
3692
|
+
},
|
|
3693
|
+
);
|
|
3694
|
+
await flushLoopMicrotasks();
|
|
3695
|
+
expect(calls[0]).toBe(123456);
|
|
3696
|
+
expect(sampleLoadAvg).not.toHaveBeenCalled();
|
|
3697
|
+
triggerShutdown();
|
|
3698
|
+
await loop;
|
|
3699
|
+
});
|
|
3700
|
+
});
|
|
3701
|
+
|
|
3484
3702
|
describe("runRunnerWithLoop — event-push wiring", () => {
|
|
3485
3703
|
it("starts the watcher alongside the poll loop when --event-push is on", async () => {
|
|
3486
3704
|
const watcher = makeWatcherStub();
|
|
@@ -3810,6 +4028,28 @@ describe("runRunnerWithLoop — event-push wiring", () => {
|
|
|
3810
4028
|
expect(watcher.disposed).toBe(true);
|
|
3811
4029
|
});
|
|
3812
4030
|
|
|
4031
|
+
it("an exit-0 auth-required pass stops the unattended loop without reporting a crash", async () => {
|
|
4032
|
+
const watcher = makeWatcherStub();
|
|
4033
|
+
const runPass = vi.fn().mockResolvedValue(AUTH_REQUIRED_PASS_EXIT);
|
|
4034
|
+
const sleep = vi.fn().mockResolvedValue(undefined);
|
|
4035
|
+
|
|
4036
|
+
const code = await runRunnerWithLoop(
|
|
4037
|
+
["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"],
|
|
4038
|
+
{
|
|
4039
|
+
runPass,
|
|
4040
|
+
sleep,
|
|
4041
|
+
clock: new FakeClock(),
|
|
4042
|
+
createWatcher: () => watcher,
|
|
4043
|
+
onShutdownSignal: () => () => {},
|
|
4044
|
+
},
|
|
4045
|
+
);
|
|
4046
|
+
|
|
4047
|
+
expect(code).toBe(0);
|
|
4048
|
+
expect(runPass).toHaveBeenCalledTimes(1);
|
|
4049
|
+
expect(sleep).not.toHaveBeenCalled();
|
|
4050
|
+
expect(watcher.disposed).toBe(true);
|
|
4051
|
+
});
|
|
4052
|
+
|
|
3813
4053
|
it("a TRANSIENT_NETWORK_EXIT poll pass does NOT surface as a crash — the loop retries (HQ-SYNC-1W)", async () => {
|
|
3814
4054
|
// A transient offline blip must keep the watcher alive: the loop logs and
|
|
3815
4055
|
// polls again instead of returning the code and letting the process exit
|
|
@@ -3845,7 +4085,7 @@ describe("runRunnerWithLoop — event-push wiring", () => {
|
|
|
3845
4085
|
|
|
3846
4086
|
it("a PARTIAL_SYNC_EXIT poll pass does NOT surface as a crash — the loop retries", async () => {
|
|
3847
4087
|
// A partial pass (some per-file transfers threw: oversized object on an
|
|
3848
|
-
// old runner, presign denial,
|
|
4088
|
+
// old runner, presign denial, corrupt vault key) must keep
|
|
3849
4089
|
// the watcher alive. Exiting on it got the watcher hot-respawned by the
|
|
3850
4090
|
// menubar supervisor and Sentry-captured on EVERY failing pass — the
|
|
3851
4091
|
// July 2026 "watcher exited unexpectedly (code=Some(2))" alert flood. The
|
package/src/bin/sync-runner.ts
CHANGED
|
@@ -62,7 +62,9 @@ import * as fs from "fs";
|
|
|
62
62
|
import { randomUUID } from "node:crypto";
|
|
63
63
|
import { fileURLToPath } from "url";
|
|
64
64
|
import {
|
|
65
|
+
CognitoRefreshError,
|
|
65
66
|
getValidAccessToken,
|
|
67
|
+
invalidateCachedTokensByFingerprint,
|
|
66
68
|
loadCachedTokens,
|
|
67
69
|
VaultClient,
|
|
68
70
|
VaultAuthError,
|
|
@@ -532,6 +534,16 @@ export type { PullScope };
|
|
|
532
534
|
*/
|
|
533
535
|
const MEMBERSHIPS_RETRY_BACKOFFS_MS: readonly number[] = [50, 100];
|
|
534
536
|
|
|
537
|
+
const REAUTH_MESSAGE =
|
|
538
|
+
"Your HQ session needs a quick refresh. Sign in again to keep sync moving.";
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Internal-only result used between a watch pass and its owning loop. The
|
|
542
|
+
* outer process still exits 0 (auth-required is a handled UX state), while
|
|
543
|
+
* the loop stops instead of retrying a known-unusable session forever.
|
|
544
|
+
*/
|
|
545
|
+
export const AUTH_REQUIRED_PASS_EXIT = 18;
|
|
546
|
+
|
|
535
547
|
/**
|
|
536
548
|
* Call `listMyMemberships()` with up to 3 attempts and a small linear
|
|
537
549
|
* backoff between them. The single network call that drives every cloud
|
|
@@ -589,6 +601,8 @@ export interface RunnerDeps {
|
|
|
589
601
|
stderr?: { write: (chunk: string) => boolean | void };
|
|
590
602
|
/** Resolve a valid access token. Defaults to `getValidAccessToken` non-interactive. */
|
|
591
603
|
getAccessToken?: () => Promise<string>;
|
|
604
|
+
/** Invalidate only the rejected token generation. Injectable for tests. */
|
|
605
|
+
clearSession?: (tokenFingerprint?: string) => void;
|
|
592
606
|
/**
|
|
593
607
|
* Read the caller's identity claims (sub/email/name) off the cached Cognito
|
|
594
608
|
* idToken. Defaults to decoding `loadCachedTokens().idToken`. Returns `null`
|
|
@@ -621,6 +635,8 @@ export interface RunnerDeps {
|
|
|
621
635
|
) => Promise<void>;
|
|
622
636
|
/** Internal: set when runRunner is invoked under the per-root operation lock. */
|
|
623
637
|
operationLockAlreadyHeld?: boolean;
|
|
638
|
+
/** Internal watch-loop result override; one-shot callers keep exit 0. */
|
|
639
|
+
authRequiredExitCode?: number;
|
|
624
640
|
/** Share function (push phase). Defaults to `cli/share.share`. */
|
|
625
641
|
share?: (options: ShareOptions) => Promise<ShareResult>;
|
|
626
642
|
/**
|
|
@@ -1109,14 +1125,27 @@ export async function runRunner(
|
|
|
1109
1125
|
const getAccessToken =
|
|
1110
1126
|
deps.getAccessToken ??
|
|
1111
1127
|
(() => getValidAccessToken(DEFAULT_COGNITO, { interactive: false }));
|
|
1128
|
+
const clearSession =
|
|
1129
|
+
deps.clearSession ?? invalidateCachedTokensByFingerprint;
|
|
1112
1130
|
try {
|
|
1113
1131
|
await getAccessToken();
|
|
1114
1132
|
} catch (err) {
|
|
1133
|
+
if (
|
|
1134
|
+
(err instanceof CognitoRefreshError && !err.requiresReauth) ||
|
|
1135
|
+
isTransientNetworkError(err)
|
|
1136
|
+
) {
|
|
1137
|
+
emit({
|
|
1138
|
+
type: "error",
|
|
1139
|
+
path: "(auth)",
|
|
1140
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1141
|
+
});
|
|
1142
|
+
return TRANSIENT_NETWORK_EXIT;
|
|
1143
|
+
}
|
|
1115
1144
|
emit({
|
|
1116
1145
|
type: "auth-error",
|
|
1117
|
-
message:
|
|
1146
|
+
message: REAUTH_MESSAGE,
|
|
1118
1147
|
});
|
|
1119
|
-
return 0;
|
|
1148
|
+
return deps.authRequiredExitCode ?? 0;
|
|
1120
1149
|
}
|
|
1121
1150
|
|
|
1122
1151
|
// ---- vault client -----------------------------------------------------
|
|
@@ -1257,11 +1286,12 @@ export async function runRunner(
|
|
|
1257
1286
|
memberships = resolution.memberships;
|
|
1258
1287
|
} catch (err) {
|
|
1259
1288
|
if (err instanceof VaultAuthError) {
|
|
1289
|
+
clearSession(err.tokenFingerprint);
|
|
1260
1290
|
emit({
|
|
1261
1291
|
type: "auth-error",
|
|
1262
|
-
message:
|
|
1292
|
+
message: REAUTH_MESSAGE,
|
|
1263
1293
|
});
|
|
1264
|
-
return 0;
|
|
1294
|
+
return deps.authRequiredExitCode ?? 0;
|
|
1265
1295
|
}
|
|
1266
1296
|
// Surface the failure as an error event for observability either way.
|
|
1267
1297
|
emit({
|
|
@@ -1279,15 +1309,31 @@ export async function runRunner(
|
|
|
1279
1309
|
return isTransientNetworkError(err) ? TRANSIENT_NETWORK_EXIT : 1;
|
|
1280
1310
|
}
|
|
1281
1311
|
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1312
|
+
let targetPlan: Awaited<ReturnType<typeof buildFanoutPlan>>;
|
|
1313
|
+
try {
|
|
1314
|
+
targetPlan = await buildFanoutPlan({
|
|
1315
|
+
memberships,
|
|
1316
|
+
companies: parsed.companies,
|
|
1317
|
+
personal: parsed.personal,
|
|
1318
|
+
skipPersonal: parsed.skipPersonal,
|
|
1319
|
+
client,
|
|
1320
|
+
claims,
|
|
1321
|
+
resolveSkipPersonal,
|
|
1322
|
+
});
|
|
1323
|
+
} catch (err) {
|
|
1324
|
+
if (err instanceof VaultAuthError) {
|
|
1325
|
+
clearSession(err.tokenFingerprint);
|
|
1326
|
+
emit({ type: "auth-error", message: REAUTH_MESSAGE });
|
|
1327
|
+
return deps.authRequiredExitCode ?? 0;
|
|
1328
|
+
}
|
|
1329
|
+
emit({
|
|
1330
|
+
type: "error",
|
|
1331
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1332
|
+
path: "(discovery)",
|
|
1333
|
+
});
|
|
1334
|
+
emitRunnerTerminalTelemetry({ status: "failed" });
|
|
1335
|
+
return isTransientNetworkError(err) ? TRANSIENT_NETWORK_EXIT : 1;
|
|
1336
|
+
}
|
|
1291
1337
|
if (targetPlan.status === "setup-needed") {
|
|
1292
1338
|
emit({ type: "setup-needed" });
|
|
1293
1339
|
return 0;
|
|
@@ -1306,24 +1352,34 @@ export async function runRunner(
|
|
|
1306
1352
|
// ---- fanout -----------------------------------------------------------
|
|
1307
1353
|
const syncFn = deps.sync ?? defaultSync;
|
|
1308
1354
|
const shareFn = deps.share ?? defaultShare;
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1355
|
+
let fanout: Awaited<ReturnType<typeof executeCompanyFanout>>;
|
|
1356
|
+
try {
|
|
1357
|
+
fanout = await executeCompanyFanout({
|
|
1358
|
+
plan,
|
|
1359
|
+
direction: parsed.direction,
|
|
1360
|
+
hqRoot: parsed.hqRoot,
|
|
1361
|
+
onConflict: parsed.onConflict,
|
|
1362
|
+
client,
|
|
1363
|
+
vaultConfig,
|
|
1364
|
+
...(uploadAuthor ? { uploadAuthor } : {}),
|
|
1365
|
+
...(deps.operationLockAlreadyHeld ? { operationLockAlreadyHeld: true } : {}),
|
|
1366
|
+
syncFn,
|
|
1367
|
+
shareFn,
|
|
1368
|
+
resolveDeletePolicy,
|
|
1369
|
+
emit,
|
|
1370
|
+
scopePaths: parsed.scopePaths,
|
|
1371
|
+
telemetryClient: client,
|
|
1372
|
+
telemetryClaims: claims,
|
|
1373
|
+
telemetrySessionId,
|
|
1374
|
+
});
|
|
1375
|
+
} catch (err) {
|
|
1376
|
+
if (err instanceof VaultAuthError) {
|
|
1377
|
+
clearSession(err.tokenFingerprint);
|
|
1378
|
+
emit({ type: "auth-error", message: REAUTH_MESSAGE });
|
|
1379
|
+
return deps.authRequiredExitCode ?? 0;
|
|
1380
|
+
}
|
|
1381
|
+
throw err;
|
|
1382
|
+
}
|
|
1327
1383
|
const { errors, allConflicts, meaningfulOutcomeCount } = fanout;
|
|
1328
1384
|
const rollup = rollupAllComplete(plan, fanout.stateByCompany);
|
|
1329
1385
|
|
|
@@ -1685,6 +1741,13 @@ const isDirectInvocation = (() => {
|
|
|
1685
1741
|
export interface RunnerLoopDeps {
|
|
1686
1742
|
/** Sleep `ms` between passes. Default: host setTimeout. */
|
|
1687
1743
|
sleep?: (ms: number) => Promise<void>;
|
|
1744
|
+
/**
|
|
1745
|
+
* 1-minute whole-box load average source for the adaptive remote-poll
|
|
1746
|
+
* backoff (used only when `--poll-remote-ms` is omitted). Defaults to
|
|
1747
|
+
* `os.loadavg()[0]`. Tests inject a fixed sample to assert the interval
|
|
1748
|
+
* scales with load without depending on the host's real load.
|
|
1749
|
+
*/
|
|
1750
|
+
sampleLoadAvg?: () => number;
|
|
1688
1751
|
/**
|
|
1689
1752
|
* Run a single sync pass. Defaults to {@link runRunner}. Injected by tests
|
|
1690
1753
|
* (and the event-push wiring) so the poll loop and the watcher-triggered
|
|
@@ -1787,7 +1850,11 @@ export async function runRunnerWithLoop(
|
|
|
1787
1850
|
const parsed = parseArgs(argv);
|
|
1788
1851
|
const runtime = {
|
|
1789
1852
|
runPassWithOperationLockAlreadyHeld: (passArgv: string[]) =>
|
|
1790
|
-
runRunner(passArgv, {
|
|
1853
|
+
runRunner(passArgv, {
|
|
1854
|
+
operationLockAlreadyHeld: true,
|
|
1855
|
+
authRequiredExitCode: AUTH_REQUIRED_PASS_EXIT,
|
|
1856
|
+
}),
|
|
1857
|
+
authRequiredPassExit: AUTH_REQUIRED_PASS_EXIT,
|
|
1791
1858
|
defaultGetIdTokenClaims,
|
|
1792
1859
|
defaultGetAccessToken: () =>
|
|
1793
1860
|
getValidAccessToken(DEFAULT_COGNITO, { interactive: false }),
|