@indigoai-us/hq-cloud 6.3.0 → 6.3.1
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.d.ts +22 -2
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +85 -2
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +201 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/rescue-core.js +14 -2
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-hq-root-guard.test.d.ts +2 -0
- package/dist/cli/rescue-hq-root-guard.test.d.ts.map +1 -0
- package/dist/cli/rescue-hq-root-guard.test.js +176 -0
- package/dist/cli/rescue-hq-root-guard.test.js.map +1 -0
- package/dist/sync/event-sync.d.ts +181 -0
- package/dist/sync/event-sync.d.ts.map +1 -0
- package/dist/sync/event-sync.js +316 -0
- package/dist/sync/event-sync.js.map +1 -0
- package/dist/sync/event-sync.test.d.ts +14 -0
- package/dist/sync/event-sync.test.d.ts.map +1 -0
- package/dist/sync/event-sync.test.js +440 -0
- package/dist/sync/event-sync.test.js.map +1 -0
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +246 -0
- package/src/bin/sync-runner.ts +117 -3
- package/src/cli/rescue-core.ts +15 -2
- package/src/cli/rescue-hq-root-guard.test.ts +193 -0
- package/src/sync/event-sync.test.ts +533 -0
- package/src/sync/event-sync.ts +481 -0
- package/test/e2e/sync/cross-tenant-isolation.test.ts +126 -0
|
@@ -3643,3 +3643,249 @@ describe("runRunnerWithLoop — operation lock", () => {
|
|
|
3643
3643
|
expect(held.command).toBe("sync");
|
|
3644
3644
|
});
|
|
3645
3645
|
});
|
|
3646
|
+
|
|
3647
|
+
// ---------------------------------------------------------------------------
|
|
3648
|
+
// Phase 3 — event-driven publish + pull wiring (US-017/018/019)
|
|
3649
|
+
// ---------------------------------------------------------------------------
|
|
3650
|
+
//
|
|
3651
|
+
// Exercises the rollout-gated bring-up through the `startEventSync` /
|
|
3652
|
+
// `getIdTokenClaims` seams: gate-off is byte-identical to today (the seam is
|
|
3653
|
+
// never consulted), gate-on passes the wiring the right identity + sync
|
|
3654
|
+
// bridge, and the publish leg fires only AFTER a SUCCESSFUL targeted push
|
|
3655
|
+
// pass (events must never announce bytes that are not in S3 yet).
|
|
3656
|
+
|
|
3657
|
+
interface BatchWatcherStub extends WatcherSurface {
|
|
3658
|
+
emit(changedRelPath?: string, batch?: { paths: Map<string, string> }): void;
|
|
3659
|
+
disposed: boolean;
|
|
3660
|
+
}
|
|
3661
|
+
|
|
3662
|
+
function makeBatchWatcherStub(): BatchWatcherStub {
|
|
3663
|
+
const listeners = new Set<
|
|
3664
|
+
(p?: string, b?: { paths: Map<string, string> }) => void
|
|
3665
|
+
>();
|
|
3666
|
+
return {
|
|
3667
|
+
disposed: false,
|
|
3668
|
+
onChange(listener) {
|
|
3669
|
+
listeners.add(listener);
|
|
3670
|
+
return () => listeners.delete(listener);
|
|
3671
|
+
},
|
|
3672
|
+
start() {},
|
|
3673
|
+
stop() {},
|
|
3674
|
+
dispose() {
|
|
3675
|
+
this.disposed = true;
|
|
3676
|
+
listeners.clear();
|
|
3677
|
+
},
|
|
3678
|
+
emit(changedRelPath?: string, batch?: { paths: Map<string, string> }) {
|
|
3679
|
+
for (const l of [...listeners]) l(changedRelPath, batch);
|
|
3680
|
+
},
|
|
3681
|
+
};
|
|
3682
|
+
}
|
|
3683
|
+
|
|
3684
|
+
describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", () => {
|
|
3685
|
+
const ENROLLED_CLAIMS = { email: "hassaan@getindigo.ai" };
|
|
3686
|
+
const UNENROLLED_CLAIMS = { email: "someone@getindigo.ai" };
|
|
3687
|
+
|
|
3688
|
+
function runLoop(opts: {
|
|
3689
|
+
claims: { email?: string } | null;
|
|
3690
|
+
startEventSync?: ReturnType<typeof vi.fn>;
|
|
3691
|
+
watcher?: BatchWatcherStub;
|
|
3692
|
+
runPass?: ReturnType<typeof vi.fn>;
|
|
3693
|
+
}) {
|
|
3694
|
+
const watcher = opts.watcher ?? makeBatchWatcherStub();
|
|
3695
|
+
const runPass = opts.runPass ?? vi.fn().mockResolvedValue(0);
|
|
3696
|
+
const clock = new FakeClock();
|
|
3697
|
+
let triggerShutdown = () => {};
|
|
3698
|
+
const loop = runRunnerWithLoop(
|
|
3699
|
+
["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"],
|
|
3700
|
+
{
|
|
3701
|
+
runPass,
|
|
3702
|
+
clock,
|
|
3703
|
+
createWatcher: () => watcher,
|
|
3704
|
+
sleep: () => new Promise<void>(() => {}),
|
|
3705
|
+
onShutdownSignal: (handler) => {
|
|
3706
|
+
triggerShutdown = handler;
|
|
3707
|
+
return () => {};
|
|
3708
|
+
},
|
|
3709
|
+
getIdTokenClaims: () => opts.claims as never,
|
|
3710
|
+
getAccessToken: async () => "jwt-test",
|
|
3711
|
+
startEventSync: (opts.startEventSync ??
|
|
3712
|
+
vi.fn().mockResolvedValue(null)) as never,
|
|
3713
|
+
},
|
|
3714
|
+
);
|
|
3715
|
+
return { loop, watcher, runPass, clock, shutdown: () => triggerShutdown() };
|
|
3716
|
+
}
|
|
3717
|
+
|
|
3718
|
+
async function microtasks(n = 6) {
|
|
3719
|
+
for (let i = 0; i < n; i++) await Promise.resolve();
|
|
3720
|
+
}
|
|
3721
|
+
|
|
3722
|
+
it("gate OFF (unenrolled email): the event-sync seam is never consulted", async () => {
|
|
3723
|
+
const startEventSync = vi.fn().mockResolvedValue(null);
|
|
3724
|
+
const { loop, shutdown } = runLoop({
|
|
3725
|
+
claims: UNENROLLED_CLAIMS,
|
|
3726
|
+
startEventSync,
|
|
3727
|
+
});
|
|
3728
|
+
await microtasks();
|
|
3729
|
+
shutdown();
|
|
3730
|
+
await loop;
|
|
3731
|
+
expect(startEventSync).not.toHaveBeenCalled();
|
|
3732
|
+
});
|
|
3733
|
+
|
|
3734
|
+
it("gate OFF (no cached claims): the event-sync seam is never consulted", async () => {
|
|
3735
|
+
const startEventSync = vi.fn().mockResolvedValue(null);
|
|
3736
|
+
const { loop, shutdown } = runLoop({ claims: null, startEventSync });
|
|
3737
|
+
await microtasks();
|
|
3738
|
+
shutdown();
|
|
3739
|
+
await loop;
|
|
3740
|
+
expect(startEventSync).not.toHaveBeenCalled();
|
|
3741
|
+
});
|
|
3742
|
+
|
|
3743
|
+
it("gate ON (enrolled email): wiring receives root, api url, auth + a sync bridge", async () => {
|
|
3744
|
+
const startEventSync = vi.fn().mockResolvedValue(null);
|
|
3745
|
+
const { loop, shutdown } = runLoop({
|
|
3746
|
+
claims: ENROLLED_CLAIMS,
|
|
3747
|
+
startEventSync,
|
|
3748
|
+
});
|
|
3749
|
+
await microtasks();
|
|
3750
|
+
shutdown();
|
|
3751
|
+
await loop;
|
|
3752
|
+
expect(startEventSync).toHaveBeenCalledTimes(1);
|
|
3753
|
+
const arg = startEventSync.mock.calls[0][0];
|
|
3754
|
+
expect(arg.hqRoot).toBe("/tmp/hq");
|
|
3755
|
+
expect(arg.apiUrl).toContain("https://");
|
|
3756
|
+
expect(typeof arg.deviceId).toBe("string");
|
|
3757
|
+
expect(arg.deviceId.length).toBeGreaterThan(0);
|
|
3758
|
+
expect(typeof arg.resolveTenantId).toBe("function");
|
|
3759
|
+
expect(typeof arg.syncFn).toBe("function");
|
|
3760
|
+
});
|
|
3761
|
+
|
|
3762
|
+
it("publishes the batch ONLY after a successful targeted push pass", async () => {
|
|
3763
|
+
const publishBatch = vi.fn();
|
|
3764
|
+
const startEventSync = vi.fn().mockResolvedValue({
|
|
3765
|
+
publishBatch,
|
|
3766
|
+
receiver: { start: async () => {}, dispose: async () => {}, connected: true },
|
|
3767
|
+
ownDeviceId: "dev-test",
|
|
3768
|
+
dispose: vi.fn().mockResolvedValue(undefined),
|
|
3769
|
+
});
|
|
3770
|
+
const runPass = vi.fn().mockResolvedValue(0);
|
|
3771
|
+
const { loop, watcher, clock, shutdown } = runLoop({
|
|
3772
|
+
claims: ENROLLED_CLAIMS,
|
|
3773
|
+
startEventSync,
|
|
3774
|
+
runPass,
|
|
3775
|
+
});
|
|
3776
|
+
await microtasks(); // initial poll pass + async event-sync bring-up
|
|
3777
|
+
runPass.mockClear();
|
|
3778
|
+
|
|
3779
|
+
const batch = {
|
|
3780
|
+
paths: new Map([["/tmp/hq/companies/indigo/a.md", "companies/indigo/a.md"]]),
|
|
3781
|
+
};
|
|
3782
|
+
watcher.emit("companies/indigo/a.md", batch);
|
|
3783
|
+
clock.advance(0);
|
|
3784
|
+
await microtasks();
|
|
3785
|
+
|
|
3786
|
+
// Targeted push ran and succeeded → batch published.
|
|
3787
|
+
expect(runPass).toHaveBeenCalled();
|
|
3788
|
+
expect(publishBatch).toHaveBeenCalledTimes(1);
|
|
3789
|
+
expect(publishBatch.mock.calls[0][0]).toBe(batch);
|
|
3790
|
+
|
|
3791
|
+
shutdown();
|
|
3792
|
+
await loop;
|
|
3793
|
+
});
|
|
3794
|
+
|
|
3795
|
+
it("publishes NOTHING when the targeted push pass fails", async () => {
|
|
3796
|
+
const publishBatch = vi.fn();
|
|
3797
|
+
const startEventSync = vi.fn().mockResolvedValue({
|
|
3798
|
+
publishBatch,
|
|
3799
|
+
receiver: { start: async () => {}, dispose: async () => {}, connected: true },
|
|
3800
|
+
ownDeviceId: "dev-test",
|
|
3801
|
+
dispose: vi.fn().mockResolvedValue(undefined),
|
|
3802
|
+
});
|
|
3803
|
+
// Initial poll pass succeeds; the targeted pass fails.
|
|
3804
|
+
const runPass = vi
|
|
3805
|
+
.fn()
|
|
3806
|
+
.mockResolvedValueOnce(0)
|
|
3807
|
+
.mockResolvedValue(1);
|
|
3808
|
+
const { loop, watcher, clock, shutdown } = runLoop({
|
|
3809
|
+
claims: ENROLLED_CLAIMS,
|
|
3810
|
+
startEventSync,
|
|
3811
|
+
runPass,
|
|
3812
|
+
});
|
|
3813
|
+
await microtasks();
|
|
3814
|
+
|
|
3815
|
+
watcher.emit(
|
|
3816
|
+
"companies/indigo/a.md",
|
|
3817
|
+
{ paths: new Map([["/tmp/hq/companies/indigo/a.md", "companies/indigo/a.md"]]) },
|
|
3818
|
+
);
|
|
3819
|
+
clock.advance(0);
|
|
3820
|
+
await microtasks();
|
|
3821
|
+
|
|
3822
|
+
expect(publishBatch).not.toHaveBeenCalled();
|
|
3823
|
+
shutdown();
|
|
3824
|
+
await loop;
|
|
3825
|
+
});
|
|
3826
|
+
|
|
3827
|
+
it("falls back to a synthesized single-path batch when the watcher emits a bare path", async () => {
|
|
3828
|
+
const publishBatch = vi.fn();
|
|
3829
|
+
const startEventSync = vi.fn().mockResolvedValue({
|
|
3830
|
+
publishBatch,
|
|
3831
|
+
receiver: { start: async () => {}, dispose: async () => {}, connected: true },
|
|
3832
|
+
ownDeviceId: "dev-test",
|
|
3833
|
+
dispose: vi.fn().mockResolvedValue(undefined),
|
|
3834
|
+
});
|
|
3835
|
+
const { loop, watcher, clock, shutdown } = runLoop({
|
|
3836
|
+
claims: ENROLLED_CLAIMS,
|
|
3837
|
+
startEventSync,
|
|
3838
|
+
});
|
|
3839
|
+
await microtasks();
|
|
3840
|
+
|
|
3841
|
+
watcher.emit("companies/indigo/b.md"); // no batch
|
|
3842
|
+
clock.advance(0);
|
|
3843
|
+
await microtasks();
|
|
3844
|
+
|
|
3845
|
+
expect(publishBatch).toHaveBeenCalledTimes(1);
|
|
3846
|
+
const synthesized = publishBatch.mock.calls[0][0] as {
|
|
3847
|
+
paths: Map<string, string>;
|
|
3848
|
+
};
|
|
3849
|
+
expect([...synthesized.paths.values()]).toEqual(["companies/indigo/b.md"]);
|
|
3850
|
+
|
|
3851
|
+
shutdown();
|
|
3852
|
+
await loop;
|
|
3853
|
+
});
|
|
3854
|
+
|
|
3855
|
+
it("disposes the event-sync handles on shutdown", async () => {
|
|
3856
|
+
const dispose = vi.fn().mockResolvedValue(undefined);
|
|
3857
|
+
const startEventSync = vi.fn().mockResolvedValue({
|
|
3858
|
+
publishBatch: vi.fn(),
|
|
3859
|
+
receiver: { start: async () => {}, dispose: async () => {}, connected: true },
|
|
3860
|
+
ownDeviceId: "dev-test",
|
|
3861
|
+
dispose,
|
|
3862
|
+
});
|
|
3863
|
+
const { loop, shutdown } = runLoop({
|
|
3864
|
+
claims: ENROLLED_CLAIMS,
|
|
3865
|
+
startEventSync,
|
|
3866
|
+
});
|
|
3867
|
+
await microtasks();
|
|
3868
|
+
shutdown();
|
|
3869
|
+
await loop;
|
|
3870
|
+
expect(dispose).toHaveBeenCalled();
|
|
3871
|
+
});
|
|
3872
|
+
|
|
3873
|
+
it("env override HQ_SYNC_EVENT_SYNC=0 keeps the seam dormant for the enrolled account", async () => {
|
|
3874
|
+
const prev = process.env.HQ_SYNC_EVENT_SYNC;
|
|
3875
|
+
process.env.HQ_SYNC_EVENT_SYNC = "0";
|
|
3876
|
+
try {
|
|
3877
|
+
const startEventSync = vi.fn().mockResolvedValue(null);
|
|
3878
|
+
const { loop, shutdown } = runLoop({
|
|
3879
|
+
claims: ENROLLED_CLAIMS,
|
|
3880
|
+
startEventSync,
|
|
3881
|
+
});
|
|
3882
|
+
await microtasks();
|
|
3883
|
+
shutdown();
|
|
3884
|
+
await loop;
|
|
3885
|
+
expect(startEventSync).not.toHaveBeenCalled();
|
|
3886
|
+
} finally {
|
|
3887
|
+
if (prev === undefined) delete process.env.HQ_SYNC_EVENT_SYNC;
|
|
3888
|
+
else process.env.HQ_SYNC_EVENT_SYNC = prev;
|
|
3889
|
+
}
|
|
3890
|
+
});
|
|
3891
|
+
});
|
package/src/bin/sync-runner.ts
CHANGED
|
@@ -112,12 +112,19 @@ import {
|
|
|
112
112
|
WatchPushDriver,
|
|
113
113
|
systemClock,
|
|
114
114
|
type Clock,
|
|
115
|
+
type TreeChangeBatch,
|
|
115
116
|
} from "../watcher.js";
|
|
116
117
|
import {
|
|
117
118
|
NoopPushReceiver,
|
|
118
119
|
type PushReceiver,
|
|
119
120
|
type SyncEngineFn,
|
|
120
121
|
} from "../sync/push-receiver.js";
|
|
122
|
+
import {
|
|
123
|
+
resolveEventSync,
|
|
124
|
+
startEventSync as defaultStartEventSync,
|
|
125
|
+
type EventSyncHandles,
|
|
126
|
+
type StartEventSyncOptions,
|
|
127
|
+
} from "../sync/event-sync.js";
|
|
121
128
|
import {
|
|
122
129
|
PERSONAL_VAULT_JOURNAL_SLUG,
|
|
123
130
|
migratePersonalVaultJournal,
|
|
@@ -1829,6 +1836,27 @@ export interface RunnerLoopDeps {
|
|
|
1829
1836
|
syncFn: SyncEngineFn;
|
|
1830
1837
|
hqRoot: string;
|
|
1831
1838
|
}) => PushReceiver;
|
|
1839
|
+
/**
|
|
1840
|
+
* Phase 3 (US-017/US-018/US-019): factory for the event-driven publish +
|
|
1841
|
+
* pull wiring, consulted only when `--event-push` is on AND the rollout
|
|
1842
|
+
* gate ({@link resolveEventSync}) passes for the signed-in account.
|
|
1843
|
+
* Defaults to the real {@link defaultStartEventSync}. Tests inject a stub
|
|
1844
|
+
* to assert gate behavior without network/AWS.
|
|
1845
|
+
*/
|
|
1846
|
+
startEventSync?: (
|
|
1847
|
+
opts: StartEventSyncOptions,
|
|
1848
|
+
) => Promise<EventSyncHandles | null>;
|
|
1849
|
+
/**
|
|
1850
|
+
* Identity-claims source for the Phase 3 rollout gate (the loop has no
|
|
1851
|
+
* RunnerDeps; mirror of RunnerDeps.getIdTokenClaims). Defaults to reading
|
|
1852
|
+
* the cached Cognito idToken.
|
|
1853
|
+
*/
|
|
1854
|
+
getIdTokenClaims?: () => IdTokenClaims | null;
|
|
1855
|
+
/**
|
|
1856
|
+
* Access-token source for the Phase 3 vault API calls (publish transport +
|
|
1857
|
+
* subscribe). Defaults to {@link getValidAccessToken} non-interactive.
|
|
1858
|
+
*/
|
|
1859
|
+
getAccessToken?: () => Promise<string>;
|
|
1832
1860
|
}
|
|
1833
1861
|
|
|
1834
1862
|
/**
|
|
@@ -1843,7 +1871,9 @@ export interface RunnerLoopDeps {
|
|
|
1843
1871
|
* relative path so the loop targets just that company.
|
|
1844
1872
|
*/
|
|
1845
1873
|
export interface WatcherSurface {
|
|
1846
|
-
onChange(
|
|
1874
|
+
onChange(
|
|
1875
|
+
listener: (changedRelPath?: string, batch?: TreeChangeBatch) => void,
|
|
1876
|
+
): () => void;
|
|
1847
1877
|
start(): void;
|
|
1848
1878
|
stop(): void;
|
|
1849
1879
|
dispose(): void;
|
|
@@ -1999,12 +2029,17 @@ export async function runRunnerWithLoop(
|
|
|
1999
2029
|
let driver: WatchPushDriver | null = null;
|
|
2000
2030
|
let detachSignal: (() => void) | null = null;
|
|
2001
2031
|
let lastChangedRel: string | null = null;
|
|
2032
|
+
let lastBatch: TreeChangeBatch | null = null;
|
|
2002
2033
|
// ---- pull-on-event receiver (Phase 2, US-009) ------------------------
|
|
2003
2034
|
// Started after the watcher, disposed before the watcher (mirror of the
|
|
2004
2035
|
// PushTransport ordering). Dormant by default: the default factory returns
|
|
2005
2036
|
// a NoopPushReceiver, and even a real receiver stays dormant unless the
|
|
2006
2037
|
// per-tenant feature flag is on AND a queue URL is provisioned server-side.
|
|
2007
2038
|
let receiver: PushReceiver | null = null;
|
|
2039
|
+
// ---- event-driven publish + pull (Phase 3, US-017/018/019) ------------
|
|
2040
|
+
// Brought up asynchronously after the watcher when the rollout gate
|
|
2041
|
+
// passes; null until ready (and stays null on startup failure → poll-only).
|
|
2042
|
+
let eventSync: EventSyncHandles | null = null;
|
|
2008
2043
|
|
|
2009
2044
|
if (eventPush) {
|
|
2010
2045
|
const clock = deps.clock ?? systemClock;
|
|
@@ -2032,12 +2067,28 @@ export async function runRunnerWithLoop(
|
|
|
2032
2067
|
push: async () => {
|
|
2033
2068
|
if (stopped) return;
|
|
2034
2069
|
const rel = lastChangedRel;
|
|
2070
|
+
// Snapshot the settled batch BEFORE the await: a change landing
|
|
2071
|
+
// mid-pass overwrites lastBatch for the NEXT pass, and this pass
|
|
2072
|
+
// must only announce what it actually pushed.
|
|
2073
|
+
const batchForPublish = lastBatch;
|
|
2074
|
+
lastBatch = null;
|
|
2035
2075
|
const route = rel
|
|
2036
2076
|
? routeChangeToTarget(rel)
|
|
2037
2077
|
: { kind: "personal" as const };
|
|
2038
2078
|
if (!route) return;
|
|
2039
2079
|
const targetedArgv = buildTargetedPushArgv(route, passArgv);
|
|
2040
|
-
await runGuarded(() => runPass(targetedArgv));
|
|
2080
|
+
const result = await runGuarded(() => runPass(targetedArgv));
|
|
2081
|
+
// Phase 3 (US-017): publish PushEvents only AFTER the targeted push
|
|
2082
|
+
// pass succeeded — an event must never announce bytes that are not
|
|
2083
|
+
// in S3 yet. A skipped pass (guard held) or a failed pass publishes
|
|
2084
|
+
// nothing; the cadence poll covers the miss. Fall back to a
|
|
2085
|
+
// single-path batch when the watcher emitted a bare path signal.
|
|
2086
|
+
if (result === 0 && eventSync) {
|
|
2087
|
+
const batch: TreeChangeBatch | null =
|
|
2088
|
+
batchForPublish ??
|
|
2089
|
+
(rel ? { paths: new Map([[path.join(hqRoot, rel), rel]]) } : null);
|
|
2090
|
+
if (batch) eventSync.publishBatch(batch);
|
|
2091
|
+
}
|
|
2041
2092
|
},
|
|
2042
2093
|
});
|
|
2043
2094
|
|
|
@@ -2046,9 +2097,10 @@ export async function runRunnerWithLoop(
|
|
|
2046
2097
|
// still serialized behind any in-flight pass. A path-aware watcher passes
|
|
2047
2098
|
// the changed relative path so the push targets just its owning company;
|
|
2048
2099
|
// the bare-signal TreeWatcher leaves it null → personal-vault route.
|
|
2049
|
-
watcher.onChange((changedRelPath) => {
|
|
2100
|
+
watcher.onChange((changedRelPath, batch) => {
|
|
2050
2101
|
if (stopped) return;
|
|
2051
2102
|
lastChangedRel = changedRelPath ?? null;
|
|
2103
|
+
lastBatch = batch ?? null;
|
|
2052
2104
|
driver?.notifyChange();
|
|
2053
2105
|
});
|
|
2054
2106
|
watcher.start();
|
|
@@ -2077,6 +2129,60 @@ export async function runRunnerWithLoop(
|
|
|
2077
2129
|
// start also keeps the poll loop's microtask timing identical to the
|
|
2078
2130
|
// pre-US-009 wiring.)
|
|
2079
2131
|
void Promise.resolve(receiver.start()).catch(() => undefined);
|
|
2132
|
+
|
|
2133
|
+
// ---- Phase 3: event-driven publish + pull (US-017/018/019) ----------
|
|
2134
|
+
// Gated to enrolled accounts (resolveEventSync — exact-email allowlist +
|
|
2135
|
+
// HQ_SYNC_EVENT_SYNC override). Brought up asynchronously so a slow
|
|
2136
|
+
// subscribe/vend can't delay the first poll pass; until (and unless) the
|
|
2137
|
+
// handles resolve, behavior is byte-identical to the gate-off path.
|
|
2138
|
+
const getClaims = deps.getIdTokenClaims ?? defaultGetIdTokenClaims;
|
|
2139
|
+
const email = getClaims()?.email;
|
|
2140
|
+
if (resolveEventSync(email, process.env.HQ_SYNC_EVENT_SYNC)) {
|
|
2141
|
+
const getAccessToken =
|
|
2142
|
+
deps.getAccessToken ??
|
|
2143
|
+
(() => getValidAccessToken(DEFAULT_COGNITO, { interactive: false }));
|
|
2144
|
+
const startES = deps.startEventSync ?? defaultStartEventSync;
|
|
2145
|
+
// Entirely async + caught: NOTHING in the Phase 3 bring-up (device-id
|
|
2146
|
+
// persistence, tenant resolution, subscribe) may crash or delay the
|
|
2147
|
+
// daemon — any failure degrades to poll-only.
|
|
2148
|
+
void (async () => {
|
|
2149
|
+
const handles = await startES({
|
|
2150
|
+
hqRoot,
|
|
2151
|
+
apiUrl: DEFAULT_VAULT_API_URL,
|
|
2152
|
+
authToken: getAccessToken,
|
|
2153
|
+
deviceId: getOrCreateMachineId(hqRoot),
|
|
2154
|
+
// The server rejects publishes whose originTenantId mismatches the
|
|
2155
|
+
// JWT principal, so resolve the SAME canonical person uid the vault
|
|
2156
|
+
// API derives from this token.
|
|
2157
|
+
resolveTenantId: async () => {
|
|
2158
|
+
const client = new VaultClient({
|
|
2159
|
+
apiUrl: DEFAULT_VAULT_API_URL,
|
|
2160
|
+
authToken: getAccessToken,
|
|
2161
|
+
region: DEFAULT_COGNITO.region,
|
|
2162
|
+
});
|
|
2163
|
+
const persons = await client.entity.listByType("person");
|
|
2164
|
+
const pick = pickCanonicalPersonEntity(persons);
|
|
2165
|
+
if (!pick?.uid) {
|
|
2166
|
+
throw new Error("no canonical person entity for this account");
|
|
2167
|
+
}
|
|
2168
|
+
return pick.uid;
|
|
2169
|
+
},
|
|
2170
|
+
syncFn: receiverSyncFn,
|
|
2171
|
+
log: (m) => process.stderr.write(`${m}\n`),
|
|
2172
|
+
});
|
|
2173
|
+
if (!handles) return;
|
|
2174
|
+
if (stopped) {
|
|
2175
|
+
// Shutdown raced the async bring-up — tear straight down.
|
|
2176
|
+
void handles.dispose();
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
eventSync = handles;
|
|
2180
|
+
})().catch((err) => {
|
|
2181
|
+
process.stderr.write(
|
|
2182
|
+
`event-sync: wiring failed, continuing poll-only: ${describeError(err)}\n`,
|
|
2183
|
+
);
|
|
2184
|
+
});
|
|
2185
|
+
}
|
|
2080
2186
|
}
|
|
2081
2187
|
|
|
2082
2188
|
// ---- clean shutdown --------------------------------------------------
|
|
@@ -2101,6 +2207,14 @@ export async function runRunnerWithLoop(
|
|
|
2101
2207
|
} catch {
|
|
2102
2208
|
/* ignore */
|
|
2103
2209
|
}
|
|
2210
|
+
// Phase 3 wiring (publish transport + live receiver) — torn down with
|
|
2211
|
+
// the same fire-and-forget posture as the Phase 2 receiver above.
|
|
2212
|
+
try {
|
|
2213
|
+
void eventSync?.dispose();
|
|
2214
|
+
} catch {
|
|
2215
|
+
/* ignore */
|
|
2216
|
+
}
|
|
2217
|
+
eventSync = null;
|
|
2104
2218
|
try {
|
|
2105
2219
|
driver?.dispose();
|
|
2106
2220
|
} catch {
|
package/src/cli/rescue-core.ts
CHANGED
|
@@ -384,9 +384,22 @@ function doRescue(
|
|
|
384
384
|
hqRoot = fs.realpathSync(process.cwd());
|
|
385
385
|
}
|
|
386
386
|
|
|
387
|
-
|
|
387
|
+
// HQ-root sanity gate — guards against wiping a non-HQ directory. `companies/`
|
|
388
|
+
// is the stable anchor (present and preserved on every HQ install), confirmed
|
|
389
|
+
// by at least one core scaffold marker. We accept ANY of `.claude/`, `core/`,
|
|
390
|
+
// or `personal/` because the layout has drifted across releases: v14.0.0 ships
|
|
391
|
+
// NEITHER `personal/` (added v15) NOR `core/` (the v15 scaffold home) — it only
|
|
392
|
+
// has `.claude/`. Requiring `personal/` here left v14.0.0 users with no upgrade
|
|
393
|
+
// path (rescue aborted; DEV-1741). `.claude/` exists on every version v14→v15,
|
|
394
|
+
// so it admits the full upgrade range while still rejecting a bare directory.
|
|
395
|
+
const hasHqMarker =
|
|
396
|
+
isDir(path.join(hqRoot, ".claude")) ||
|
|
397
|
+
isDir(path.join(hqRoot, "core")) ||
|
|
398
|
+
isDir(path.join(hqRoot, "personal"));
|
|
399
|
+
if (!isDir(path.join(hqRoot, "companies")) || !hasHqMarker) {
|
|
388
400
|
err(
|
|
389
|
-
`error: ${hqRoot} does not look like an HQ root
|
|
401
|
+
`error: ${hqRoot} does not look like an HQ root ` +
|
|
402
|
+
`(need companies/ plus one of .claude/, core/, or personal/). Aborting.\n`,
|
|
390
403
|
);
|
|
391
404
|
err(" pass --hq-root <dir> if the script is not at personal/skills/<skill>/.\n");
|
|
392
405
|
throw new ExitError(3);
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression test for the rescue HQ-root sanity gate in src/cli/rescue-core.ts.
|
|
3
|
+
*
|
|
4
|
+
* Bug (DEV-1741): the gate required BOTH `companies/` AND `personal/`. The
|
|
5
|
+
* `personal/` directory was only introduced in v15 — a faithful v14.0.0 install
|
|
6
|
+
* ships NEITHER `personal/` NOR `core/` (its scaffold lived in top-level dirs
|
|
7
|
+
* like `scripts/`, `workers/`, `knowledge/`, plus `.claude/`). So every v14.0.0
|
|
8
|
+
* user hit `error: ... missing companies/ or personal/. Aborting.` (exit 3) and
|
|
9
|
+
* had no supported upgrade path to v15.
|
|
10
|
+
*
|
|
11
|
+
* The gate now anchors on `companies/` plus ANY ONE of `.claude/`, `core/`, or
|
|
12
|
+
* `personal/`. `.claude/` exists on every release from v14 through v15, so the
|
|
13
|
+
* relaxed gate admits the full upgrade range while still rejecting a directory
|
|
14
|
+
* that is not an HQ root at all.
|
|
15
|
+
*
|
|
16
|
+
* Like the sibling drift test, the rescue clones its source from GitHub, so we
|
|
17
|
+
* shim `git clone` to a local fixture repo. All runs are `--dry-run` (no
|
|
18
|
+
* destructive ops, no backup) and we assert on the gate's behavior (exit code +
|
|
19
|
+
* message), not the full overlay.
|
|
20
|
+
*/
|
|
21
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
22
|
+
import { execFileSync } from "child_process";
|
|
23
|
+
import * as fs from "fs";
|
|
24
|
+
import * as os from "os";
|
|
25
|
+
import * as path from "path";
|
|
26
|
+
import { runRescue } from "./rescue-core.js";
|
|
27
|
+
|
|
28
|
+
function hasGit(): boolean {
|
|
29
|
+
try {
|
|
30
|
+
execFileSync("git", ["--version"], { stdio: "ignore" });
|
|
31
|
+
return true;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const gitAvailable = hasGit();
|
|
38
|
+
|
|
39
|
+
/** Run the rescue in-process, capturing its stdout/stderr. */
|
|
40
|
+
function runRescueCapture(argv: string[], env: NodeJS.ProcessEnv) {
|
|
41
|
+
let stdout = "";
|
|
42
|
+
let stderr = "";
|
|
43
|
+
const origOut = process.stdout.write.bind(process.stdout);
|
|
44
|
+
const origErr = process.stderr.write.bind(process.stderr);
|
|
45
|
+
process.stdout.write = ((chunk: unknown) => {
|
|
46
|
+
stdout += String(chunk);
|
|
47
|
+
return true;
|
|
48
|
+
}) as typeof process.stdout.write;
|
|
49
|
+
process.stderr.write = ((chunk: unknown) => {
|
|
50
|
+
stderr += String(chunk);
|
|
51
|
+
return true;
|
|
52
|
+
}) as typeof process.stderr.write;
|
|
53
|
+
let status: number;
|
|
54
|
+
try {
|
|
55
|
+
status = runRescue(argv, { env }).status;
|
|
56
|
+
} finally {
|
|
57
|
+
process.stdout.write = origOut;
|
|
58
|
+
process.stderr.write = origErr;
|
|
59
|
+
}
|
|
60
|
+
return { status, stdout, stderr };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe.skipIf(!gitAvailable)("rescue HQ-root sanity gate", () => {
|
|
64
|
+
let workDir: string;
|
|
65
|
+
let upstream: string;
|
|
66
|
+
let shimDir: string;
|
|
67
|
+
let floorSha: string;
|
|
68
|
+
let env: NodeJS.ProcessEnv;
|
|
69
|
+
|
|
70
|
+
const git = (cwd: string, ...args: string[]) =>
|
|
71
|
+
execFileSync("git", args, {
|
|
72
|
+
cwd,
|
|
73
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
74
|
+
env: {
|
|
75
|
+
...process.env,
|
|
76
|
+
GIT_AUTHOR_NAME: "t",
|
|
77
|
+
GIT_AUTHOR_EMAIL: "t@t",
|
|
78
|
+
GIT_COMMITTER_NAME: "t",
|
|
79
|
+
GIT_COMMITTER_EMAIL: "t@t",
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
.toString()
|
|
83
|
+
.trim();
|
|
84
|
+
|
|
85
|
+
beforeAll(() => {
|
|
86
|
+
workDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rescue-gate-"));
|
|
87
|
+
|
|
88
|
+
// --- Minimal local "upstream" repo (one core file, floor + HEAD). ---
|
|
89
|
+
upstream = path.join(workDir, "upstream");
|
|
90
|
+
fs.mkdirSync(path.join(upstream, "core"), { recursive: true });
|
|
91
|
+
git(workDir, "init", "-b", "main", "upstream");
|
|
92
|
+
fs.writeFileSync(path.join(upstream, "core/x.md"), "v1\n");
|
|
93
|
+
git(upstream, "add", "-A");
|
|
94
|
+
git(upstream, "commit", "-m", "floor");
|
|
95
|
+
floorSha = git(upstream, "rev-parse", "HEAD");
|
|
96
|
+
fs.writeFileSync(path.join(upstream, "core/x.md"), "v2\n");
|
|
97
|
+
git(upstream, "add", "-A");
|
|
98
|
+
git(upstream, "commit", "-m", "head");
|
|
99
|
+
|
|
100
|
+
// --- git shim: rewrite `git clone <github-url> dest` to the fixture. ---
|
|
101
|
+
const realGit =
|
|
102
|
+
execFileSync("bash", ["-lc", "command -v git"]).toString().trim() || "/usr/bin/git";
|
|
103
|
+
shimDir = path.join(workDir, "shim");
|
|
104
|
+
fs.mkdirSync(shimDir, { recursive: true });
|
|
105
|
+
const shim = `#!/usr/bin/env bash
|
|
106
|
+
if [ "$1" = "clone" ]; then
|
|
107
|
+
args=()
|
|
108
|
+
for a in "$@"; do
|
|
109
|
+
case "$a" in
|
|
110
|
+
https://github.com/*) a=${JSON.stringify(upstream)} ;;
|
|
111
|
+
esac
|
|
112
|
+
args+=("$a")
|
|
113
|
+
done
|
|
114
|
+
exec ${JSON.stringify(realGit)} "\${args[@]}"
|
|
115
|
+
fi
|
|
116
|
+
exec ${JSON.stringify(realGit)} "$@"
|
|
117
|
+
`;
|
|
118
|
+
fs.writeFileSync(path.join(shimDir, "git"), shim, { mode: 0o755 });
|
|
119
|
+
env = { ...process.env, PATH: `${shimDir}:${process.env.PATH ?? ""}` };
|
|
120
|
+
}, 60_000); // git init + commits can exceed vitest's 10s default under parallel load
|
|
121
|
+
|
|
122
|
+
afterAll(() => {
|
|
123
|
+
if (workDir) fs.rmSync(workDir, { recursive: true, force: true });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
function makeRoot(name: string, dirs: string[]): string {
|
|
127
|
+
const root = path.join(workDir, name);
|
|
128
|
+
for (const d of dirs) fs.mkdirSync(path.join(root, d), { recursive: true });
|
|
129
|
+
return root;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function rescueDry(hqRoot: string) {
|
|
133
|
+
return runRescueCapture(
|
|
134
|
+
[
|
|
135
|
+
"--hq-root", hqRoot,
|
|
136
|
+
"--source", "test/repo",
|
|
137
|
+
"--ref", "main",
|
|
138
|
+
"--floor-sha", floorSha,
|
|
139
|
+
"--dry-run",
|
|
140
|
+
"--yes",
|
|
141
|
+
"--no-backup",
|
|
142
|
+
],
|
|
143
|
+
env,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
it("admits a faithful v14.0.0 root (companies/ + .claude/, NO personal/, NO core/)", () => {
|
|
148
|
+
// Exactly the shape that aborted before the fix.
|
|
149
|
+
const root = makeRoot("v14", ["companies", ".claude", "repos", "workspace"]);
|
|
150
|
+
expect(fs.existsSync(path.join(root, "personal"))).toBe(false);
|
|
151
|
+
expect(fs.existsSync(path.join(root, "core"))).toBe(false);
|
|
152
|
+
|
|
153
|
+
const r = rescueDry(root);
|
|
154
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
155
|
+
// Must get PAST the gate — not the exit-3 abort.
|
|
156
|
+
expect(r.status, out).not.toBe(3);
|
|
157
|
+
expect(out).not.toContain("does not look like an HQ root");
|
|
158
|
+
expect(r.status, out).toBe(0);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("admits a v15 root (companies/ + core/ + personal/)", () => {
|
|
162
|
+
const root = makeRoot("v15", ["companies", ".claude", "core", "personal", "repos", "workspace"]);
|
|
163
|
+
const r = rescueDry(root);
|
|
164
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
165
|
+
expect(r.status, out).toBe(0);
|
|
166
|
+
expect(out).not.toContain("does not look like an HQ root");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("admits a core-only root (companies/ + core/, no .claude/ or personal/)", () => {
|
|
170
|
+
const root = makeRoot("coreonly", ["companies", "core"]);
|
|
171
|
+
const r = rescueDry(root);
|
|
172
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
173
|
+
expect(r.status, out).not.toBe(3);
|
|
174
|
+
expect(out).not.toContain("does not look like an HQ root");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("still rejects a non-HQ directory (companies/ only — no scaffold marker)", () => {
|
|
178
|
+
const root = makeRoot("bare", ["companies"]);
|
|
179
|
+
const r = rescueDry(root);
|
|
180
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
181
|
+
expect(r.status, out).toBe(3);
|
|
182
|
+
expect(out).toContain("does not look like an HQ root");
|
|
183
|
+
expect(out).toContain("need companies/ plus one of .claude/, core/, or personal/");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("still rejects a directory with a marker but no companies/", () => {
|
|
187
|
+
const root = makeRoot("nocompanies", [".claude", "core", "personal"]);
|
|
188
|
+
const r = rescueDry(root);
|
|
189
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
190
|
+
expect(r.status, out).toBe(3);
|
|
191
|
+
expect(out).toContain("does not look like an HQ root");
|
|
192
|
+
});
|
|
193
|
+
});
|