@indigoai-us/hq-cloud 6.2.7 → 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 +105 -3
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +262 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts +8 -0
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +222 -198
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +35 -0
- package/dist/cli/reindex.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/cli/rescue.d.ts.map +1 -1
- package/dist/cli/rescue.js +39 -16
- package/dist/cli/rescue.js.map +1 -1
- package/dist/cli/rescue.reindex.test.js +15 -2
- package/dist/cli/rescue.reindex.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +3 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +2 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/operation-lock.d.ts +100 -0
- package/dist/operation-lock.d.ts.map +1 -0
- package/dist/operation-lock.js +256 -0
- package/dist/operation-lock.js.map +1 -0
- package/dist/operation-lock.test.d.ts +5 -0
- package/dist/operation-lock.test.d.ts.map +1 -0
- package/dist/operation-lock.test.js +140 -0
- package/dist/operation-lock.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 +323 -0
- package/src/bin/sync-runner.ts +139 -4
- package/src/cli/reindex.test.ts +45 -0
- package/src/cli/reindex.ts +36 -0
- package/src/cli/rescue-core.ts +15 -2
- package/src/cli/rescue-hq-root-guard.test.ts +193 -0
- package/src/cli/rescue.reindex.test.ts +17 -2
- package/src/cli/rescue.ts +40 -15
- package/src/cli/sync.test.ts +2 -1
- package/src/cli/sync.ts +3 -1
- package/src/operation-lock.test.ts +162 -0
- package/src/operation-lock.ts +293 -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
|
@@ -13,6 +13,7 @@ import * as path from "path";
|
|
|
13
13
|
import { runRunner, runRunnerWithLoop, resolveDeletePolicy, resolveSkipPersonal, resolvePresignTransport, routeChangeToTarget, buildTargetedPushArgv, resolvePullScope, readPinnedPrefixes, } from "./sync-runner.js";
|
|
14
14
|
import { FakeClock } from "../watcher.js";
|
|
15
15
|
import { PERSONAL_VAULT_JOURNAL_SLUG } from "../journal.js";
|
|
16
|
+
import { lockPathFor, OPERATION_LOCKED_EXIT } from "../operation-lock.js";
|
|
16
17
|
import { VaultAuthError } from "../vault-client.js";
|
|
17
18
|
// ---------------------------------------------------------------------------
|
|
18
19
|
// Hermetic journal state — the runner now calls migratePersonalVaultJournal()
|
|
@@ -2850,4 +2851,265 @@ describe("readPinnedPrefixes", () => {
|
|
|
2850
2851
|
expect(readPinnedPrefixes(root, "acme")).toEqual([]);
|
|
2851
2852
|
});
|
|
2852
2853
|
});
|
|
2854
|
+
// ---------------------------------------------------------------------------
|
|
2855
|
+
// Operation lock — one-shot sync takes it; the watch runner is exempt.
|
|
2856
|
+
// ---------------------------------------------------------------------------
|
|
2857
|
+
describe("runRunnerWithLoop — operation lock", () => {
|
|
2858
|
+
const HQ = "/tmp/hq-oplock";
|
|
2859
|
+
/** Write a live-holder lock (pid 1) for the HQ root into the test state dir. */
|
|
2860
|
+
function writeLiveHolder(command) {
|
|
2861
|
+
const p = lockPathFor(HQ);
|
|
2862
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
2863
|
+
fs.writeFileSync(p, JSON.stringify({ pid: 1, command, startedAt: new Date(0).toISOString(), hqRoot: HQ }));
|
|
2864
|
+
return p;
|
|
2865
|
+
}
|
|
2866
|
+
it("one-shot sync refuses fast (exit 17) when another op holds the root", async () => {
|
|
2867
|
+
const lp = writeLiveHolder("rescue");
|
|
2868
|
+
const errs = [];
|
|
2869
|
+
const spy = vi
|
|
2870
|
+
.spyOn(process.stderr, "write")
|
|
2871
|
+
.mockImplementation((chunk) => {
|
|
2872
|
+
errs.push(String(chunk));
|
|
2873
|
+
return true;
|
|
2874
|
+
});
|
|
2875
|
+
// No --watch → one-shot. Refusal short-circuits BEFORE runRunner (so no
|
|
2876
|
+
// network / auth is touched).
|
|
2877
|
+
const code = await runRunnerWithLoop(["--companies", "--hq-root", HQ]);
|
|
2878
|
+
spy.mockRestore();
|
|
2879
|
+
expect(code).toBe(OPERATION_LOCKED_EXIT);
|
|
2880
|
+
expect(errs.join("")).toContain("rescue"); // names the holder
|
|
2881
|
+
// The holder's lock is left intact — we refused, we didn't take it over.
|
|
2882
|
+
const held = JSON.parse(fs.readFileSync(lp, "utf8"));
|
|
2883
|
+
expect(held.pid).toBe(1);
|
|
2884
|
+
expect(held.command).toBe("rescue");
|
|
2885
|
+
});
|
|
2886
|
+
it("the watch runner is EXEMPT — runs despite a held lock and never takes it", async () => {
|
|
2887
|
+
const lp = writeLiveHolder("sync");
|
|
2888
|
+
const watcher = makeWatcherStub();
|
|
2889
|
+
let triggerShutdown = () => { };
|
|
2890
|
+
const runPass = vi.fn().mockResolvedValue(0);
|
|
2891
|
+
const loop = runRunnerWithLoop(["--companies", "--watch", "--event-push", "--hq-root", HQ], {
|
|
2892
|
+
runPass,
|
|
2893
|
+
clock: new FakeClock(),
|
|
2894
|
+
createWatcher: () => watcher,
|
|
2895
|
+
sleep: () => new Promise(() => { }),
|
|
2896
|
+
onShutdownSignal: (handler) => {
|
|
2897
|
+
triggerShutdown = handler;
|
|
2898
|
+
return () => { };
|
|
2899
|
+
},
|
|
2900
|
+
});
|
|
2901
|
+
await Promise.resolve();
|
|
2902
|
+
await Promise.resolve();
|
|
2903
|
+
// It started and ran a pass even though the lock is held → not blocked.
|
|
2904
|
+
expect(watcher.started).toBe(true);
|
|
2905
|
+
expect(runPass).toHaveBeenCalled();
|
|
2906
|
+
triggerShutdown();
|
|
2907
|
+
await loop;
|
|
2908
|
+
// The pre-existing holder lock is untouched → the watcher never took it.
|
|
2909
|
+
const held = JSON.parse(fs.readFileSync(lp, "utf8"));
|
|
2910
|
+
expect(held.pid).toBe(1);
|
|
2911
|
+
expect(held.command).toBe("sync");
|
|
2912
|
+
});
|
|
2913
|
+
});
|
|
2914
|
+
function makeBatchWatcherStub() {
|
|
2915
|
+
const listeners = new Set();
|
|
2916
|
+
return {
|
|
2917
|
+
disposed: false,
|
|
2918
|
+
onChange(listener) {
|
|
2919
|
+
listeners.add(listener);
|
|
2920
|
+
return () => listeners.delete(listener);
|
|
2921
|
+
},
|
|
2922
|
+
start() { },
|
|
2923
|
+
stop() { },
|
|
2924
|
+
dispose() {
|
|
2925
|
+
this.disposed = true;
|
|
2926
|
+
listeners.clear();
|
|
2927
|
+
},
|
|
2928
|
+
emit(changedRelPath, batch) {
|
|
2929
|
+
for (const l of [...listeners])
|
|
2930
|
+
l(changedRelPath, batch);
|
|
2931
|
+
},
|
|
2932
|
+
};
|
|
2933
|
+
}
|
|
2934
|
+
describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", () => {
|
|
2935
|
+
const ENROLLED_CLAIMS = { email: "hassaan@getindigo.ai" };
|
|
2936
|
+
const UNENROLLED_CLAIMS = { email: "someone@getindigo.ai" };
|
|
2937
|
+
function runLoop(opts) {
|
|
2938
|
+
const watcher = opts.watcher ?? makeBatchWatcherStub();
|
|
2939
|
+
const runPass = opts.runPass ?? vi.fn().mockResolvedValue(0);
|
|
2940
|
+
const clock = new FakeClock();
|
|
2941
|
+
let triggerShutdown = () => { };
|
|
2942
|
+
const loop = runRunnerWithLoop(["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"], {
|
|
2943
|
+
runPass,
|
|
2944
|
+
clock,
|
|
2945
|
+
createWatcher: () => watcher,
|
|
2946
|
+
sleep: () => new Promise(() => { }),
|
|
2947
|
+
onShutdownSignal: (handler) => {
|
|
2948
|
+
triggerShutdown = handler;
|
|
2949
|
+
return () => { };
|
|
2950
|
+
},
|
|
2951
|
+
getIdTokenClaims: () => opts.claims,
|
|
2952
|
+
getAccessToken: async () => "jwt-test",
|
|
2953
|
+
startEventSync: (opts.startEventSync ??
|
|
2954
|
+
vi.fn().mockResolvedValue(null)),
|
|
2955
|
+
});
|
|
2956
|
+
return { loop, watcher, runPass, clock, shutdown: () => triggerShutdown() };
|
|
2957
|
+
}
|
|
2958
|
+
async function microtasks(n = 6) {
|
|
2959
|
+
for (let i = 0; i < n; i++)
|
|
2960
|
+
await Promise.resolve();
|
|
2961
|
+
}
|
|
2962
|
+
it("gate OFF (unenrolled email): the event-sync seam is never consulted", async () => {
|
|
2963
|
+
const startEventSync = vi.fn().mockResolvedValue(null);
|
|
2964
|
+
const { loop, shutdown } = runLoop({
|
|
2965
|
+
claims: UNENROLLED_CLAIMS,
|
|
2966
|
+
startEventSync,
|
|
2967
|
+
});
|
|
2968
|
+
await microtasks();
|
|
2969
|
+
shutdown();
|
|
2970
|
+
await loop;
|
|
2971
|
+
expect(startEventSync).not.toHaveBeenCalled();
|
|
2972
|
+
});
|
|
2973
|
+
it("gate OFF (no cached claims): the event-sync seam is never consulted", async () => {
|
|
2974
|
+
const startEventSync = vi.fn().mockResolvedValue(null);
|
|
2975
|
+
const { loop, shutdown } = runLoop({ claims: null, startEventSync });
|
|
2976
|
+
await microtasks();
|
|
2977
|
+
shutdown();
|
|
2978
|
+
await loop;
|
|
2979
|
+
expect(startEventSync).not.toHaveBeenCalled();
|
|
2980
|
+
});
|
|
2981
|
+
it("gate ON (enrolled email): wiring receives root, api url, auth + a sync bridge", async () => {
|
|
2982
|
+
const startEventSync = vi.fn().mockResolvedValue(null);
|
|
2983
|
+
const { loop, shutdown } = runLoop({
|
|
2984
|
+
claims: ENROLLED_CLAIMS,
|
|
2985
|
+
startEventSync,
|
|
2986
|
+
});
|
|
2987
|
+
await microtasks();
|
|
2988
|
+
shutdown();
|
|
2989
|
+
await loop;
|
|
2990
|
+
expect(startEventSync).toHaveBeenCalledTimes(1);
|
|
2991
|
+
const arg = startEventSync.mock.calls[0][0];
|
|
2992
|
+
expect(arg.hqRoot).toBe("/tmp/hq");
|
|
2993
|
+
expect(arg.apiUrl).toContain("https://");
|
|
2994
|
+
expect(typeof arg.deviceId).toBe("string");
|
|
2995
|
+
expect(arg.deviceId.length).toBeGreaterThan(0);
|
|
2996
|
+
expect(typeof arg.resolveTenantId).toBe("function");
|
|
2997
|
+
expect(typeof arg.syncFn).toBe("function");
|
|
2998
|
+
});
|
|
2999
|
+
it("publishes the batch ONLY after a successful targeted push pass", async () => {
|
|
3000
|
+
const publishBatch = vi.fn();
|
|
3001
|
+
const startEventSync = vi.fn().mockResolvedValue({
|
|
3002
|
+
publishBatch,
|
|
3003
|
+
receiver: { start: async () => { }, dispose: async () => { }, connected: true },
|
|
3004
|
+
ownDeviceId: "dev-test",
|
|
3005
|
+
dispose: vi.fn().mockResolvedValue(undefined),
|
|
3006
|
+
});
|
|
3007
|
+
const runPass = vi.fn().mockResolvedValue(0);
|
|
3008
|
+
const { loop, watcher, clock, shutdown } = runLoop({
|
|
3009
|
+
claims: ENROLLED_CLAIMS,
|
|
3010
|
+
startEventSync,
|
|
3011
|
+
runPass,
|
|
3012
|
+
});
|
|
3013
|
+
await microtasks(); // initial poll pass + async event-sync bring-up
|
|
3014
|
+
runPass.mockClear();
|
|
3015
|
+
const batch = {
|
|
3016
|
+
paths: new Map([["/tmp/hq/companies/indigo/a.md", "companies/indigo/a.md"]]),
|
|
3017
|
+
};
|
|
3018
|
+
watcher.emit("companies/indigo/a.md", batch);
|
|
3019
|
+
clock.advance(0);
|
|
3020
|
+
await microtasks();
|
|
3021
|
+
// Targeted push ran and succeeded → batch published.
|
|
3022
|
+
expect(runPass).toHaveBeenCalled();
|
|
3023
|
+
expect(publishBatch).toHaveBeenCalledTimes(1);
|
|
3024
|
+
expect(publishBatch.mock.calls[0][0]).toBe(batch);
|
|
3025
|
+
shutdown();
|
|
3026
|
+
await loop;
|
|
3027
|
+
});
|
|
3028
|
+
it("publishes NOTHING when the targeted push pass fails", async () => {
|
|
3029
|
+
const publishBatch = vi.fn();
|
|
3030
|
+
const startEventSync = vi.fn().mockResolvedValue({
|
|
3031
|
+
publishBatch,
|
|
3032
|
+
receiver: { start: async () => { }, dispose: async () => { }, connected: true },
|
|
3033
|
+
ownDeviceId: "dev-test",
|
|
3034
|
+
dispose: vi.fn().mockResolvedValue(undefined),
|
|
3035
|
+
});
|
|
3036
|
+
// Initial poll pass succeeds; the targeted pass fails.
|
|
3037
|
+
const runPass = vi
|
|
3038
|
+
.fn()
|
|
3039
|
+
.mockResolvedValueOnce(0)
|
|
3040
|
+
.mockResolvedValue(1);
|
|
3041
|
+
const { loop, watcher, clock, shutdown } = runLoop({
|
|
3042
|
+
claims: ENROLLED_CLAIMS,
|
|
3043
|
+
startEventSync,
|
|
3044
|
+
runPass,
|
|
3045
|
+
});
|
|
3046
|
+
await microtasks();
|
|
3047
|
+
watcher.emit("companies/indigo/a.md", { paths: new Map([["/tmp/hq/companies/indigo/a.md", "companies/indigo/a.md"]]) });
|
|
3048
|
+
clock.advance(0);
|
|
3049
|
+
await microtasks();
|
|
3050
|
+
expect(publishBatch).not.toHaveBeenCalled();
|
|
3051
|
+
shutdown();
|
|
3052
|
+
await loop;
|
|
3053
|
+
});
|
|
3054
|
+
it("falls back to a synthesized single-path batch when the watcher emits a bare path", async () => {
|
|
3055
|
+
const publishBatch = vi.fn();
|
|
3056
|
+
const startEventSync = vi.fn().mockResolvedValue({
|
|
3057
|
+
publishBatch,
|
|
3058
|
+
receiver: { start: async () => { }, dispose: async () => { }, connected: true },
|
|
3059
|
+
ownDeviceId: "dev-test",
|
|
3060
|
+
dispose: vi.fn().mockResolvedValue(undefined),
|
|
3061
|
+
});
|
|
3062
|
+
const { loop, watcher, clock, shutdown } = runLoop({
|
|
3063
|
+
claims: ENROLLED_CLAIMS,
|
|
3064
|
+
startEventSync,
|
|
3065
|
+
});
|
|
3066
|
+
await microtasks();
|
|
3067
|
+
watcher.emit("companies/indigo/b.md"); // no batch
|
|
3068
|
+
clock.advance(0);
|
|
3069
|
+
await microtasks();
|
|
3070
|
+
expect(publishBatch).toHaveBeenCalledTimes(1);
|
|
3071
|
+
const synthesized = publishBatch.mock.calls[0][0];
|
|
3072
|
+
expect([...synthesized.paths.values()]).toEqual(["companies/indigo/b.md"]);
|
|
3073
|
+
shutdown();
|
|
3074
|
+
await loop;
|
|
3075
|
+
});
|
|
3076
|
+
it("disposes the event-sync handles on shutdown", async () => {
|
|
3077
|
+
const dispose = vi.fn().mockResolvedValue(undefined);
|
|
3078
|
+
const startEventSync = vi.fn().mockResolvedValue({
|
|
3079
|
+
publishBatch: vi.fn(),
|
|
3080
|
+
receiver: { start: async () => { }, dispose: async () => { }, connected: true },
|
|
3081
|
+
ownDeviceId: "dev-test",
|
|
3082
|
+
dispose,
|
|
3083
|
+
});
|
|
3084
|
+
const { loop, shutdown } = runLoop({
|
|
3085
|
+
claims: ENROLLED_CLAIMS,
|
|
3086
|
+
startEventSync,
|
|
3087
|
+
});
|
|
3088
|
+
await microtasks();
|
|
3089
|
+
shutdown();
|
|
3090
|
+
await loop;
|
|
3091
|
+
expect(dispose).toHaveBeenCalled();
|
|
3092
|
+
});
|
|
3093
|
+
it("env override HQ_SYNC_EVENT_SYNC=0 keeps the seam dormant for the enrolled account", async () => {
|
|
3094
|
+
const prev = process.env.HQ_SYNC_EVENT_SYNC;
|
|
3095
|
+
process.env.HQ_SYNC_EVENT_SYNC = "0";
|
|
3096
|
+
try {
|
|
3097
|
+
const startEventSync = vi.fn().mockResolvedValue(null);
|
|
3098
|
+
const { loop, shutdown } = runLoop({
|
|
3099
|
+
claims: ENROLLED_CLAIMS,
|
|
3100
|
+
startEventSync,
|
|
3101
|
+
});
|
|
3102
|
+
await microtasks();
|
|
3103
|
+
shutdown();
|
|
3104
|
+
await loop;
|
|
3105
|
+
expect(startEventSync).not.toHaveBeenCalled();
|
|
3106
|
+
}
|
|
3107
|
+
finally {
|
|
3108
|
+
if (prev === undefined)
|
|
3109
|
+
delete process.env.HQ_SYNC_EVENT_SYNC;
|
|
3110
|
+
else
|
|
3111
|
+
process.env.HQ_SYNC_EVENT_SYNC = prev;
|
|
3112
|
+
}
|
|
3113
|
+
});
|
|
3114
|
+
});
|
|
2853
3115
|
//# sourceMappingURL=sync-runner.test.js.map
|