@indigoai-us/hq-cloud 6.11.10 → 6.11.12
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 +2 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +231 -52
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +330 -11
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +16 -1
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +39 -1
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/rescue-classify-ordering.test.js +58 -0
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/cli/rescue-core.js +229 -15
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-exec-bit-preserve.test.d.ts +2 -0
- package/dist/cli/rescue-exec-bit-preserve.test.d.ts.map +1 -0
- package/dist/cli/rescue-exec-bit-preserve.test.js +169 -0
- package/dist/cli/rescue-exec-bit-preserve.test.js.map +1 -0
- package/dist/cli/share.d.ts +2 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +100 -32
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +30 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +28 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +188 -59
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +487 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +55 -10
- package/dist/cognito-auth.js.map +1 -1
- package/dist/cognito-auth.test.js +61 -0
- package/dist/cognito-auth.test.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +93 -6
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +59 -0
- package/dist/journal.test.js.map +1 -1
- package/dist/machine-auth.test.js +60 -2
- package/dist/machine-auth.test.js.map +1 -1
- package/dist/object-io.d.ts +37 -1
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +148 -29
- package/dist/object-io.js.map +1 -1
- package/dist/object-io.test.js +121 -0
- package/dist/object-io.test.js.map +1 -1
- package/dist/operation-lock.d.ts +8 -8
- package/dist/operation-lock.d.ts.map +1 -1
- package/dist/operation-lock.js +99 -32
- package/dist/operation-lock.js.map +1 -1
- package/dist/operation-lock.test.js +51 -4
- package/dist/operation-lock.test.js.map +1 -1
- package/dist/personal-vault.d.ts +8 -0
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +17 -3
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.js +34 -0
- package/dist/personal-vault.test.js.map +1 -1
- package/dist/prefix-coalesce.d.ts +20 -9
- package/dist/prefix-coalesce.d.ts.map +1 -1
- package/dist/prefix-coalesce.js +124 -28
- package/dist/prefix-coalesce.js.map +1 -1
- package/dist/prefix-coalesce.test.js +57 -2
- package/dist/prefix-coalesce.test.js.map +1 -1
- package/dist/remote-pull.d.ts +6 -1
- package/dist/remote-pull.d.ts.map +1 -1
- package/dist/remote-pull.js +62 -13
- package/dist/remote-pull.js.map +1 -1
- package/dist/remote-pull.test.js +189 -0
- package/dist/remote-pull.test.js.map +1 -1
- package/dist/s3.d.ts +2 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +197 -116
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +109 -0
- package/dist/s3.test.js.map +1 -1
- package/dist/scope-shrink.d.ts +3 -2
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +1 -1
- package/dist/scope-shrink.js.map +1 -1
- package/dist/skill-telemetry.d.ts +1 -1
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +69 -9
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +86 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/event-sync.d.ts +6 -0
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +34 -1
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +73 -0
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/metrics.d.ts +17 -1
- package/dist/sync/metrics.d.ts.map +1 -1
- package/dist/sync/metrics.js +32 -1
- package/dist/sync/metrics.js.map +1 -1
- package/dist/sync/metrics.test.js +74 -1
- package/dist/sync/metrics.test.js.map +1 -1
- package/dist/sync/pull-scope.d.ts.map +1 -1
- package/dist/sync/pull-scope.js +15 -7
- package/dist/sync/pull-scope.js.map +1 -1
- package/dist/sync/push-receiver.d.ts +6 -5
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +13 -15
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +36 -1
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/telemetry.d.ts +1 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +59 -6
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +74 -0
- package/dist/telemetry.test.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/watcher.d.ts +36 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +152 -30
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +103 -0
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +396 -11
- package/src/bin/sync-runner.ts +254 -52
- package/src/cli/reindex.test.ts +47 -1
- package/src/cli/reindex.ts +17 -1
- package/src/cli/rescue-classify-ordering.test.ts +61 -0
- package/src/cli/rescue-core.ts +261 -15
- package/src/cli/rescue-exec-bit-preserve.test.ts +187 -0
- package/src/cli/share.test.ts +38 -0
- package/src/cli/share.ts +103 -34
- package/src/cli/sync.test.ts +594 -1
- package/src/cli/sync.ts +229 -65
- package/src/cognito-auth.test.ts +77 -0
- package/src/cognito-auth.ts +73 -11
- package/src/index.ts +8 -0
- package/src/journal.test.ts +72 -0
- package/src/journal.ts +95 -8
- package/src/machine-auth.test.ts +64 -2
- package/src/object-io.test.ts +142 -0
- package/src/object-io.ts +182 -30
- package/src/operation-lock.test.ts +63 -4
- package/src/operation-lock.ts +99 -31
- package/src/personal-vault.test.ts +42 -0
- package/src/personal-vault.ts +18 -3
- package/src/prefix-coalesce.test.ts +71 -1
- package/src/prefix-coalesce.ts +155 -30
- package/src/remote-pull.test.ts +205 -0
- package/src/remote-pull.ts +77 -14
- package/src/s3.test.ts +126 -0
- package/src/s3.ts +237 -122
- package/src/scope-shrink.ts +6 -3
- package/src/skill-telemetry.test.ts +109 -0
- package/src/skill-telemetry.ts +82 -14
- package/src/sync/event-sync.test.ts +75 -0
- package/src/sync/event-sync.ts +54 -1
- package/src/sync/metrics.test.ts +81 -0
- package/src/sync/metrics.ts +59 -4
- package/src/sync/pull-scope.ts +23 -7
- package/src/sync/push-receiver.test.ts +38 -1
- package/src/sync/push-receiver.ts +15 -18
- package/src/telemetry.test.ts +85 -0
- package/src/telemetry.ts +69 -6
- package/src/types.ts +8 -0
- package/src/watcher.test.ts +117 -0
- package/src/watcher.ts +209 -33
|
@@ -42,6 +42,7 @@ import type {
|
|
|
42
42
|
PendingInviteByEmail,
|
|
43
43
|
} from "../vault-client.js";
|
|
44
44
|
import { VaultAuthError } from "../vault-client.js";
|
|
45
|
+
import type { PushEvent } from "../sync/push-event.js";
|
|
45
46
|
|
|
46
47
|
// ---------------------------------------------------------------------------
|
|
47
48
|
// Hermetic journal state — the runner now calls migratePersonalVaultJournal()
|
|
@@ -1008,7 +1009,7 @@ describe("per-company fanout", () => {
|
|
|
1008
1009
|
]);
|
|
1009
1010
|
});
|
|
1010
1011
|
|
|
1011
|
-
it("
|
|
1012
|
+
it("F09: per-file transfer errors make the run partial and non-zero", async () => {
|
|
1012
1013
|
const deps = makeDeps({
|
|
1013
1014
|
createVaultClient: () =>
|
|
1014
1015
|
makeVaultStub({
|
|
@@ -1027,7 +1028,7 @@ describe("per-company fanout", () => {
|
|
|
1027
1028
|
});
|
|
1028
1029
|
|
|
1029
1030
|
const code = await runRunner(["--companies"], deps);
|
|
1030
|
-
expect(code).toBe(
|
|
1031
|
+
expect(code).toBe(2);
|
|
1031
1032
|
// Per-file errors are routed to stderr (error-class events).
|
|
1032
1033
|
const errs = deps.stderr
|
|
1033
1034
|
.events()
|
|
@@ -1042,6 +1043,14 @@ describe("per-company fanout", () => {
|
|
|
1042
1043
|
message: "access denied",
|
|
1043
1044
|
},
|
|
1044
1045
|
]);
|
|
1046
|
+
const all = deps.stdout
|
|
1047
|
+
.events()
|
|
1048
|
+
.find(
|
|
1049
|
+
(e): e is Extract<RunnerEvent, { type: "all-complete" }> =>
|
|
1050
|
+
e.type === "all-complete",
|
|
1051
|
+
);
|
|
1052
|
+
expect(all).toBeDefined();
|
|
1053
|
+
expect(all?.partial).toBe(true);
|
|
1045
1054
|
});
|
|
1046
1055
|
|
|
1047
1056
|
it("emits complete event per company with the SyncResult spread", async () => {
|
|
@@ -2182,6 +2191,104 @@ describe("personal slot fanout", () => {
|
|
|
2182
2191
|
}
|
|
2183
2192
|
});
|
|
2184
2193
|
|
|
2194
|
+
it("E2: personal slot pierces the workspace/ exclusion for the session-continuity pointer + active thread file, while the rest of workspace/ stays local (DEV-1778)", async () => {
|
|
2195
|
+
// Regression guard for the cross-machine session-handoff carve-out. Test
|
|
2196
|
+
// "E" proves `workspace/` is excluded wholesale; this proves the ONE
|
|
2197
|
+
// exception travels through the REAL runner push composition (not just the
|
|
2198
|
+
// pure computeContinuityPointerPaths helper): `/handoff` writes
|
|
2199
|
+
// workspace/threads/handoff.json + the thread file it points to, and both
|
|
2200
|
+
// must reach share() so a session handed off on machine A resumes on
|
|
2201
|
+
// machine B. Everything else under workspace/ (locks, other threads, the
|
|
2202
|
+
// INDEX) must remain machine-local.
|
|
2203
|
+
const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
|
|
2204
|
+
const tmpHqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-runner-test-"));
|
|
2205
|
+
try {
|
|
2206
|
+
// A normal included top-level dir, so the personal push is non-empty
|
|
2207
|
+
// regardless of the carve-out.
|
|
2208
|
+
fs.mkdirSync(path.join(tmpHqRoot, "knowledge"));
|
|
2209
|
+
|
|
2210
|
+
// The session-continuity carve-out: handoff.json + the active thread it
|
|
2211
|
+
// references via `thread_path` (hq-root-relative, as handoff-finalize.sh
|
|
2212
|
+
// writes it).
|
|
2213
|
+
const threadsDir = path.join(tmpHqRoot, "workspace", "threads");
|
|
2214
|
+
fs.mkdirSync(threadsDir, { recursive: true });
|
|
2215
|
+
const activeThreadRel = "workspace/threads/T-20260619-1200-resume-me.json";
|
|
2216
|
+
fs.writeFileSync(
|
|
2217
|
+
path.join(tmpHqRoot, activeThreadRel),
|
|
2218
|
+
JSON.stringify({ conversation_summary: "pick up here" }),
|
|
2219
|
+
);
|
|
2220
|
+
fs.writeFileSync(
|
|
2221
|
+
path.join(threadsDir, "handoff.json"),
|
|
2222
|
+
JSON.stringify({ thread_path: activeThreadRel }),
|
|
2223
|
+
);
|
|
2224
|
+
|
|
2225
|
+
// The rest of workspace/ that must NOT travel: a lock, an inactive
|
|
2226
|
+
// thread, and the regenerated index.
|
|
2227
|
+
fs.mkdirSync(path.join(tmpHqRoot, "workspace", "locks"), {
|
|
2228
|
+
recursive: true,
|
|
2229
|
+
});
|
|
2230
|
+
fs.writeFileSync(
|
|
2231
|
+
path.join(tmpHqRoot, "workspace", "locks", "sync.lock"),
|
|
2232
|
+
"1",
|
|
2233
|
+
);
|
|
2234
|
+
fs.writeFileSync(
|
|
2235
|
+
path.join(threadsDir, "T-20260101-0000-stale.json"),
|
|
2236
|
+
"{}",
|
|
2237
|
+
);
|
|
2238
|
+
fs.writeFileSync(path.join(threadsDir, "INDEX.md"), "# threads");
|
|
2239
|
+
|
|
2240
|
+
const deps = makeDeps({
|
|
2241
|
+
createVaultClient: () =>
|
|
2242
|
+
makeVaultStub({
|
|
2243
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
2244
|
+
entityGet: (uid: string) =>
|
|
2245
|
+
Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
|
|
2246
|
+
listPersons: () => Promise.resolve([olderPerson]),
|
|
2247
|
+
}),
|
|
2248
|
+
share: shareSpy,
|
|
2249
|
+
});
|
|
2250
|
+
|
|
2251
|
+
const code = await runRunner(
|
|
2252
|
+
["--companies", "--direction", "push", "--hq-root", tmpHqRoot],
|
|
2253
|
+
deps,
|
|
2254
|
+
);
|
|
2255
|
+
expect(code).toBe(0);
|
|
2256
|
+
|
|
2257
|
+
const personalCall = (shareSpy.mock.calls as Array<[ShareOptions]>).find(
|
|
2258
|
+
(c) => c[0].company?.startsWith("prs_"),
|
|
2259
|
+
);
|
|
2260
|
+
expect(personalCall).toBeDefined();
|
|
2261
|
+
const personalArgs = personalCall![0];
|
|
2262
|
+
|
|
2263
|
+
// Compare as hq-root-relative, forward-slash paths (the namespace the
|
|
2264
|
+
// continuity carve-out is specified in).
|
|
2265
|
+
const relPaths = personalArgs.paths.map((p) =>
|
|
2266
|
+
path.relative(tmpHqRoot, p).split(path.sep).join("/"),
|
|
2267
|
+
);
|
|
2268
|
+
|
|
2269
|
+
// The two continuity files MUST be in the push set.
|
|
2270
|
+
expect(relPaths).toContain("workspace/threads/handoff.json");
|
|
2271
|
+
expect(relPaths).toContain(activeThreadRel);
|
|
2272
|
+
|
|
2273
|
+
// The rest of workspace/ must NOT leak in — neither as the parent dir
|
|
2274
|
+
// nor as the individual machine-local files.
|
|
2275
|
+
expect(relPaths).not.toContain("workspace");
|
|
2276
|
+
expect(relPaths).not.toContain("workspace/locks");
|
|
2277
|
+
expect(
|
|
2278
|
+
relPaths.some(
|
|
2279
|
+
(p) => p === "workspace/locks" || p.startsWith("workspace/locks/"),
|
|
2280
|
+
),
|
|
2281
|
+
).toBe(false);
|
|
2282
|
+
expect(relPaths).not.toContain("workspace/threads/T-20260101-0000-stale.json");
|
|
2283
|
+
expect(relPaths).not.toContain("workspace/threads/INDEX.md");
|
|
2284
|
+
// The bare threads dir is never handed in wholesale — only the two
|
|
2285
|
+
// explicit files (otherwise share()'s walk would sweep stale threads).
|
|
2286
|
+
expect(relPaths).not.toContain("workspace/threads");
|
|
2287
|
+
} finally {
|
|
2288
|
+
fs.rmSync(tmpHqRoot, { recursive: true, force: true });
|
|
2289
|
+
}
|
|
2290
|
+
});
|
|
2291
|
+
|
|
2185
2292
|
it("G: personal slot includes companies/{slug}/ subdirs when cloud:false marker is set AND HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1, excluding _template + team-synced slugs + missing/cloud:true markers", async () => {
|
|
2186
2293
|
// Gate the new behavior: without this env var the runner falls back to
|
|
2187
2294
|
// the legacy "companies/ never enumerated" personal vault. Test H below
|
|
@@ -2958,6 +3065,73 @@ describe("runRunnerWithLoop — event-push wiring", () => {
|
|
|
2958
3065
|
await loop;
|
|
2959
3066
|
});
|
|
2960
3067
|
|
|
3068
|
+
it("F05: guard-held watcher push is queued after the active poll pass", async () => {
|
|
3069
|
+
const watcher = makeWatcherStub();
|
|
3070
|
+
const clock = new FakeClock();
|
|
3071
|
+
let triggerShutdown = () => {};
|
|
3072
|
+
|
|
3073
|
+
let releasePoll: () => void = () => {};
|
|
3074
|
+
const pollGate = new Promise<void>((resolve) => {
|
|
3075
|
+
releasePoll = resolve;
|
|
3076
|
+
});
|
|
3077
|
+
let active = 0;
|
|
3078
|
+
let maxConcurrent = 0;
|
|
3079
|
+
let calls = 0;
|
|
3080
|
+
const runPass = vi.fn().mockImplementation(async () => {
|
|
3081
|
+
active++;
|
|
3082
|
+
maxConcurrent = Math.max(maxConcurrent, active);
|
|
3083
|
+
calls++;
|
|
3084
|
+
if (calls === 1) await pollGate;
|
|
3085
|
+
active--;
|
|
3086
|
+
return 0;
|
|
3087
|
+
});
|
|
3088
|
+
|
|
3089
|
+
const loop = runRunnerWithLoop(
|
|
3090
|
+
["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"],
|
|
3091
|
+
{
|
|
3092
|
+
runPass,
|
|
3093
|
+
clock,
|
|
3094
|
+
createWatcher: () => watcher,
|
|
3095
|
+
sleep: () => new Promise<void>(() => {}),
|
|
3096
|
+
onShutdownSignal: (handler) => {
|
|
3097
|
+
triggerShutdown = handler;
|
|
3098
|
+
return () => {};
|
|
3099
|
+
},
|
|
3100
|
+
},
|
|
3101
|
+
);
|
|
3102
|
+
|
|
3103
|
+
await Promise.resolve();
|
|
3104
|
+
await Promise.resolve();
|
|
3105
|
+
expect(active).toBe(1);
|
|
3106
|
+
|
|
3107
|
+
watcher.emit("companies/indigo/queued.md");
|
|
3108
|
+
clock.advance(0);
|
|
3109
|
+
await Promise.resolve();
|
|
3110
|
+
await Promise.resolve();
|
|
3111
|
+
expect(maxConcurrent).toBe(1);
|
|
3112
|
+
|
|
3113
|
+
releasePoll();
|
|
3114
|
+
for (let i = 0; i < 8; i++) await Promise.resolve();
|
|
3115
|
+
clock.advance(0);
|
|
3116
|
+
for (let i = 0; i < 8; i++) await Promise.resolve();
|
|
3117
|
+
|
|
3118
|
+
triggerShutdown();
|
|
3119
|
+
await loop;
|
|
3120
|
+
|
|
3121
|
+
expect(maxConcurrent).toBe(1);
|
|
3122
|
+
const targetedCall = runPass.mock.calls.find((c) =>
|
|
3123
|
+
(c[0] as string[]).includes("--company"),
|
|
3124
|
+
);
|
|
3125
|
+
expect(targetedCall?.[0]).toEqual([
|
|
3126
|
+
"--company",
|
|
3127
|
+
"indigo",
|
|
3128
|
+
"--direction",
|
|
3129
|
+
"push",
|
|
3130
|
+
"--hq-root",
|
|
3131
|
+
"/tmp/hq",
|
|
3132
|
+
]);
|
|
3133
|
+
});
|
|
3134
|
+
|
|
2961
3135
|
it("poll-still-runs: the poll loop fires passes independent of the watcher", async () => {
|
|
2962
3136
|
const watcher = makeWatcherStub();
|
|
2963
3137
|
let triggerShutdown = () => {};
|
|
@@ -3715,7 +3889,7 @@ describe("readPinnedPrefixes", () => {
|
|
|
3715
3889
|
});
|
|
3716
3890
|
|
|
3717
3891
|
// ---------------------------------------------------------------------------
|
|
3718
|
-
// Operation lock —
|
|
3892
|
+
// Operation lock — sync operations serialize per root.
|
|
3719
3893
|
// ---------------------------------------------------------------------------
|
|
3720
3894
|
describe("runRunnerWithLoop — operation lock", () => {
|
|
3721
3895
|
const HQ = "/tmp/hq-oplock";
|
|
@@ -3833,14 +4007,29 @@ describe("runRunnerWithLoop — operation lock", () => {
|
|
|
3833
4007
|
expect(out).toContain("reindex");
|
|
3834
4008
|
}, 20_000);
|
|
3835
4009
|
|
|
3836
|
-
it("
|
|
4010
|
+
it("F03: watch runner refuses when another operation holds the root lock", async () => {
|
|
3837
4011
|
const lp = writeLiveHolder("sync");
|
|
3838
4012
|
const watcher = makeWatcherStub();
|
|
3839
4013
|
let triggerShutdown = () => {};
|
|
3840
4014
|
const runPass = vi.fn().mockResolvedValue(0);
|
|
4015
|
+
const errs: string[] = [];
|
|
4016
|
+
const spy = vi
|
|
4017
|
+
.spyOn(process.stderr, "write")
|
|
4018
|
+
.mockImplementation((chunk: string | Uint8Array) => {
|
|
4019
|
+
errs.push(String(chunk));
|
|
4020
|
+
return true;
|
|
4021
|
+
});
|
|
3841
4022
|
|
|
3842
4023
|
const loop = runRunnerWithLoop(
|
|
3843
|
-
[
|
|
4024
|
+
[
|
|
4025
|
+
"--companies",
|
|
4026
|
+
"--watch",
|
|
4027
|
+
"--event-push",
|
|
4028
|
+
"--hq-root",
|
|
4029
|
+
HQ,
|
|
4030
|
+
"--lock-timeout",
|
|
4031
|
+
"0",
|
|
4032
|
+
],
|
|
3844
4033
|
{
|
|
3845
4034
|
runPass,
|
|
3846
4035
|
clock: new FakeClock(),
|
|
@@ -3855,14 +4044,18 @@ describe("runRunnerWithLoop — operation lock", () => {
|
|
|
3855
4044
|
|
|
3856
4045
|
await Promise.resolve();
|
|
3857
4046
|
await Promise.resolve();
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
4047
|
+
if (watcher.started || runPass.mock.calls.length > 0) {
|
|
4048
|
+
triggerShutdown();
|
|
4049
|
+
}
|
|
4050
|
+
const code = await loop;
|
|
4051
|
+
spy.mockRestore();
|
|
3861
4052
|
|
|
3862
|
-
|
|
3863
|
-
|
|
4053
|
+
expect(code).toBe(OPERATION_LOCKED_EXIT);
|
|
4054
|
+
expect(watcher.started).toBe(false);
|
|
4055
|
+
expect(runPass).not.toHaveBeenCalled();
|
|
4056
|
+
expect(errs.join("")).toContain("sync");
|
|
3864
4057
|
|
|
3865
|
-
// The pre-existing holder lock is untouched
|
|
4058
|
+
// The pre-existing holder lock is untouched.
|
|
3866
4059
|
const held = JSON.parse(fs.readFileSync(lp, "utf8"));
|
|
3867
4060
|
expect(held.pid).toBe(1);
|
|
3868
4061
|
expect(held.command).toBe("sync");
|
|
@@ -4049,6 +4242,198 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
4049
4242
|
await loop;
|
|
4050
4243
|
});
|
|
4051
4244
|
|
|
4245
|
+
it("F04: mixed-route watcher batches publish only routes that were pushed", async () => {
|
|
4246
|
+
const publishBatch = vi.fn();
|
|
4247
|
+
const startEventSync = vi.fn().mockResolvedValue({
|
|
4248
|
+
publishBatch,
|
|
4249
|
+
receiver: { start: async () => {}, dispose: async () => {}, connected: true },
|
|
4250
|
+
ownDeviceId: "dev-test",
|
|
4251
|
+
dispose: vi.fn().mockResolvedValue(undefined),
|
|
4252
|
+
});
|
|
4253
|
+
const runPass = vi.fn().mockResolvedValue(0);
|
|
4254
|
+
const { loop, watcher, clock, shutdown } = runLoop({
|
|
4255
|
+
claims: ENROLLED_CLAIMS,
|
|
4256
|
+
startEventSync,
|
|
4257
|
+
runPass,
|
|
4258
|
+
});
|
|
4259
|
+
await microtasks();
|
|
4260
|
+
runPass.mockClear();
|
|
4261
|
+
|
|
4262
|
+
watcher.emit("companies/indigo/a.md", {
|
|
4263
|
+
paths: new Map([
|
|
4264
|
+
["/tmp/hq/companies/indigo/a.md", "companies/indigo/a.md"],
|
|
4265
|
+
["/tmp/hq/companies/acme/b.md", "companies/acme/b.md"],
|
|
4266
|
+
["/tmp/hq/notes/personal.md", "notes/personal.md"],
|
|
4267
|
+
]),
|
|
4268
|
+
});
|
|
4269
|
+
clock.advance(0);
|
|
4270
|
+
await microtasks();
|
|
4271
|
+
|
|
4272
|
+
const pushedRoutes = new Set<string>();
|
|
4273
|
+
let pushedEverything = false;
|
|
4274
|
+
for (const call of runPass.mock.calls) {
|
|
4275
|
+
const passArgv = call[0] as string[];
|
|
4276
|
+
const directionIdx = passArgv.indexOf("--direction");
|
|
4277
|
+
if (passArgv[directionIdx + 1] !== "push") continue;
|
|
4278
|
+
const companyIdx = passArgv.indexOf("--company");
|
|
4279
|
+
if (companyIdx >= 0) {
|
|
4280
|
+
pushedRoutes.add(`company:${passArgv[companyIdx + 1]}`);
|
|
4281
|
+
} else if (passArgv.includes("--companies")) {
|
|
4282
|
+
pushedEverything = true;
|
|
4283
|
+
}
|
|
4284
|
+
}
|
|
4285
|
+
const publishedRelPaths = publishBatch.mock.calls.flatMap((call) => {
|
|
4286
|
+
const batch = call[0] as { paths: Map<string, string> };
|
|
4287
|
+
return [...batch.paths.values()];
|
|
4288
|
+
});
|
|
4289
|
+
const publishedWithoutPush = publishedRelPaths.filter((relPath) => {
|
|
4290
|
+
if (pushedEverything) return false;
|
|
4291
|
+
const route = routeChangeToTarget(relPath);
|
|
4292
|
+
if (!route) return true;
|
|
4293
|
+
if (route.kind === "company") {
|
|
4294
|
+
return !pushedRoutes.has(`company:${route.slug}`);
|
|
4295
|
+
}
|
|
4296
|
+
return !pushedRoutes.has("personal");
|
|
4297
|
+
});
|
|
4298
|
+
|
|
4299
|
+
expect(publishedRelPaths).toContain("companies/indigo/a.md");
|
|
4300
|
+
expect(publishedWithoutPush).toEqual([]);
|
|
4301
|
+
|
|
4302
|
+
shutdown();
|
|
4303
|
+
await loop;
|
|
4304
|
+
});
|
|
4305
|
+
|
|
4306
|
+
it("R-F04F05: preserves queued watcher push targets across overlap", async () => {
|
|
4307
|
+
const publishBatch = vi.fn();
|
|
4308
|
+
const startEventSync = vi.fn().mockResolvedValue({
|
|
4309
|
+
publishBatch,
|
|
4310
|
+
receiver: { start: async () => {}, dispose: async () => {}, connected: true },
|
|
4311
|
+
ownDeviceId: "dev-test",
|
|
4312
|
+
dispose: vi.fn().mockResolvedValue(undefined),
|
|
4313
|
+
});
|
|
4314
|
+
let receiverSync:
|
|
4315
|
+
| ((ctx: {
|
|
4316
|
+
event: PushEvent;
|
|
4317
|
+
signal: AbortSignal;
|
|
4318
|
+
}) => Promise<void>)
|
|
4319
|
+
| null = null;
|
|
4320
|
+
const watcher = makeBatchWatcherStub();
|
|
4321
|
+
const clock = new FakeClock();
|
|
4322
|
+
let triggerShutdown = () => {};
|
|
4323
|
+
let releasePoll: () => void = () => {};
|
|
4324
|
+
const pollGate = new Promise<void>((resolve) => {
|
|
4325
|
+
releasePoll = resolve;
|
|
4326
|
+
});
|
|
4327
|
+
let passCount = 0;
|
|
4328
|
+
const runPass = vi.fn().mockImplementation(async () => {
|
|
4329
|
+
passCount += 1;
|
|
4330
|
+
if (passCount === 1) await pollGate;
|
|
4331
|
+
return 0;
|
|
4332
|
+
});
|
|
4333
|
+
|
|
4334
|
+
const loop = runRunnerWithLoop(
|
|
4335
|
+
["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"],
|
|
4336
|
+
{
|
|
4337
|
+
runPass,
|
|
4338
|
+
clock,
|
|
4339
|
+
createWatcher: () => watcher,
|
|
4340
|
+
createReceiver: ({ syncFn }) => {
|
|
4341
|
+
receiverSync = syncFn;
|
|
4342
|
+
return {
|
|
4343
|
+
connected: true,
|
|
4344
|
+
start: async () => {},
|
|
4345
|
+
dispose: async () => {},
|
|
4346
|
+
};
|
|
4347
|
+
},
|
|
4348
|
+
sleep: () => new Promise<void>(() => {}),
|
|
4349
|
+
onShutdownSignal: (handler) => {
|
|
4350
|
+
triggerShutdown = handler;
|
|
4351
|
+
return () => {};
|
|
4352
|
+
},
|
|
4353
|
+
getIdTokenClaims: () => ENROLLED_CLAIMS as never,
|
|
4354
|
+
getAccessToken: async () => "jwt-test",
|
|
4355
|
+
startEventSync,
|
|
4356
|
+
},
|
|
4357
|
+
);
|
|
4358
|
+
await microtasks();
|
|
4359
|
+
expect(passCount).toBe(1);
|
|
4360
|
+
expect(receiverSync).toBeTruthy();
|
|
4361
|
+
|
|
4362
|
+
const indigoBatch = {
|
|
4363
|
+
paths: new Map([
|
|
4364
|
+
["/tmp/hq/companies/indigo/a.md", "companies/indigo/a.md"],
|
|
4365
|
+
]),
|
|
4366
|
+
};
|
|
4367
|
+
const betaBatch = {
|
|
4368
|
+
paths: new Map([
|
|
4369
|
+
["/tmp/hq/companies/beta/b.md", "companies/beta/b.md"],
|
|
4370
|
+
]),
|
|
4371
|
+
};
|
|
4372
|
+
watcher.emit("companies/indigo/a.md", indigoBatch);
|
|
4373
|
+
clock.advance(0);
|
|
4374
|
+
await microtasks();
|
|
4375
|
+
watcher.emit("companies/beta/b.md", betaBatch);
|
|
4376
|
+
clock.advance(0);
|
|
4377
|
+
await microtasks();
|
|
4378
|
+
|
|
4379
|
+
const remotePull = receiverSync!({
|
|
4380
|
+
event: {
|
|
4381
|
+
kind: "upsert",
|
|
4382
|
+
relativePath: "companies/acme/remote.md",
|
|
4383
|
+
contentHash: "sha256:remote",
|
|
4384
|
+
mtime: "2026-06-18T12:00:00.000Z",
|
|
4385
|
+
originDeviceId: "peer-device",
|
|
4386
|
+
originTenantId: "tenant-acme",
|
|
4387
|
+
sequenceNumber: 7,
|
|
4388
|
+
eventTimestamp: "2026-06-18T12:00:00.000Z",
|
|
4389
|
+
},
|
|
4390
|
+
signal: new AbortController().signal,
|
|
4391
|
+
});
|
|
4392
|
+
await microtasks();
|
|
4393
|
+
|
|
4394
|
+
releasePoll();
|
|
4395
|
+
await remotePull;
|
|
4396
|
+
await microtasks();
|
|
4397
|
+
clock.advance(0);
|
|
4398
|
+
await microtasks();
|
|
4399
|
+
|
|
4400
|
+
const passArgvs = runPass.mock.calls.map((call) => call[0] as string[]);
|
|
4401
|
+
expect(passArgvs).toEqual(
|
|
4402
|
+
expect.arrayContaining([
|
|
4403
|
+
["--company", "indigo", "--direction", "push", "--hq-root", "/tmp/hq"],
|
|
4404
|
+
["--company", "beta", "--direction", "push", "--hq-root", "/tmp/hq"],
|
|
4405
|
+
["--company", "acme", "--direction", "pull", "--hq-root", "/tmp/hq"],
|
|
4406
|
+
]),
|
|
4407
|
+
);
|
|
4408
|
+
|
|
4409
|
+
const pushedRoutes = new Set<string>();
|
|
4410
|
+
for (const passArgv of passArgvs) {
|
|
4411
|
+
const directionIdx = passArgv.indexOf("--direction");
|
|
4412
|
+
if (passArgv[directionIdx + 1] !== "push") continue;
|
|
4413
|
+
const companyIdx = passArgv.indexOf("--company");
|
|
4414
|
+
if (companyIdx >= 0) pushedRoutes.add(`company:${passArgv[companyIdx + 1]}`);
|
|
4415
|
+
}
|
|
4416
|
+
const publishedRelPaths = publishBatch.mock.calls.flatMap((call) => {
|
|
4417
|
+
const batch = call[0] as { paths: Map<string, string> };
|
|
4418
|
+
return [...batch.paths.values()];
|
|
4419
|
+
});
|
|
4420
|
+
const publishedWithoutPush = publishedRelPaths.filter((relPath) => {
|
|
4421
|
+
const route = routeChangeToTarget(relPath);
|
|
4422
|
+
return route?.kind !== "company" || !pushedRoutes.has(`company:${route.slug}`);
|
|
4423
|
+
});
|
|
4424
|
+
|
|
4425
|
+
expect(publishedRelPaths).toEqual(
|
|
4426
|
+
expect.arrayContaining([
|
|
4427
|
+
"companies/indigo/a.md",
|
|
4428
|
+
"companies/beta/b.md",
|
|
4429
|
+
]),
|
|
4430
|
+
);
|
|
4431
|
+
expect(publishedWithoutPush).toEqual([]);
|
|
4432
|
+
|
|
4433
|
+
triggerShutdown();
|
|
4434
|
+
await loop;
|
|
4435
|
+
});
|
|
4436
|
+
|
|
4052
4437
|
it("falls back to a synthesized single-path batch when the watcher emits a bare path", async () => {
|
|
4053
4438
|
const publishBatch = vi.fn();
|
|
4054
4439
|
const startEventSync = vi.fn().mockResolvedValue({
|