@indigoai-us/hq-cloud 6.13.4 → 6.14.0
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-company.d.ts +1 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +41 -2
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +9 -1
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +85 -51
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts +3 -0
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-routes.js +52 -1
- package/dist/bin/sync-runner-watch-routes.js.map +1 -1
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +19 -4
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +666 -45
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/lib/net-errors.d.ts +36 -0
- package/dist/lib/net-errors.d.ts.map +1 -0
- package/dist/lib/net-errors.js +81 -0
- package/dist/lib/net-errors.js.map +1 -0
- package/dist/lib/net-errors.test.d.ts +2 -0
- package/dist/lib/net-errors.test.d.ts.map +1 -0
- package/dist/lib/net-errors.test.js +47 -0
- package/dist/lib/net-errors.test.js.map +1 -0
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +61 -2
- package/src/bin/sync-runner-watch-loop.ts +116 -55
- package/src/bin/sync-runner-watch-routes.ts +57 -1
- package/src/bin/sync-runner.test.ts +774 -50
- package/src/bin/sync-runner.ts +23 -4
- package/src/lib/net-errors.test.ts +53 -0
- package/src/lib/net-errors.ts +83 -0
|
@@ -35,6 +35,8 @@ import { FakeClock } from "../watcher.js";
|
|
|
35
35
|
import { PERSONAL_VAULT_JOURNAL_SLUG } from "../journal.js";
|
|
36
36
|
import { HQ_CLOUD_VERSION } from "../version.js";
|
|
37
37
|
import { lockPathFor, OPERATION_LOCKED_EXIT } from "../operation-lock.js";
|
|
38
|
+
import { TRANSIENT_NETWORK_EXIT } from "../lib/net-errors.js";
|
|
39
|
+
import { isCoveredByAny } from "../prefix-coalesce.js";
|
|
38
40
|
import type { SyncResult, SyncOptions } from "../cli/sync.js";
|
|
39
41
|
import type { ShareResult, ShareOptions } from "../cli/share.js";
|
|
40
42
|
import type {
|
|
@@ -397,6 +399,49 @@ describe("memberships retry", () => {
|
|
|
397
399
|
});
|
|
398
400
|
});
|
|
399
401
|
|
|
402
|
+
it("a transient network failure (fetch failed) during discovery returns TRANSIENT_NETWORK_EXIT, not 1 (HQ-SYNC-1W)", async () => {
|
|
403
|
+
// Regression for the "auto-sync watcher exited unexpectedly (code=Some(1))"
|
|
404
|
+
// cluster: a machine that stays offline through all 3 membership retries
|
|
405
|
+
// used to `return 1`, which the watch loop propagated and the menubar
|
|
406
|
+
// reported as a crash for every network blip. undici surfaces the blip as
|
|
407
|
+
// `TypeError: fetch failed`; that must map to the retryable exit code so the
|
|
408
|
+
// watcher stays alive instead of crash-reporting. A one-shot run still sees
|
|
409
|
+
// a non-zero exit.
|
|
410
|
+
let listCalls = 0;
|
|
411
|
+
const stub = makeVaultStub();
|
|
412
|
+
stub.listMyMemberships = () => {
|
|
413
|
+
listCalls++;
|
|
414
|
+
return Promise.reject(new TypeError("fetch failed"));
|
|
415
|
+
};
|
|
416
|
+
const deps = makeDeps({ createVaultClient: () => stub });
|
|
417
|
+
const code = await runRunner(["--companies"], deps);
|
|
418
|
+
expect(code).toBe(TRANSIENT_NETWORK_EXIT);
|
|
419
|
+
expect(listCalls).toBe(3);
|
|
420
|
+
// Still surfaced for observability — just no longer a "code 1" crash.
|
|
421
|
+
const events = deps.stderr.events();
|
|
422
|
+
expect(events).toHaveLength(1);
|
|
423
|
+
expect(events[0]).toMatchObject({
|
|
424
|
+
type: "error",
|
|
425
|
+
message: "fetch failed",
|
|
426
|
+
path: "(discovery)",
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("a NON-transient discovery failure still returns 1 (unchanged)", async () => {
|
|
431
|
+
// Guard the conservative classifier: a generic error (not a transport
|
|
432
|
+
// shape) is still a hard exit 1, exactly as before.
|
|
433
|
+
let listCalls = 0;
|
|
434
|
+
const stub = makeVaultStub();
|
|
435
|
+
stub.listMyMemberships = () => {
|
|
436
|
+
listCalls++;
|
|
437
|
+
return Promise.reject(new Error("unexpected 500 from vault api"));
|
|
438
|
+
};
|
|
439
|
+
const deps = makeDeps({ createVaultClient: () => stub });
|
|
440
|
+
const code = await runRunner(["--companies"], deps);
|
|
441
|
+
expect(code).toBe(1);
|
|
442
|
+
expect(listCalls).toBe(3);
|
|
443
|
+
});
|
|
444
|
+
|
|
400
445
|
it("VaultAuthError short-circuits retry (no retries on auth failure)", async () => {
|
|
401
446
|
let listCalls = 0;
|
|
402
447
|
const stub = makeVaultStub();
|
|
@@ -1936,6 +1981,101 @@ describe("--direction", () => {
|
|
|
1936
1981
|
expect(opts.prefixSet).toBeUndefined();
|
|
1937
1982
|
});
|
|
1938
1983
|
|
|
1984
|
+
it("direction=push with --scope-path: forwards exact and descendant scoped prefixes to share()", async () => {
|
|
1985
|
+
const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
|
|
1986
|
+
const deps = makeDeps({
|
|
1987
|
+
createVaultClient: () =>
|
|
1988
|
+
makeVaultStub({
|
|
1989
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
1990
|
+
entityGet: (uid: string) =>
|
|
1991
|
+
Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
|
|
1992
|
+
}),
|
|
1993
|
+
sync: vi.fn(),
|
|
1994
|
+
share: shareSpy,
|
|
1995
|
+
});
|
|
1996
|
+
|
|
1997
|
+
await runRunner(
|
|
1998
|
+
[
|
|
1999
|
+
"--companies",
|
|
2000
|
+
"--direction",
|
|
2001
|
+
"push",
|
|
2002
|
+
"--hq-root",
|
|
2003
|
+
"/tmp/fake-hq",
|
|
2004
|
+
"--scope-path",
|
|
2005
|
+
"knowledge/a.md",
|
|
2006
|
+
],
|
|
2007
|
+
deps,
|
|
2008
|
+
);
|
|
2009
|
+
|
|
2010
|
+
const opts = (shareSpy.mock.calls[0] as [ShareOptions])[0];
|
|
2011
|
+
expect(opts.prefixSet).toEqual(["knowledge/a.md", "knowledge/a.md/"]);
|
|
2012
|
+
});
|
|
2013
|
+
|
|
2014
|
+
it("direction=push with directory --scope-path covers descendant keys for tombstones", async () => {
|
|
2015
|
+
const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
|
|
2016
|
+
const deps = makeDeps({
|
|
2017
|
+
createVaultClient: () =>
|
|
2018
|
+
makeVaultStub({
|
|
2019
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
2020
|
+
entityGet: (uid: string) =>
|
|
2021
|
+
Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
|
|
2022
|
+
}),
|
|
2023
|
+
sync: vi.fn(),
|
|
2024
|
+
share: shareSpy,
|
|
2025
|
+
});
|
|
2026
|
+
|
|
2027
|
+
await runRunner(
|
|
2028
|
+
[
|
|
2029
|
+
"--companies",
|
|
2030
|
+
"--direction",
|
|
2031
|
+
"push",
|
|
2032
|
+
"--hq-root",
|
|
2033
|
+
"/tmp/fake-hq",
|
|
2034
|
+
"--scope-path",
|
|
2035
|
+
"knowledge/archive",
|
|
2036
|
+
],
|
|
2037
|
+
deps,
|
|
2038
|
+
);
|
|
2039
|
+
|
|
2040
|
+
const opts = (shareSpy.mock.calls[0] as [ShareOptions])[0];
|
|
2041
|
+
expect(opts.prefixSet).toBeDefined();
|
|
2042
|
+
expect(isCoveredByAny("knowledge/archive/a.md", opts.prefixSet ?? [])).toBe(
|
|
2043
|
+
true,
|
|
2044
|
+
);
|
|
2045
|
+
});
|
|
2046
|
+
|
|
2047
|
+
it("direction=push with file --scope-path does not cover siblings", async () => {
|
|
2048
|
+
const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
|
|
2049
|
+
const deps = makeDeps({
|
|
2050
|
+
createVaultClient: () =>
|
|
2051
|
+
makeVaultStub({
|
|
2052
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
2053
|
+
entityGet: (uid: string) =>
|
|
2054
|
+
Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
|
|
2055
|
+
}),
|
|
2056
|
+
sync: vi.fn(),
|
|
2057
|
+
share: shareSpy,
|
|
2058
|
+
});
|
|
2059
|
+
|
|
2060
|
+
await runRunner(
|
|
2061
|
+
[
|
|
2062
|
+
"--companies",
|
|
2063
|
+
"--direction",
|
|
2064
|
+
"push",
|
|
2065
|
+
"--hq-root",
|
|
2066
|
+
"/tmp/fake-hq",
|
|
2067
|
+
"--scope-path",
|
|
2068
|
+
"board.json",
|
|
2069
|
+
],
|
|
2070
|
+
deps,
|
|
2071
|
+
);
|
|
2072
|
+
|
|
2073
|
+
const opts = (shareSpy.mock.calls[0] as [ShareOptions])[0];
|
|
2074
|
+
expect(opts.prefixSet).toBeDefined();
|
|
2075
|
+
expect(isCoveredByAny("board.json", opts.prefixSet ?? [])).toBe(true);
|
|
2076
|
+
expect(isCoveredByAny("other.json", opts.prefixSet ?? [])).toBe(false);
|
|
2077
|
+
});
|
|
2078
|
+
|
|
1939
2079
|
it("direction=push (shared membership): forwards the resolved ACL prefixSet to share() (feedback_ded09d56)", async () => {
|
|
1940
2080
|
// Plumbing regression for the fresh fix: a member/guest's push must be
|
|
1941
2081
|
// scoped to their granted prefixes so out-of-scope keys are filtered
|
|
@@ -1971,6 +2111,92 @@ describe("--direction", () => {
|
|
|
1971
2111
|
expect(opts.prefixSet).toEqual(["knowledge/", "policies/"]);
|
|
1972
2112
|
});
|
|
1973
2113
|
|
|
2114
|
+
it("direction=push with --scope-path never widens a shared ACL prefixSet", async () => {
|
|
2115
|
+
const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
|
|
2116
|
+
const client = {
|
|
2117
|
+
...makeVaultStub({
|
|
2118
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
2119
|
+
entityGet: (uid: string) =>
|
|
2120
|
+
Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
|
|
2121
|
+
}),
|
|
2122
|
+
getMembershipSyncConfig: async () => ({
|
|
2123
|
+
membershipId: "mk_a",
|
|
2124
|
+
syncMode: "shared",
|
|
2125
|
+
isDefault: false,
|
|
2126
|
+
}),
|
|
2127
|
+
listMyExplicitGrants: async () => [
|
|
2128
|
+
{ companyUid: "cmp_a", path: "knowledge/", permission: "read", source: "person" },
|
|
2129
|
+
],
|
|
2130
|
+
} as unknown as ReturnType<typeof makeVaultStub>;
|
|
2131
|
+
const deps = makeDeps({
|
|
2132
|
+
createVaultClient: () => client,
|
|
2133
|
+
sync: vi.fn(),
|
|
2134
|
+
share: shareSpy,
|
|
2135
|
+
});
|
|
2136
|
+
|
|
2137
|
+
await runRunner(
|
|
2138
|
+
[
|
|
2139
|
+
"--companies",
|
|
2140
|
+
"--direction",
|
|
2141
|
+
"push",
|
|
2142
|
+
"--hq-root",
|
|
2143
|
+
"/tmp/fake-hq",
|
|
2144
|
+
"--scope-path",
|
|
2145
|
+
"knowledge/a.md",
|
|
2146
|
+
"--scope-path",
|
|
2147
|
+
"secrets/b.md",
|
|
2148
|
+
],
|
|
2149
|
+
deps,
|
|
2150
|
+
);
|
|
2151
|
+
|
|
2152
|
+
const opts = (shareSpy.mock.calls[0] as [ShareOptions])[0];
|
|
2153
|
+
expect(opts.prefixSet).toEqual(["knowledge/a.md", "knowledge/a.md/"]);
|
|
2154
|
+
expect(isCoveredByAny("secrets/b.md", opts.prefixSet ?? [])).toBe(false);
|
|
2155
|
+
});
|
|
2156
|
+
|
|
2157
|
+
it("direction=push with directory --scope-path never widens outside a shared ACL prefixSet", async () => {
|
|
2158
|
+
const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
|
|
2159
|
+
const client = {
|
|
2160
|
+
...makeVaultStub({
|
|
2161
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
2162
|
+
entityGet: (uid: string) =>
|
|
2163
|
+
Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
|
|
2164
|
+
}),
|
|
2165
|
+
getMembershipSyncConfig: async () => ({
|
|
2166
|
+
membershipId: "mk_a",
|
|
2167
|
+
syncMode: "shared",
|
|
2168
|
+
isDefault: false,
|
|
2169
|
+
}),
|
|
2170
|
+
listMyExplicitGrants: async () => [
|
|
2171
|
+
{ companyUid: "cmp_a", path: "policies/", permission: "read", source: "person" },
|
|
2172
|
+
],
|
|
2173
|
+
} as unknown as ReturnType<typeof makeVaultStub>;
|
|
2174
|
+
const deps = makeDeps({
|
|
2175
|
+
createVaultClient: () => client,
|
|
2176
|
+
sync: vi.fn(),
|
|
2177
|
+
share: shareSpy,
|
|
2178
|
+
});
|
|
2179
|
+
|
|
2180
|
+
await runRunner(
|
|
2181
|
+
[
|
|
2182
|
+
"--companies",
|
|
2183
|
+
"--direction",
|
|
2184
|
+
"push",
|
|
2185
|
+
"--hq-root",
|
|
2186
|
+
"/tmp/fake-hq",
|
|
2187
|
+
"--scope-path",
|
|
2188
|
+
"knowledge/archive",
|
|
2189
|
+
],
|
|
2190
|
+
deps,
|
|
2191
|
+
);
|
|
2192
|
+
|
|
2193
|
+
const opts = (shareSpy.mock.calls[0] as [ShareOptions])[0];
|
|
2194
|
+
expect(isCoveredByAny("knowledge/archive/a.md", opts.prefixSet ?? [])).toBe(
|
|
2195
|
+
false,
|
|
2196
|
+
);
|
|
2197
|
+
expect(isCoveredByAny("policies/a.md", opts.prefixSet ?? [])).toBe(false);
|
|
2198
|
+
});
|
|
2199
|
+
|
|
1974
2200
|
it("direction=both: all-complete sums uploaded and downloaded across companies", async () => {
|
|
1975
2201
|
const slugs: Record<string, string> = { cmp_a: "acme", cmp_b: "beta" };
|
|
1976
2202
|
const deps = makeDeps({
|
|
@@ -2948,6 +3174,25 @@ function makeWatcherStub(): WatcherStub {
|
|
|
2948
3174
|
return stub;
|
|
2949
3175
|
}
|
|
2950
3176
|
|
|
3177
|
+
async function flushLoopMicrotasks(n = 8) {
|
|
3178
|
+
for (let i = 0; i < n; i++) await Promise.resolve();
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
function makeSteppableSleep() {
|
|
3182
|
+
const pending: Array<() => void> = [];
|
|
3183
|
+
return {
|
|
3184
|
+
sleep: () =>
|
|
3185
|
+
new Promise<void>((resolve) => {
|
|
3186
|
+
pending.push(resolve);
|
|
3187
|
+
}),
|
|
3188
|
+
tick() {
|
|
3189
|
+
const resolve = pending.shift();
|
|
3190
|
+
if (!resolve) throw new Error("poll sleep was not pending");
|
|
3191
|
+
resolve();
|
|
3192
|
+
},
|
|
3193
|
+
};
|
|
3194
|
+
}
|
|
3195
|
+
|
|
2951
3196
|
describe("runRunnerWithLoop — event-push wiring", () => {
|
|
2952
3197
|
it("starts the watcher alongside the poll loop when --event-push is on", async () => {
|
|
2953
3198
|
const watcher = makeWatcherStub();
|
|
@@ -2979,7 +3224,7 @@ describe("runRunnerWithLoop — event-push wiring", () => {
|
|
|
2979
3224
|
expect(watcher.disposed).toBe(true);
|
|
2980
3225
|
});
|
|
2981
3226
|
|
|
2982
|
-
it("trigger-on-change: a
|
|
3227
|
+
it("trigger-on-change: a changed signal accumulates without an immediate push", async () => {
|
|
2983
3228
|
const watcher = makeWatcherStub();
|
|
2984
3229
|
const clock = new FakeClock();
|
|
2985
3230
|
let triggerShutdown = () => {};
|
|
@@ -3010,24 +3255,13 @@ describe("runRunnerWithLoop — event-push wiring", () => {
|
|
|
3010
3255
|
await Promise.resolve();
|
|
3011
3256
|
await Promise.resolve();
|
|
3012
3257
|
|
|
3013
|
-
|
|
3014
|
-
(c[0] as string[]).includes("--company"),
|
|
3015
|
-
);
|
|
3016
|
-
expect(targetedCall).toBeDefined();
|
|
3017
|
-
expect(targetedCall![0]).toEqual([
|
|
3018
|
-
"--company",
|
|
3019
|
-
"indigo",
|
|
3020
|
-
"--direction",
|
|
3021
|
-
"push",
|
|
3022
|
-
"--hq-root",
|
|
3023
|
-
"/tmp/hq",
|
|
3024
|
-
]);
|
|
3258
|
+
expect(runPass).not.toHaveBeenCalled();
|
|
3025
3259
|
|
|
3026
3260
|
triggerShutdown();
|
|
3027
3261
|
await loop;
|
|
3028
3262
|
});
|
|
3029
3263
|
|
|
3030
|
-
it("bare signal (no path)
|
|
3264
|
+
it("bare signal (no path) accumulates without an immediate push", async () => {
|
|
3031
3265
|
const watcher = makeWatcherStub();
|
|
3032
3266
|
const clock = new FakeClock();
|
|
3033
3267
|
let triggerShutdown = () => {};
|
|
@@ -3056,19 +3290,7 @@ describe("runRunnerWithLoop — event-push wiring", () => {
|
|
|
3056
3290
|
await Promise.resolve();
|
|
3057
3291
|
await Promise.resolve();
|
|
3058
3292
|
|
|
3059
|
-
|
|
3060
|
-
(c) =>
|
|
3061
|
-
(c[0] as string[]).includes("--companies") &&
|
|
3062
|
-
(c[0] as string[]).includes("--direction"),
|
|
3063
|
-
);
|
|
3064
|
-
expect(personalCall).toBeDefined();
|
|
3065
|
-
expect(personalCall![0]).toEqual([
|
|
3066
|
-
"--companies",
|
|
3067
|
-
"--direction",
|
|
3068
|
-
"push",
|
|
3069
|
-
"--hq-root",
|
|
3070
|
-
"/tmp/hq",
|
|
3071
|
-
]);
|
|
3293
|
+
expect(runPass).not.toHaveBeenCalled();
|
|
3072
3294
|
|
|
3073
3295
|
triggerShutdown();
|
|
3074
3296
|
await loop;
|
|
@@ -3138,7 +3360,7 @@ describe("runRunnerWithLoop — event-push wiring", () => {
|
|
|
3138
3360
|
await loop;
|
|
3139
3361
|
});
|
|
3140
3362
|
|
|
3141
|
-
it("F05: guard-held watcher
|
|
3363
|
+
it("F05: guard-held watcher changes stay accumulated until a poll drain", async () => {
|
|
3142
3364
|
const watcher = makeWatcherStub();
|
|
3143
3365
|
const clock = new FakeClock();
|
|
3144
3366
|
let triggerShutdown = () => {};
|
|
@@ -3195,14 +3417,7 @@ describe("runRunnerWithLoop — event-push wiring", () => {
|
|
|
3195
3417
|
const targetedCall = runPass.mock.calls.find((c) =>
|
|
3196
3418
|
(c[0] as string[]).includes("--company"),
|
|
3197
3419
|
);
|
|
3198
|
-
expect(targetedCall
|
|
3199
|
-
"--company",
|
|
3200
|
-
"indigo",
|
|
3201
|
-
"--direction",
|
|
3202
|
-
"push",
|
|
3203
|
-
"--hq-root",
|
|
3204
|
-
"/tmp/hq",
|
|
3205
|
-
]);
|
|
3420
|
+
expect(targetedCall).toBeUndefined();
|
|
3206
3421
|
});
|
|
3207
3422
|
|
|
3208
3423
|
it("poll-still-runs: the poll loop fires passes independent of the watcher", async () => {
|
|
@@ -3307,6 +3522,39 @@ describe("runRunnerWithLoop — event-push wiring", () => {
|
|
|
3307
3522
|
expect(watcher.disposed).toBe(true);
|
|
3308
3523
|
});
|
|
3309
3524
|
|
|
3525
|
+
it("a TRANSIENT_NETWORK_EXIT poll pass does NOT surface as a crash — the loop retries (HQ-SYNC-1W)", async () => {
|
|
3526
|
+
// A transient offline blip must keep the watcher alive: the loop logs and
|
|
3527
|
+
// polls again instead of returning the code and letting the process exit
|
|
3528
|
+
// (which the menubar reports as "watcher exited unexpectedly"). A genuine
|
|
3529
|
+
// hard error on a later pass still terminates the loop.
|
|
3530
|
+
const watcher = makeWatcherStub();
|
|
3531
|
+
let n = 0;
|
|
3532
|
+
const runPass = vi.fn().mockImplementation(async () => {
|
|
3533
|
+
n++;
|
|
3534
|
+
// First pass: machine is offline (transient). Second pass: a real hard
|
|
3535
|
+
// error, used here only to end the otherwise-infinite loop deterministically.
|
|
3536
|
+
return n === 1 ? TRANSIENT_NETWORK_EXIT : 2;
|
|
3537
|
+
});
|
|
3538
|
+
|
|
3539
|
+
const code = await runRunnerWithLoop(
|
|
3540
|
+
["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"],
|
|
3541
|
+
{
|
|
3542
|
+
runPass,
|
|
3543
|
+
clock: new FakeClock(),
|
|
3544
|
+
createWatcher: () => watcher,
|
|
3545
|
+
sleep: () => Promise.resolve(),
|
|
3546
|
+
onShutdownSignal: () => () => {},
|
|
3547
|
+
},
|
|
3548
|
+
);
|
|
3549
|
+
|
|
3550
|
+
// The transient pass did NOT end the loop — it ran again…
|
|
3551
|
+
expect(runPass).toHaveBeenCalledTimes(2);
|
|
3552
|
+
// …and the transient code was never surfaced as the watcher's exit code.
|
|
3553
|
+
expect(code).toBe(2);
|
|
3554
|
+
expect(code).not.toBe(TRANSIENT_NETWORK_EXIT);
|
|
3555
|
+
expect(watcher.disposed).toBe(true);
|
|
3556
|
+
});
|
|
3557
|
+
|
|
3310
3558
|
it("without --event-push, no watcher is created (poll-only safety net)", async () => {
|
|
3311
3559
|
let triggerShutdown = () => {};
|
|
3312
3560
|
const createWatcher = vi.fn(() => makeWatcherStub());
|
|
@@ -3331,6 +3579,419 @@ describe("runRunnerWithLoop — event-push wiring", () => {
|
|
|
3331
3579
|
});
|
|
3332
3580
|
});
|
|
3333
3581
|
|
|
3582
|
+
describe("runRunnerWithLoop — accumulate-and-drain scoped push", () => {
|
|
3583
|
+
const watchArgv = [
|
|
3584
|
+
"--companies",
|
|
3585
|
+
"--watch",
|
|
3586
|
+
"--event-push",
|
|
3587
|
+
"--direction",
|
|
3588
|
+
"both",
|
|
3589
|
+
"--hq-root",
|
|
3590
|
+
"/tmp/hq",
|
|
3591
|
+
"--poll-remote-ms",
|
|
3592
|
+
"60000",
|
|
3593
|
+
];
|
|
3594
|
+
const fullArgv = ["--companies", "--direction", "both", "--hq-root", "/tmp/hq"];
|
|
3595
|
+
const fullPullArgv = [
|
|
3596
|
+
"--companies",
|
|
3597
|
+
"--direction",
|
|
3598
|
+
"pull",
|
|
3599
|
+
"--hq-root",
|
|
3600
|
+
"/tmp/hq",
|
|
3601
|
+
];
|
|
3602
|
+
|
|
3603
|
+
function startDrainLoop(runPass: ReturnType<typeof vi.fn>) {
|
|
3604
|
+
return startDrainLoopWithArgv(runPass, watchArgv);
|
|
3605
|
+
}
|
|
3606
|
+
|
|
3607
|
+
function startDrainLoopWithArgv(
|
|
3608
|
+
runPass: ReturnType<typeof vi.fn>,
|
|
3609
|
+
argv: string[],
|
|
3610
|
+
) {
|
|
3611
|
+
const watcher = makeWatcherStub();
|
|
3612
|
+
const sleep = makeSteppableSleep();
|
|
3613
|
+
let triggerShutdown = () => {};
|
|
3614
|
+
const loop = runRunnerWithLoop(argv, {
|
|
3615
|
+
runPass,
|
|
3616
|
+
clock: new FakeClock(),
|
|
3617
|
+
createWatcher: () => watcher,
|
|
3618
|
+
sleep: sleep.sleep,
|
|
3619
|
+
onShutdownSignal: (handler) => {
|
|
3620
|
+
triggerShutdown = handler;
|
|
3621
|
+
return () => {};
|
|
3622
|
+
},
|
|
3623
|
+
});
|
|
3624
|
+
return {
|
|
3625
|
+
loop,
|
|
3626
|
+
watcher,
|
|
3627
|
+
tick: sleep.tick,
|
|
3628
|
+
shutdown: () => triggerShutdown(),
|
|
3629
|
+
};
|
|
3630
|
+
}
|
|
3631
|
+
|
|
3632
|
+
it("startup runs one full reconcile, then watcher edits wait for the poll drain", async () => {
|
|
3633
|
+
const runPass = vi.fn().mockResolvedValue(0);
|
|
3634
|
+
const { loop, watcher, tick, shutdown } = startDrainLoop(runPass);
|
|
3635
|
+
await flushLoopMicrotasks();
|
|
3636
|
+
|
|
3637
|
+
expect(runPass.mock.calls.map((call) => call[0])).toEqual([fullArgv]);
|
|
3638
|
+
runPass.mockClear();
|
|
3639
|
+
|
|
3640
|
+
watcher.emit("companies/indigo/knowledge/a.md");
|
|
3641
|
+
await flushLoopMicrotasks();
|
|
3642
|
+
expect(runPass).not.toHaveBeenCalled();
|
|
3643
|
+
|
|
3644
|
+
tick();
|
|
3645
|
+
await flushLoopMicrotasks();
|
|
3646
|
+
|
|
3647
|
+
expect(runPass.mock.calls.map((call) => call[0])).toEqual([
|
|
3648
|
+
[
|
|
3649
|
+
"--company",
|
|
3650
|
+
"indigo",
|
|
3651
|
+
"--direction",
|
|
3652
|
+
"push",
|
|
3653
|
+
"--scope-path",
|
|
3654
|
+
"knowledge/a.md",
|
|
3655
|
+
"--hq-root",
|
|
3656
|
+
"/tmp/hq",
|
|
3657
|
+
],
|
|
3658
|
+
fullPullArgv,
|
|
3659
|
+
]);
|
|
3660
|
+
|
|
3661
|
+
shutdown();
|
|
3662
|
+
tick();
|
|
3663
|
+
await loop;
|
|
3664
|
+
});
|
|
3665
|
+
|
|
3666
|
+
it("coalesces repeated edits to the same path into one scoped push", async () => {
|
|
3667
|
+
const runPass = vi.fn().mockResolvedValue(0);
|
|
3668
|
+
const { loop, watcher, tick, shutdown } = startDrainLoop(runPass);
|
|
3669
|
+
await flushLoopMicrotasks();
|
|
3670
|
+
runPass.mockClear();
|
|
3671
|
+
|
|
3672
|
+
watcher.emit("companies/indigo/knowledge/a.md");
|
|
3673
|
+
watcher.emit("companies/indigo/knowledge/a.md");
|
|
3674
|
+
tick();
|
|
3675
|
+
await flushLoopMicrotasks();
|
|
3676
|
+
|
|
3677
|
+
const pushCalls = runPass.mock.calls
|
|
3678
|
+
.map((call) => call[0] as string[])
|
|
3679
|
+
.filter((argv) => argv.includes("--company"));
|
|
3680
|
+
expect(pushCalls).toEqual([
|
|
3681
|
+
[
|
|
3682
|
+
"--company",
|
|
3683
|
+
"indigo",
|
|
3684
|
+
"--direction",
|
|
3685
|
+
"push",
|
|
3686
|
+
"--scope-path",
|
|
3687
|
+
"knowledge/a.md",
|
|
3688
|
+
"--hq-root",
|
|
3689
|
+
"/tmp/hq",
|
|
3690
|
+
],
|
|
3691
|
+
]);
|
|
3692
|
+
|
|
3693
|
+
shutdown();
|
|
3694
|
+
tick();
|
|
3695
|
+
await loop;
|
|
3696
|
+
});
|
|
3697
|
+
|
|
3698
|
+
it("drains an unlink path through the same scoped tombstone push path", async () => {
|
|
3699
|
+
const runPass = vi.fn().mockResolvedValue(0);
|
|
3700
|
+
const { loop, watcher, tick, shutdown } = startDrainLoop(runPass);
|
|
3701
|
+
await flushLoopMicrotasks();
|
|
3702
|
+
runPass.mockClear();
|
|
3703
|
+
|
|
3704
|
+
watcher.emit("companies/indigo/knowledge/deleted.md");
|
|
3705
|
+
tick();
|
|
3706
|
+
await flushLoopMicrotasks();
|
|
3707
|
+
|
|
3708
|
+
expect(runPass.mock.calls[0][0]).toEqual([
|
|
3709
|
+
"--company",
|
|
3710
|
+
"indigo",
|
|
3711
|
+
"--direction",
|
|
3712
|
+
"push",
|
|
3713
|
+
"--scope-path",
|
|
3714
|
+
"knowledge/deleted.md",
|
|
3715
|
+
"--hq-root",
|
|
3716
|
+
"/tmp/hq",
|
|
3717
|
+
]);
|
|
3718
|
+
|
|
3719
|
+
shutdown();
|
|
3720
|
+
tick();
|
|
3721
|
+
await loop;
|
|
3722
|
+
});
|
|
3723
|
+
|
|
3724
|
+
it("omitted --direction is pull-only (parser default): a scoped tick never pushes", async () => {
|
|
3725
|
+
const runPass = vi.fn().mockResolvedValue(0);
|
|
3726
|
+
// No --direction flag → parseArgs defaults the run to pull-only. The drain
|
|
3727
|
+
// must honor that and NOT run a scoped push. Regression: the loop used to
|
|
3728
|
+
// re-parse argv and wrongly default an omitted direction to "both", so a
|
|
3729
|
+
// pull-only watcher would push local edits it should never send.
|
|
3730
|
+
const pullOnlyArgv = [
|
|
3731
|
+
"--companies",
|
|
3732
|
+
"--watch",
|
|
3733
|
+
"--event-push",
|
|
3734
|
+
"--hq-root",
|
|
3735
|
+
"/tmp/hq",
|
|
3736
|
+
"--poll-remote-ms",
|
|
3737
|
+
"60000",
|
|
3738
|
+
];
|
|
3739
|
+
const { loop, watcher, tick, shutdown } = startDrainLoopWithArgv(
|
|
3740
|
+
runPass,
|
|
3741
|
+
pullOnlyArgv,
|
|
3742
|
+
);
|
|
3743
|
+
await flushLoopMicrotasks();
|
|
3744
|
+
runPass.mockClear();
|
|
3745
|
+
|
|
3746
|
+
watcher.emit("companies/indigo/knowledge/a.md");
|
|
3747
|
+
tick();
|
|
3748
|
+
await flushLoopMicrotasks();
|
|
3749
|
+
|
|
3750
|
+
const calls = runPass.mock.calls.map((call) => call[0] as string[]);
|
|
3751
|
+
// No scoped push of any kind ran...
|
|
3752
|
+
expect(calls.some((argv) => argv.includes("--company"))).toBe(false);
|
|
3753
|
+
expect(
|
|
3754
|
+
calls.some(
|
|
3755
|
+
(argv) =>
|
|
3756
|
+
argv.includes("--direction") &&
|
|
3757
|
+
argv[argv.indexOf("--direction") + 1] === "push",
|
|
3758
|
+
),
|
|
3759
|
+
).toBe(false);
|
|
3760
|
+
// ...only the pull leg ran.
|
|
3761
|
+
expect(calls).toEqual([fullPullArgv]);
|
|
3762
|
+
|
|
3763
|
+
shutdown();
|
|
3764
|
+
tick();
|
|
3765
|
+
await loop;
|
|
3766
|
+
});
|
|
3767
|
+
|
|
3768
|
+
it("restores failed scoped push paths so the next tick retries them", async () => {
|
|
3769
|
+
const runPass = vi
|
|
3770
|
+
.fn()
|
|
3771
|
+
.mockResolvedValueOnce(0)
|
|
3772
|
+
.mockResolvedValueOnce(1)
|
|
3773
|
+
.mockResolvedValueOnce(0)
|
|
3774
|
+
.mockResolvedValueOnce(0)
|
|
3775
|
+
.mockResolvedValueOnce(0);
|
|
3776
|
+
const { loop, watcher, tick, shutdown } = startDrainLoop(runPass);
|
|
3777
|
+
await flushLoopMicrotasks();
|
|
3778
|
+
runPass.mockClear();
|
|
3779
|
+
|
|
3780
|
+
watcher.emit("companies/indigo/knowledge/retry.md");
|
|
3781
|
+
tick();
|
|
3782
|
+
await flushLoopMicrotasks();
|
|
3783
|
+
tick();
|
|
3784
|
+
await flushLoopMicrotasks();
|
|
3785
|
+
|
|
3786
|
+
const pushCalls = runPass.mock.calls
|
|
3787
|
+
.map((call) => call[0] as string[])
|
|
3788
|
+
.filter((argv) => argv.includes("--company"));
|
|
3789
|
+
expect(pushCalls).toEqual([
|
|
3790
|
+
[
|
|
3791
|
+
"--company",
|
|
3792
|
+
"indigo",
|
|
3793
|
+
"--direction",
|
|
3794
|
+
"push",
|
|
3795
|
+
"--scope-path",
|
|
3796
|
+
"knowledge/retry.md",
|
|
3797
|
+
"--hq-root",
|
|
3798
|
+
"/tmp/hq",
|
|
3799
|
+
],
|
|
3800
|
+
[
|
|
3801
|
+
"--company",
|
|
3802
|
+
"indigo",
|
|
3803
|
+
"--direction",
|
|
3804
|
+
"push",
|
|
3805
|
+
"--scope-path",
|
|
3806
|
+
"knowledge/retry.md",
|
|
3807
|
+
"--hq-root",
|
|
3808
|
+
"/tmp/hq",
|
|
3809
|
+
],
|
|
3810
|
+
]);
|
|
3811
|
+
|
|
3812
|
+
shutdown();
|
|
3813
|
+
tick();
|
|
3814
|
+
await loop;
|
|
3815
|
+
});
|
|
3816
|
+
|
|
3817
|
+
it("empty scoped ticks run only the pull leg", async () => {
|
|
3818
|
+
const runPass = vi.fn().mockResolvedValue(0);
|
|
3819
|
+
const { loop, tick, shutdown } = startDrainLoop(runPass);
|
|
3820
|
+
await flushLoopMicrotasks();
|
|
3821
|
+
runPass.mockClear();
|
|
3822
|
+
|
|
3823
|
+
tick();
|
|
3824
|
+
await flushLoopMicrotasks();
|
|
3825
|
+
|
|
3826
|
+
expect(runPass.mock.calls.map((call) => call[0])).toEqual([fullPullArgv]);
|
|
3827
|
+
|
|
3828
|
+
shutdown();
|
|
3829
|
+
tick();
|
|
3830
|
+
await loop;
|
|
3831
|
+
});
|
|
3832
|
+
|
|
3833
|
+
it("personal scoped ticks preserve personal mode for the pull leg", async () => {
|
|
3834
|
+
const runPass = vi.fn().mockResolvedValue(0);
|
|
3835
|
+
const { loop, watcher, tick, shutdown } = startDrainLoopWithArgv(runPass, [
|
|
3836
|
+
"--personal",
|
|
3837
|
+
"--watch",
|
|
3838
|
+
"--event-push",
|
|
3839
|
+
"--direction",
|
|
3840
|
+
"both",
|
|
3841
|
+
"--hq-root",
|
|
3842
|
+
"/tmp/hq",
|
|
3843
|
+
"--poll-remote-ms",
|
|
3844
|
+
"60000",
|
|
3845
|
+
]);
|
|
3846
|
+
await flushLoopMicrotasks();
|
|
3847
|
+
runPass.mockClear();
|
|
3848
|
+
|
|
3849
|
+
watcher.emit("knowledge/a.md");
|
|
3850
|
+
tick();
|
|
3851
|
+
await flushLoopMicrotasks();
|
|
3852
|
+
|
|
3853
|
+
expect(runPass.mock.calls.map((call) => call[0])).toEqual([
|
|
3854
|
+
[
|
|
3855
|
+
"--personal",
|
|
3856
|
+
"--direction",
|
|
3857
|
+
"push",
|
|
3858
|
+
"--scope-path",
|
|
3859
|
+
"knowledge/a.md",
|
|
3860
|
+
"--hq-root",
|
|
3861
|
+
"/tmp/hq",
|
|
3862
|
+
],
|
|
3863
|
+
["--personal", "--direction", "pull", "--hq-root", "/tmp/hq"],
|
|
3864
|
+
]);
|
|
3865
|
+
|
|
3866
|
+
shutdown();
|
|
3867
|
+
tick();
|
|
3868
|
+
await loop;
|
|
3869
|
+
});
|
|
3870
|
+
|
|
3871
|
+
it("single-company scoped ticks preserve company mode for the pull leg", async () => {
|
|
3872
|
+
const runPass = vi.fn().mockResolvedValue(0);
|
|
3873
|
+
const { loop, watcher, tick, shutdown } = startDrainLoopWithArgv(runPass, [
|
|
3874
|
+
"--company",
|
|
3875
|
+
"acme",
|
|
3876
|
+
"--watch",
|
|
3877
|
+
"--event-push",
|
|
3878
|
+
"--direction",
|
|
3879
|
+
"both",
|
|
3880
|
+
"--hq-root",
|
|
3881
|
+
"/tmp/hq",
|
|
3882
|
+
"--poll-remote-ms",
|
|
3883
|
+
"60000",
|
|
3884
|
+
]);
|
|
3885
|
+
await flushLoopMicrotasks();
|
|
3886
|
+
runPass.mockClear();
|
|
3887
|
+
|
|
3888
|
+
watcher.emit("companies/acme/knowledge/a.md");
|
|
3889
|
+
tick();
|
|
3890
|
+
await flushLoopMicrotasks();
|
|
3891
|
+
|
|
3892
|
+
expect(runPass.mock.calls.map((call) => call[0])).toEqual([
|
|
3893
|
+
[
|
|
3894
|
+
"--company",
|
|
3895
|
+
"acme",
|
|
3896
|
+
"--direction",
|
|
3897
|
+
"push",
|
|
3898
|
+
"--scope-path",
|
|
3899
|
+
"knowledge/a.md",
|
|
3900
|
+
"--hq-root",
|
|
3901
|
+
"/tmp/hq",
|
|
3902
|
+
],
|
|
3903
|
+
["--company", "acme", "--direction", "pull", "--hq-root", "/tmp/hq"],
|
|
3904
|
+
]);
|
|
3905
|
+
|
|
3906
|
+
shutdown();
|
|
3907
|
+
tick();
|
|
3908
|
+
await loop;
|
|
3909
|
+
});
|
|
3910
|
+
|
|
3911
|
+
it("push-only scoped ticks run the scoped push and no pull leg", async () => {
|
|
3912
|
+
const runPass = vi.fn().mockResolvedValue(0);
|
|
3913
|
+
const { loop, watcher, tick, shutdown } = startDrainLoopWithArgv(runPass, [
|
|
3914
|
+
"--companies",
|
|
3915
|
+
"--watch",
|
|
3916
|
+
"--event-push",
|
|
3917
|
+
"--direction",
|
|
3918
|
+
"push",
|
|
3919
|
+
"--hq-root",
|
|
3920
|
+
"/tmp/hq",
|
|
3921
|
+
"--poll-remote-ms",
|
|
3922
|
+
"60000",
|
|
3923
|
+
]);
|
|
3924
|
+
await flushLoopMicrotasks();
|
|
3925
|
+
runPass.mockClear();
|
|
3926
|
+
|
|
3927
|
+
watcher.emit("companies/indigo/knowledge/a.md");
|
|
3928
|
+
tick();
|
|
3929
|
+
await flushLoopMicrotasks();
|
|
3930
|
+
|
|
3931
|
+
expect(runPass.mock.calls.map((call) => call[0])).toEqual([
|
|
3932
|
+
[
|
|
3933
|
+
"--company",
|
|
3934
|
+
"indigo",
|
|
3935
|
+
"--direction",
|
|
3936
|
+
"push",
|
|
3937
|
+
"--scope-path",
|
|
3938
|
+
"knowledge/a.md",
|
|
3939
|
+
"--hq-root",
|
|
3940
|
+
"/tmp/hq",
|
|
3941
|
+
],
|
|
3942
|
+
]);
|
|
3943
|
+
|
|
3944
|
+
shutdown();
|
|
3945
|
+
tick();
|
|
3946
|
+
await loop;
|
|
3947
|
+
});
|
|
3948
|
+
|
|
3949
|
+
it("overflow and every tenth tick run a full unscoped reconcile", async () => {
|
|
3950
|
+
const watcher = makeBatchWatcherStub();
|
|
3951
|
+
const sleep = makeSteppableSleep();
|
|
3952
|
+
let triggerShutdown = () => {};
|
|
3953
|
+
const runPass = vi.fn().mockResolvedValue(0);
|
|
3954
|
+
const loop = runRunnerWithLoop(watchArgv, {
|
|
3955
|
+
runPass,
|
|
3956
|
+
clock: new FakeClock(),
|
|
3957
|
+
createWatcher: () => watcher,
|
|
3958
|
+
sleep: sleep.sleep,
|
|
3959
|
+
onShutdownSignal: (handler) => {
|
|
3960
|
+
triggerShutdown = handler;
|
|
3961
|
+
return () => {};
|
|
3962
|
+
},
|
|
3963
|
+
});
|
|
3964
|
+
await flushLoopMicrotasks();
|
|
3965
|
+
runPass.mockClear();
|
|
3966
|
+
|
|
3967
|
+
watcher.emit("companies/indigo/a.md", {
|
|
3968
|
+
paths: new Map([["/tmp/hq/companies/indigo/a.md", "companies/indigo/a.md"]]),
|
|
3969
|
+
overflowed: true,
|
|
3970
|
+
} as never);
|
|
3971
|
+
sleep.tick();
|
|
3972
|
+
await flushLoopMicrotasks();
|
|
3973
|
+
expect(runPass.mock.calls.map((call) => call[0])).toEqual([fullArgv]);
|
|
3974
|
+
|
|
3975
|
+
runPass.mockClear();
|
|
3976
|
+
for (let i = 0; i < 7; i++) {
|
|
3977
|
+
sleep.tick();
|
|
3978
|
+
await flushLoopMicrotasks();
|
|
3979
|
+
}
|
|
3980
|
+
expect(runPass.mock.calls.map((call) => call[0])).toEqual(
|
|
3981
|
+
Array.from({ length: 7 }, () => fullPullArgv),
|
|
3982
|
+
);
|
|
3983
|
+
|
|
3984
|
+
runPass.mockClear();
|
|
3985
|
+
sleep.tick();
|
|
3986
|
+
await flushLoopMicrotasks();
|
|
3987
|
+
expect(runPass.mock.calls.map((call) => call[0])).toEqual([fullArgv]);
|
|
3988
|
+
|
|
3989
|
+
triggerShutdown();
|
|
3990
|
+
sleep.tick();
|
|
3991
|
+
await loop;
|
|
3992
|
+
});
|
|
3993
|
+
});
|
|
3994
|
+
|
|
3334
3995
|
// ---------------------------------------------------------------------------
|
|
3335
3996
|
// Re-initialize for each test (mock state hygiene)
|
|
3336
3997
|
// ---------------------------------------------------------------------------
|
|
@@ -4226,14 +4887,26 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
4226
4887
|
const watcher = opts.watcher ?? makeBatchWatcherStub();
|
|
4227
4888
|
const runPass = opts.runPass ?? vi.fn().mockResolvedValue(0);
|
|
4228
4889
|
const clock = new FakeClock();
|
|
4890
|
+
const sleep = makeSteppableSleep();
|
|
4229
4891
|
let triggerShutdown = () => {};
|
|
4230
4892
|
const loop = runRunnerWithLoop(
|
|
4231
|
-
|
|
4893
|
+
// `--direction both` so the push leg (and therefore the publish-after-push
|
|
4894
|
+
// wiring these tests exercise) actually runs. Omitting --direction is a
|
|
4895
|
+
// pull-only run by the parser default, which correctly never pushes.
|
|
4896
|
+
[
|
|
4897
|
+
"--companies",
|
|
4898
|
+
"--watch",
|
|
4899
|
+
"--event-push",
|
|
4900
|
+
"--direction",
|
|
4901
|
+
"both",
|
|
4902
|
+
"--hq-root",
|
|
4903
|
+
"/tmp/hq",
|
|
4904
|
+
],
|
|
4232
4905
|
{
|
|
4233
4906
|
runPass,
|
|
4234
4907
|
clock,
|
|
4235
4908
|
createWatcher: () => watcher,
|
|
4236
|
-
sleep:
|
|
4909
|
+
sleep: sleep.sleep,
|
|
4237
4910
|
onShutdownSignal: (handler) => {
|
|
4238
4911
|
triggerShutdown = handler;
|
|
4239
4912
|
return () => {};
|
|
@@ -4244,7 +4917,14 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
4244
4917
|
vi.fn().mockResolvedValue(null)) as never,
|
|
4245
4918
|
},
|
|
4246
4919
|
);
|
|
4247
|
-
return {
|
|
4920
|
+
return {
|
|
4921
|
+
loop,
|
|
4922
|
+
watcher,
|
|
4923
|
+
runPass,
|
|
4924
|
+
clock,
|
|
4925
|
+
tick: sleep.tick,
|
|
4926
|
+
shutdown: () => triggerShutdown(),
|
|
4927
|
+
};
|
|
4248
4928
|
}
|
|
4249
4929
|
|
|
4250
4930
|
async function microtasks(n = 6) {
|
|
@@ -4300,7 +4980,7 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
4300
4980
|
dispose: vi.fn().mockResolvedValue(undefined),
|
|
4301
4981
|
});
|
|
4302
4982
|
const runPass = vi.fn().mockResolvedValue(0);
|
|
4303
|
-
const { loop, watcher, clock, shutdown } = runLoop({
|
|
4983
|
+
const { loop, watcher, clock, tick, shutdown } = runLoop({
|
|
4304
4984
|
claims: ENROLLED_CLAIMS,
|
|
4305
4985
|
startEventSync,
|
|
4306
4986
|
runPass,
|
|
@@ -4314,11 +4994,18 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
4314
4994
|
watcher.emit("companies/indigo/a.md", batch);
|
|
4315
4995
|
clock.advance(0);
|
|
4316
4996
|
await microtasks();
|
|
4997
|
+
expect(runPass).not.toHaveBeenCalled();
|
|
4998
|
+
expect(publishBatch).not.toHaveBeenCalled();
|
|
4317
4999
|
|
|
4318
|
-
|
|
5000
|
+
tick();
|
|
5001
|
+
await microtasks();
|
|
5002
|
+
|
|
5003
|
+
// Scoped push drained and succeeded → batch published.
|
|
4319
5004
|
expect(runPass).toHaveBeenCalled();
|
|
4320
5005
|
expect(publishBatch).toHaveBeenCalledTimes(1);
|
|
4321
|
-
expect(publishBatch.mock.calls[0][0]).
|
|
5006
|
+
expect([...publishBatch.mock.calls[0][0].paths.values()]).toEqual([
|
|
5007
|
+
"companies/indigo/a.md",
|
|
5008
|
+
]);
|
|
4322
5009
|
|
|
4323
5010
|
shutdown();
|
|
4324
5011
|
await loop;
|
|
@@ -4337,7 +5024,7 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
4337
5024
|
.fn()
|
|
4338
5025
|
.mockResolvedValueOnce(0)
|
|
4339
5026
|
.mockResolvedValue(1);
|
|
4340
|
-
const { loop, watcher, clock, shutdown } = runLoop({
|
|
5027
|
+
const { loop, watcher, clock, tick, shutdown } = runLoop({
|
|
4341
5028
|
claims: ENROLLED_CLAIMS,
|
|
4342
5029
|
startEventSync,
|
|
4343
5030
|
runPass,
|
|
@@ -4350,6 +5037,8 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
4350
5037
|
);
|
|
4351
5038
|
clock.advance(0);
|
|
4352
5039
|
await microtasks();
|
|
5040
|
+
tick();
|
|
5041
|
+
await microtasks();
|
|
4353
5042
|
|
|
4354
5043
|
expect(publishBatch).not.toHaveBeenCalled();
|
|
4355
5044
|
shutdown();
|
|
@@ -4365,7 +5054,7 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
4365
5054
|
dispose: vi.fn().mockResolvedValue(undefined),
|
|
4366
5055
|
});
|
|
4367
5056
|
const runPass = vi.fn().mockResolvedValue(0);
|
|
4368
|
-
const { loop, watcher, clock, shutdown } = runLoop({
|
|
5057
|
+
const { loop, watcher, clock, tick, shutdown } = runLoop({
|
|
4369
5058
|
claims: ENROLLED_CLAIMS,
|
|
4370
5059
|
startEventSync,
|
|
4371
5060
|
runPass,
|
|
@@ -4382,6 +5071,8 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
4382
5071
|
});
|
|
4383
5072
|
clock.advance(0);
|
|
4384
5073
|
await microtasks();
|
|
5074
|
+
tick();
|
|
5075
|
+
await microtasks();
|
|
4385
5076
|
|
|
4386
5077
|
const pushedRoutes = new Set<string>();
|
|
4387
5078
|
let pushedEverything = false;
|
|
@@ -4392,6 +5083,8 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
4392
5083
|
const companyIdx = passArgv.indexOf("--company");
|
|
4393
5084
|
if (companyIdx >= 0) {
|
|
4394
5085
|
pushedRoutes.add(`company:${passArgv[companyIdx + 1]}`);
|
|
5086
|
+
} else if (passArgv.includes("--personal")) {
|
|
5087
|
+
pushedRoutes.add("personal");
|
|
4395
5088
|
} else if (passArgv.includes("--companies")) {
|
|
4396
5089
|
pushedEverything = true;
|
|
4397
5090
|
}
|
|
@@ -4433,6 +5126,7 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
4433
5126
|
| null = null;
|
|
4434
5127
|
const watcher = makeBatchWatcherStub();
|
|
4435
5128
|
const clock = new FakeClock();
|
|
5129
|
+
const sleep = makeSteppableSleep();
|
|
4436
5130
|
let triggerShutdown = () => {};
|
|
4437
5131
|
let releasePoll: () => void = () => {};
|
|
4438
5132
|
const pollGate = new Promise<void>((resolve) => {
|
|
@@ -4446,7 +5140,17 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
4446
5140
|
});
|
|
4447
5141
|
|
|
4448
5142
|
const loop = runRunnerWithLoop(
|
|
4449
|
-
|
|
5143
|
+
// `--direction both` so the queued watcher pushes actually run (an omitted
|
|
5144
|
+
// direction is pull-only by the parser default).
|
|
5145
|
+
[
|
|
5146
|
+
"--companies",
|
|
5147
|
+
"--watch",
|
|
5148
|
+
"--event-push",
|
|
5149
|
+
"--direction",
|
|
5150
|
+
"both",
|
|
5151
|
+
"--hq-root",
|
|
5152
|
+
"/tmp/hq",
|
|
5153
|
+
],
|
|
4450
5154
|
{
|
|
4451
5155
|
runPass,
|
|
4452
5156
|
clock,
|
|
@@ -4459,7 +5163,7 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
4459
5163
|
dispose: async () => {},
|
|
4460
5164
|
};
|
|
4461
5165
|
},
|
|
4462
|
-
sleep:
|
|
5166
|
+
sleep: sleep.sleep,
|
|
4463
5167
|
onShutdownSignal: (handler) => {
|
|
4464
5168
|
triggerShutdown = handler;
|
|
4465
5169
|
return () => {};
|
|
@@ -4508,14 +5212,32 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
4508
5212
|
releasePoll();
|
|
4509
5213
|
await remotePull;
|
|
4510
5214
|
await microtasks();
|
|
4511
|
-
|
|
5215
|
+
sleep.tick();
|
|
4512
5216
|
await microtasks();
|
|
4513
5217
|
|
|
4514
5218
|
const passArgvs = runPass.mock.calls.map((call) => call[0] as string[]);
|
|
4515
5219
|
expect(passArgvs).toEqual(
|
|
4516
5220
|
expect.arrayContaining([
|
|
4517
|
-
[
|
|
4518
|
-
|
|
5221
|
+
[
|
|
5222
|
+
"--company",
|
|
5223
|
+
"indigo",
|
|
5224
|
+
"--direction",
|
|
5225
|
+
"push",
|
|
5226
|
+
"--scope-path",
|
|
5227
|
+
"a.md",
|
|
5228
|
+
"--hq-root",
|
|
5229
|
+
"/tmp/hq",
|
|
5230
|
+
],
|
|
5231
|
+
[
|
|
5232
|
+
"--company",
|
|
5233
|
+
"beta",
|
|
5234
|
+
"--direction",
|
|
5235
|
+
"push",
|
|
5236
|
+
"--scope-path",
|
|
5237
|
+
"b.md",
|
|
5238
|
+
"--hq-root",
|
|
5239
|
+
"/tmp/hq",
|
|
5240
|
+
],
|
|
4519
5241
|
["--company", "acme", "--direction", "pull", "--hq-root", "/tmp/hq"],
|
|
4520
5242
|
]),
|
|
4521
5243
|
);
|
|
@@ -4556,7 +5278,7 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
4556
5278
|
ownDeviceId: "dev-test",
|
|
4557
5279
|
dispose: vi.fn().mockResolvedValue(undefined),
|
|
4558
5280
|
});
|
|
4559
|
-
const { loop, watcher, clock, shutdown } = runLoop({
|
|
5281
|
+
const { loop, watcher, clock, tick, shutdown } = runLoop({
|
|
4560
5282
|
claims: ENROLLED_CLAIMS,
|
|
4561
5283
|
startEventSync,
|
|
4562
5284
|
});
|
|
@@ -4565,6 +5287,8 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
4565
5287
|
watcher.emit("companies/indigo/b.md"); // no batch
|
|
4566
5288
|
clock.advance(0);
|
|
4567
5289
|
await microtasks();
|
|
5290
|
+
tick();
|
|
5291
|
+
await microtasks();
|
|
4568
5292
|
|
|
4569
5293
|
expect(publishBatch).toHaveBeenCalledTimes(1);
|
|
4570
5294
|
const synthesized = publishBatch.mock.calls[0][0] as {
|