@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
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transient network-failure classification for the sync runner.
|
|
3
|
+
*
|
|
4
|
+
* The auto-sync watcher runs an unattended poll loop. At the top of every pass
|
|
5
|
+
* it calls `GET /membership/me` to resolve which companies to sync. When the
|
|
6
|
+
* machine is briefly offline (wifi drop, waking from sleep, a DNS blip, the
|
|
7
|
+
* vault API momentarily unreachable) that call fails at the transport layer —
|
|
8
|
+
* Node's `fetch` throws `TypeError: fetch failed` with the real cause (an
|
|
9
|
+
* `ECONNREFUSED` / `ENOTFOUND` / `ETIMEDOUT` / … Error) on `.cause`.
|
|
10
|
+
*
|
|
11
|
+
* This is NOT a crash: the next poll (~30s later) succeeds once the network is
|
|
12
|
+
* back. But the runner used to `return 1` for it, the watch loop propagated
|
|
13
|
+
* that non-zero exit, the process exited, and the menubar supervisor reported
|
|
14
|
+
* "auto-sync watcher exited unexpectedly (code=Some(1))" for every blip — the
|
|
15
|
+
* HQ-SYNC-1W cluster. We classify these so the watch loop can stay alive and
|
|
16
|
+
* retry instead of surfacing a false crash, while a one-shot `hq sync` still
|
|
17
|
+
* exits non-zero so a human running it by hand sees the failure.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Distinct runner exit code for a transient network failure. The watch loop
|
|
21
|
+
* treats it as "stay alive, retry next tick" (never a crash); a one-shot run
|
|
22
|
+
* still exits non-zero so the failure is visible to a human/script. Value 75 =
|
|
23
|
+
* `EX_TEMPFAIL` from sysexits.h ("temporary failure; retry"). Kept off the
|
|
24
|
+
* codes already in use by the runner (0 ok, 1 error, 2 partial, 17 op-locked).
|
|
25
|
+
*/
|
|
26
|
+
export declare const TRANSIENT_NETWORK_EXIT = 75;
|
|
27
|
+
/**
|
|
28
|
+
* True when `err` is (or wraps) a transient network-transport failure. Walks the
|
|
29
|
+
* `.cause` chain and `AggregateError.errors` (undici's happy-eyeballs raises the
|
|
30
|
+
* latter), matching either undici's canonical `TypeError: fetch failed` message
|
|
31
|
+
* or a known transient `.code`. Conservative by design: an arbitrary
|
|
32
|
+
* `new Error("network down")` with no code and a non-canonical message is NOT
|
|
33
|
+
* treated as transient, so only real transport-failure shapes qualify.
|
|
34
|
+
*/
|
|
35
|
+
export declare function isTransientNetworkError(err: unknown, depth?: number): boolean;
|
|
36
|
+
//# sourceMappingURL=net-errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"net-errors.d.ts","sourceRoot":"","sources":["../../src/lib/net-errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,KAAK,CAAC;AA0BzC;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,OAAO,EAAE,KAAK,SAAI,GAAG,OAAO,CAsBxE"}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transient network-failure classification for the sync runner.
|
|
3
|
+
*
|
|
4
|
+
* The auto-sync watcher runs an unattended poll loop. At the top of every pass
|
|
5
|
+
* it calls `GET /membership/me` to resolve which companies to sync. When the
|
|
6
|
+
* machine is briefly offline (wifi drop, waking from sleep, a DNS blip, the
|
|
7
|
+
* vault API momentarily unreachable) that call fails at the transport layer —
|
|
8
|
+
* Node's `fetch` throws `TypeError: fetch failed` with the real cause (an
|
|
9
|
+
* `ECONNREFUSED` / `ENOTFOUND` / `ETIMEDOUT` / … Error) on `.cause`.
|
|
10
|
+
*
|
|
11
|
+
* This is NOT a crash: the next poll (~30s later) succeeds once the network is
|
|
12
|
+
* back. But the runner used to `return 1` for it, the watch loop propagated
|
|
13
|
+
* that non-zero exit, the process exited, and the menubar supervisor reported
|
|
14
|
+
* "auto-sync watcher exited unexpectedly (code=Some(1))" for every blip — the
|
|
15
|
+
* HQ-SYNC-1W cluster. We classify these so the watch loop can stay alive and
|
|
16
|
+
* retry instead of surfacing a false crash, while a one-shot `hq sync` still
|
|
17
|
+
* exits non-zero so a human running it by hand sees the failure.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Distinct runner exit code for a transient network failure. The watch loop
|
|
21
|
+
* treats it as "stay alive, retry next tick" (never a crash); a one-shot run
|
|
22
|
+
* still exits non-zero so the failure is visible to a human/script. Value 75 =
|
|
23
|
+
* `EX_TEMPFAIL` from sysexits.h ("temporary failure; retry"). Kept off the
|
|
24
|
+
* codes already in use by the runner (0 ok, 1 error, 2 partial, 17 op-locked).
|
|
25
|
+
*/
|
|
26
|
+
export const TRANSIENT_NETWORK_EXIT = 75;
|
|
27
|
+
/**
|
|
28
|
+
* Node/undici error `code`s that mean "the request never completed at the
|
|
29
|
+
* transport layer" — the machine could not reach the host. All are transient:
|
|
30
|
+
* a later attempt from the same machine can succeed unchanged.
|
|
31
|
+
*/
|
|
32
|
+
const TRANSIENT_CODES = new Set([
|
|
33
|
+
"ECONNREFUSED",
|
|
34
|
+
"ECONNRESET",
|
|
35
|
+
"ENOTFOUND",
|
|
36
|
+
"EAI_AGAIN",
|
|
37
|
+
"ETIMEDOUT",
|
|
38
|
+
"EPIPE",
|
|
39
|
+
"EHOSTUNREACH",
|
|
40
|
+
"EHOSTDOWN",
|
|
41
|
+
"ENETUNREACH",
|
|
42
|
+
"ENETDOWN",
|
|
43
|
+
"ECONNABORTED",
|
|
44
|
+
// undici's own timeout/socket failures
|
|
45
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
46
|
+
"UND_ERR_HEADERS_TIMEOUT",
|
|
47
|
+
"UND_ERR_BODY_TIMEOUT",
|
|
48
|
+
"UND_ERR_SOCKET",
|
|
49
|
+
]);
|
|
50
|
+
/**
|
|
51
|
+
* True when `err` is (or wraps) a transient network-transport failure. Walks the
|
|
52
|
+
* `.cause` chain and `AggregateError.errors` (undici's happy-eyeballs raises the
|
|
53
|
+
* latter), matching either undici's canonical `TypeError: fetch failed` message
|
|
54
|
+
* or a known transient `.code`. Conservative by design: an arbitrary
|
|
55
|
+
* `new Error("network down")` with no code and a non-canonical message is NOT
|
|
56
|
+
* treated as transient, so only real transport-failure shapes qualify.
|
|
57
|
+
*/
|
|
58
|
+
export function isTransientNetworkError(err, depth = 0) {
|
|
59
|
+
if (depth > 8 || !(err instanceof Error))
|
|
60
|
+
return false;
|
|
61
|
+
// undici's transport failure surfaces as exactly this message.
|
|
62
|
+
if (err.message === "fetch failed")
|
|
63
|
+
return true;
|
|
64
|
+
const code = err.code;
|
|
65
|
+
if (typeof code === "string" && TRANSIENT_CODES.has(code))
|
|
66
|
+
return true;
|
|
67
|
+
// AggregateError (e.g. happy-eyeballs) stashes the per-attempt errors here.
|
|
68
|
+
const aggregated = err.errors;
|
|
69
|
+
if (Array.isArray(aggregated)) {
|
|
70
|
+
for (const inner of aggregated) {
|
|
71
|
+
if (isTransientNetworkError(inner, depth + 1))
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const cause = err.cause;
|
|
76
|
+
if (cause !== undefined && cause !== err) {
|
|
77
|
+
return isTransientNetworkError(cause, depth + 1);
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=net-errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"net-errors.js","sourceRoot":"","sources":["../../src/lib/net-errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,EAAE,CAAC;AAEzC;;;;GAIG;AACH,MAAM,eAAe,GAAG,IAAI,GAAG,CAAS;IACtC,cAAc;IACd,YAAY;IACZ,WAAW;IACX,WAAW;IACX,WAAW;IACX,OAAO;IACP,cAAc;IACd,WAAW;IACX,aAAa;IACb,UAAU;IACV,cAAc;IACd,uCAAuC;IACvC,yBAAyB;IACzB,yBAAyB;IACzB,sBAAsB;IACtB,gBAAgB;CACjB,CAAC,CAAC;AAEH;;;;;;;GAOG;AACH,MAAM,UAAU,uBAAuB,CAAC,GAAY,EAAE,KAAK,GAAG,CAAC;IAC7D,IAAI,KAAK,GAAG,CAAC,IAAI,CAAC,CAAC,GAAG,YAAY,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IAEvD,+DAA+D;IAC/D,IAAI,GAAG,CAAC,OAAO,KAAK,cAAc;QAAE,OAAO,IAAI,CAAC;IAEhD,MAAM,IAAI,GAAI,GAA0B,CAAC,IAAI,CAAC;IAC9C,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,eAAe,CAAC,GAAG,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IAEvE,4EAA4E;IAC5E,MAAM,UAAU,GAAI,GAA4B,CAAC,MAAM,CAAC;IACxD,IAAI,KAAK,CAAC,OAAO,CAAC,UAAU,CAAC,EAAE,CAAC;QAC9B,KAAK,MAAM,KAAK,IAAI,UAAU,EAAE,CAAC;YAC/B,IAAI,uBAAuB,CAAC,KAAK,EAAE,KAAK,GAAG,CAAC,CAAC;gBAAE,OAAO,IAAI,CAAC;QAC7D,CAAC;IACH,CAAC;IAED,MAAM,KAAK,GAAI,GAA2B,CAAC,KAAK,CAAC;IACjD,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,GAAG,EAAE,CAAC;QACzC,OAAO,uBAAuB,CAAC,KAAK,EAAE,KAAK,GAAG,CAAC,CAAC,CAAC;IACnD,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"net-errors.test.d.ts","sourceRoot":"","sources":["../../src/lib/net-errors.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { isTransientNetworkError, TRANSIENT_NETWORK_EXIT } from "./net-errors.js";
|
|
3
|
+
describe("isTransientNetworkError", () => {
|
|
4
|
+
it("treats undici's canonical `fetch failed` TypeError as transient", () => {
|
|
5
|
+
// This is the exact shape the runner's discovery step sees in production
|
|
6
|
+
// (HQ-SYNC-1W): `{"type":"error","message":"fetch failed","path":"(discovery)"}`.
|
|
7
|
+
expect(isTransientNetworkError(new TypeError("fetch failed"))).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
it("treats known transport `.code`s as transient", () => {
|
|
10
|
+
for (const code of ["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ETIMEDOUT"]) {
|
|
11
|
+
const err = Object.assign(new Error("boom"), { code });
|
|
12
|
+
expect(isTransientNetworkError(err)).toBe(true);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
it("walks the .cause chain (fetch failed wrapping a DNS error)", () => {
|
|
16
|
+
const cause = Object.assign(new Error("getaddrinfo ENOTFOUND api"), {
|
|
17
|
+
code: "ENOTFOUND",
|
|
18
|
+
});
|
|
19
|
+
const err = Object.assign(new TypeError("fetch failed"), { cause });
|
|
20
|
+
expect(isTransientNetworkError(err)).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
it("walks AggregateError.errors (happy-eyeballs)", () => {
|
|
23
|
+
const agg = Object.assign(new AggregateError([
|
|
24
|
+
Object.assign(new Error("v4"), { code: "ECONNREFUSED" }),
|
|
25
|
+
Object.assign(new Error("v6"), { code: "EHOSTUNREACH" }),
|
|
26
|
+
], "all attempts failed"), {});
|
|
27
|
+
expect(isTransientNetworkError(agg)).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
it("is conservative: an arbitrary error is NOT transient", () => {
|
|
30
|
+
// A bare message like "network down" (no code, not the canonical undici
|
|
31
|
+
// message) must not be misclassified — only real transport shapes qualify.
|
|
32
|
+
expect(isTransientNetworkError(new Error("network down"))).toBe(false);
|
|
33
|
+
expect(isTransientNetworkError(new Error("boom"))).toBe(false);
|
|
34
|
+
expect(isTransientNetworkError("fetch failed")).toBe(false);
|
|
35
|
+
expect(isTransientNetworkError(undefined)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
it("does not infinite-loop on a self-referential cause", () => {
|
|
38
|
+
const err = new Error("weird");
|
|
39
|
+
err.cause = err;
|
|
40
|
+
expect(isTransientNetworkError(err)).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
it("exposes a distinct exit code off the runner's other codes", () => {
|
|
43
|
+
expect(TRANSIENT_NETWORK_EXIT).toBe(75);
|
|
44
|
+
expect([0, 1, 2, 17]).not.toContain(TRANSIENT_NETWORK_EXIT);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
//# sourceMappingURL=net-errors.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"net-errors.test.js","sourceRoot":"","sources":["../../src/lib/net-errors.test.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -6,6 +6,13 @@ import type { ConflictStrategy } from "../cli/conflict.js";
|
|
|
6
6
|
import type { UploadAuthor } from "../s3.js";
|
|
7
7
|
import type { VaultServiceConfig } from "../index.js";
|
|
8
8
|
import { resolvePullScope, type PullScope } from "../sync/pull-scope.js";
|
|
9
|
+
import {
|
|
10
|
+
coalescePrefixes,
|
|
11
|
+
pathToScopePrefix,
|
|
12
|
+
toScopePrefixEntries,
|
|
13
|
+
type ScopePrefixEntry,
|
|
14
|
+
type ScopePrefixInput,
|
|
15
|
+
} from "../prefix-coalesce.js";
|
|
9
16
|
import { describeError } from "../lib/describe-error.js";
|
|
10
17
|
import type {
|
|
11
18
|
DeletePropagationPolicy,
|
|
@@ -42,6 +49,7 @@ export async function executeCompanyFanout(options: {
|
|
|
42
49
|
shareFn: (options: ShareOptions) => Promise<ShareResult>;
|
|
43
50
|
resolveDeletePolicy: () => DeletePropagationPolicy;
|
|
44
51
|
emit: (event: RunnerEvent) => void;
|
|
52
|
+
scopePaths?: string[];
|
|
45
53
|
}): Promise<CompanyFanoutResult> {
|
|
46
54
|
const doPush =
|
|
47
55
|
options.direction === "push" || options.direction === "both";
|
|
@@ -196,6 +204,10 @@ export async function executeCompanyFanout(options: {
|
|
|
196
204
|
|
|
197
205
|
if (doPush) {
|
|
198
206
|
activePhase = "push";
|
|
207
|
+
const pushPrefixSet = effectivePushPrefixSet(
|
|
208
|
+
scope.prefixSet,
|
|
209
|
+
options.scopePaths ?? [],
|
|
210
|
+
);
|
|
199
211
|
const pushPaths =
|
|
200
212
|
target.personalMode === true
|
|
201
213
|
? computePersonalVaultPaths(options.hqRoot, { teamSyncedSlugs })
|
|
@@ -224,8 +236,8 @@ export async function executeCompanyFanout(options: {
|
|
|
224
236
|
...(decommissionPrefixes && decommissionPrefixes.length > 0
|
|
225
237
|
? { decommissionPrefixes }
|
|
226
238
|
: {}),
|
|
227
|
-
...(
|
|
228
|
-
? { prefixSet:
|
|
239
|
+
...(pushPrefixSet !== undefined
|
|
240
|
+
? { prefixSet: pushPrefixSet }
|
|
229
241
|
: {}),
|
|
230
242
|
});
|
|
231
243
|
}
|
|
@@ -364,6 +376,53 @@ export async function executeCompanyFanout(options: {
|
|
|
364
376
|
return { stateByCompany, errors, allConflicts };
|
|
365
377
|
}
|
|
366
378
|
|
|
379
|
+
function effectivePushPrefixSet(
|
|
380
|
+
aclPrefixSet: readonly ScopePrefixInput[] | undefined,
|
|
381
|
+
scopePaths: readonly string[],
|
|
382
|
+
): string[] | undefined {
|
|
383
|
+
if (scopePaths.length === 0) {
|
|
384
|
+
return aclPrefixSet === undefined
|
|
385
|
+
? undefined
|
|
386
|
+
: coalescePrefixes(aclPrefixSet);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const requested = scopePaths.flatMap((p) => [
|
|
390
|
+
pathToScopePrefix(p),
|
|
391
|
+
pathToScopePrefix(p.endsWith("/") ? p : p + "/"),
|
|
392
|
+
]);
|
|
393
|
+
if (aclPrefixSet === undefined) {
|
|
394
|
+
return coalescePrefixes(requested);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const aclEntries = toScopePrefixEntries(aclPrefixSet);
|
|
398
|
+
const requestedEntries = toScopePrefixEntries(requested);
|
|
399
|
+
const intersection: ScopePrefixEntry[] = [];
|
|
400
|
+
for (const requestedEntry of requestedEntries) {
|
|
401
|
+
for (const aclEntry of aclEntries) {
|
|
402
|
+
if (entryCoversEntry(aclEntry, requestedEntry)) {
|
|
403
|
+
intersection.push(requestedEntry);
|
|
404
|
+
} else if (entryCoversEntry(requestedEntry, aclEntry)) {
|
|
405
|
+
intersection.push(aclEntry);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return coalescePrefixes(intersection);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function entryCoversEntry(
|
|
413
|
+
entry: ScopePrefixEntry,
|
|
414
|
+
candidate: ScopePrefixEntry,
|
|
415
|
+
): boolean {
|
|
416
|
+
if (entry.prefix === "") return true;
|
|
417
|
+
if (entry.match === "prefix") {
|
|
418
|
+
return (
|
|
419
|
+
candidate.prefix === entry.prefix ||
|
|
420
|
+
candidate.prefix.startsWith(entry.prefix)
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
return candidate.match === "exact" && candidate.prefix === entry.prefix;
|
|
424
|
+
}
|
|
425
|
+
|
|
367
426
|
function normalizePullChangedPaths(
|
|
368
427
|
target: RunnerTarget,
|
|
369
428
|
changedPaths: string[] | undefined,
|
|
@@ -8,10 +8,10 @@ import {
|
|
|
8
8
|
OPERATION_LOCKED_EXIT,
|
|
9
9
|
} from "../operation-lock.js";
|
|
10
10
|
import { describeError } from "../lib/describe-error.js";
|
|
11
|
+
import { TRANSIENT_NETWORK_EXIT } from "../lib/net-errors.js";
|
|
11
12
|
import { getOrCreateMachineId } from "../lib/machine-id.js";
|
|
12
13
|
import {
|
|
13
14
|
TreeWatcher,
|
|
14
|
-
WatchPushDriver,
|
|
15
15
|
systemClock,
|
|
16
16
|
type TreeChangeBatch,
|
|
17
17
|
} from "../watcher.js";
|
|
@@ -26,18 +26,28 @@ import {
|
|
|
26
26
|
type EventSyncHandles,
|
|
27
27
|
} from "../sync/event-sync.js";
|
|
28
28
|
import {
|
|
29
|
-
|
|
29
|
+
buildScopedDrainPullArgv,
|
|
30
|
+
buildScopedPushArgv,
|
|
30
31
|
buildTargetedPullArgv,
|
|
31
|
-
buildTargetedPushArgv,
|
|
32
32
|
routeChangeToTarget,
|
|
33
|
-
|
|
33
|
+
routeKey,
|
|
34
34
|
type WatchRoute,
|
|
35
35
|
} from "./sync-runner-watch-routes.js";
|
|
36
|
-
import type { RunnerLoopDeps, WatcherSurface } from "./sync-runner.js";
|
|
36
|
+
import type { Direction, RunnerLoopDeps, WatcherSurface } from "./sync-runner.js";
|
|
37
|
+
|
|
38
|
+
const FULL_RECONCILE_EVERY_TICKS = 10;
|
|
37
39
|
|
|
38
40
|
export interface ParsedLoopArgs {
|
|
39
41
|
hqRoot: string;
|
|
40
42
|
lockTimeoutSec?: number;
|
|
43
|
+
/**
|
|
44
|
+
* The run's resolved sync direction, straight from `parseArgs` (the single
|
|
45
|
+
* source of truth — same default as the parser). The drain reads this to
|
|
46
|
+
* decide whether scoped pushes and/or the pull leg run; it must NOT re-parse
|
|
47
|
+
* `--direction` from argv, which mis-defaults an omitted flag and disagrees
|
|
48
|
+
* with the parser on duplicate flags.
|
|
49
|
+
*/
|
|
50
|
+
direction?: Direction;
|
|
41
51
|
}
|
|
42
52
|
|
|
43
53
|
export interface WatchLoopRuntime {
|
|
@@ -91,9 +101,19 @@ export async function runWatchLoop(
|
|
|
91
101
|
if (a === "--watch") return false;
|
|
92
102
|
if (a === "--poll-remote-ms") return false;
|
|
93
103
|
if (a === "--event-push") return false;
|
|
104
|
+
if (a === "--scope-path") return false;
|
|
94
105
|
if (i > 0 && argv[i - 1] === "--poll-remote-ms") return false;
|
|
106
|
+
if (i > 0 && argv[i - 1] === "--scope-path") return false;
|
|
95
107
|
return true;
|
|
96
108
|
});
|
|
109
|
+
// Authoritative direction from parseArgs — never re-derive from argv (that
|
|
110
|
+
// mis-defaults an omitted --direction to "both" and picks the wrong
|
|
111
|
+
// occurrence on duplicates). Fall back to the parser's own default ("pull").
|
|
112
|
+
const originalDirection: Direction = parsed.direction ?? "pull";
|
|
113
|
+
const drainRunsPush =
|
|
114
|
+
originalDirection === "both" || originalDirection === "push";
|
|
115
|
+
const drainRunsPull =
|
|
116
|
+
originalDirection === "both" || originalDirection === "pull";
|
|
97
117
|
|
|
98
118
|
if (parsed.lockTimeoutSec === 0) {
|
|
99
119
|
try {
|
|
@@ -197,7 +217,6 @@ export async function runWatchLoop(
|
|
|
197
217
|
};
|
|
198
218
|
|
|
199
219
|
let watcher: WatcherSurface | null = null;
|
|
200
|
-
let driver: WatchPushDriver | null = null;
|
|
201
220
|
let detachSignal: (() => void) | null = null;
|
|
202
221
|
const pendingWatcherPaths = new Map<string, string>();
|
|
203
222
|
let pendingWatcherOriginalBatch: TreeChangeBatch | null = null;
|
|
@@ -236,8 +255,8 @@ export async function runWatchLoop(
|
|
|
236
255
|
pendingWatcherBareChange = true;
|
|
237
256
|
};
|
|
238
257
|
const takePendingWatcherChange = (): {
|
|
239
|
-
rel: string | null;
|
|
240
258
|
batch: TreeChangeBatch | null;
|
|
259
|
+
bare: boolean;
|
|
241
260
|
} => {
|
|
242
261
|
const batch =
|
|
243
262
|
pendingWatcherOriginalBatch !== null && !pendingWatcherOverflowed
|
|
@@ -254,15 +273,81 @@ export async function runWatchLoop(
|
|
|
254
273
|
: {}),
|
|
255
274
|
}
|
|
256
275
|
: null;
|
|
257
|
-
const
|
|
258
|
-
const fallbackRel = rel ?? (pendingWatcherBareChange ? null : null);
|
|
276
|
+
const bare = pendingWatcherBareChange;
|
|
259
277
|
pendingWatcherPaths.clear();
|
|
260
278
|
pendingWatcherOriginalBatch = null;
|
|
261
279
|
pendingWatcherBareChange = false;
|
|
262
280
|
pendingWatcherOverflowed = false;
|
|
263
281
|
pendingWatcherDroppedPaths = 0;
|
|
264
282
|
pendingWatcherDroppedBytes = 0;
|
|
265
|
-
return {
|
|
283
|
+
return { batch, bare };
|
|
284
|
+
};
|
|
285
|
+
const restorePendingWatcherPaths = (paths: Map<string, string>): void => {
|
|
286
|
+
pendingWatcherOriginalBatch = null;
|
|
287
|
+
for (const [absolutePath, relativePath] of paths.entries()) {
|
|
288
|
+
pendingWatcherPaths.set(absolutePath, relativePath);
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const groupBatchByRoute = (
|
|
293
|
+
batch: TreeChangeBatch,
|
|
294
|
+
): Map<
|
|
295
|
+
string,
|
|
296
|
+
{ route: WatchRoute; paths: Map<string, string>; relPaths: string[] }
|
|
297
|
+
> => {
|
|
298
|
+
const grouped = new Map<
|
|
299
|
+
string,
|
|
300
|
+
{ route: WatchRoute; paths: Map<string, string>; relPaths: string[] }
|
|
301
|
+
>();
|
|
302
|
+
for (const [absolutePath, relativePath] of batch.paths.entries()) {
|
|
303
|
+
const route = routeChangeToTarget(relativePath);
|
|
304
|
+
if (!route) continue;
|
|
305
|
+
const key = routeKey(route);
|
|
306
|
+
const existing =
|
|
307
|
+
grouped.get(key) ??
|
|
308
|
+
{ route, paths: new Map<string, string>(), relPaths: [] };
|
|
309
|
+
existing.paths.set(absolutePath, relativePath);
|
|
310
|
+
existing.relPaths = [...new Set([...existing.paths.values()])];
|
|
311
|
+
grouped.set(key, existing);
|
|
312
|
+
}
|
|
313
|
+
return grouped;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const publishBatchIfAvailable = (paths: Map<string, string>): void => {
|
|
317
|
+
if (!stopped && eventSync && paths.size > 0) {
|
|
318
|
+
eventSync.publishBatch({ paths: new Map(paths) });
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const runScopedDrain = async (
|
|
323
|
+
batch: TreeChangeBatch | null,
|
|
324
|
+
): Promise<number> => {
|
|
325
|
+
if (drainRunsPush && batch && batch.paths.size > 0) {
|
|
326
|
+
const grouped = groupBatchByRoute(batch);
|
|
327
|
+
for (const group of grouped.values()) {
|
|
328
|
+
const scopedArgv = buildScopedPushArgv(
|
|
329
|
+
group.route,
|
|
330
|
+
group.relPaths,
|
|
331
|
+
passArgv,
|
|
332
|
+
);
|
|
333
|
+
try {
|
|
334
|
+
const result = await runGuarded(scopedArgv);
|
|
335
|
+
if (result === 0) {
|
|
336
|
+
publishBatchIfAvailable(group.paths);
|
|
337
|
+
} else {
|
|
338
|
+
restorePendingWatcherPaths(group.paths);
|
|
339
|
+
}
|
|
340
|
+
} catch (err) {
|
|
341
|
+
restorePendingWatcherPaths(group.paths);
|
|
342
|
+
process.stderr.write(
|
|
343
|
+
`watch scoped push failed, will retry next tick: ${describeError(err)}\n`,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (!drainRunsPull) return 0;
|
|
350
|
+
return runGuarded(buildScopedDrainPullArgv(passArgv));
|
|
266
351
|
};
|
|
267
352
|
|
|
268
353
|
let receiver: PushReceiver | null = null;
|
|
@@ -282,47 +367,9 @@ export async function runWatchLoop(
|
|
|
282
367
|
}));
|
|
283
368
|
watcher = createWatcher({ hqRoot, debounceMs, clock });
|
|
284
369
|
|
|
285
|
-
driver = new WatchPushDriver({
|
|
286
|
-
debounceMs: 0,
|
|
287
|
-
clock,
|
|
288
|
-
push: async () => {
|
|
289
|
-
if (stopped) return;
|
|
290
|
-
const { rel, batch: batchForPublish } = takePendingWatcherChange();
|
|
291
|
-
const batchRoutes = batchForPublish
|
|
292
|
-
? routesForBatch(batchForPublish)
|
|
293
|
-
: new Map<string, WatchRoute>();
|
|
294
|
-
let targetedArgv: string[];
|
|
295
|
-
if (batchForPublish?.overflowed || batchRoutes.size > 1) {
|
|
296
|
-
targetedArgv = buildFullFanoutPushArgv(passArgv);
|
|
297
|
-
} else {
|
|
298
|
-
const route =
|
|
299
|
-
batchRoutes.size === 1
|
|
300
|
-
? [...batchRoutes.values()][0]
|
|
301
|
-
: rel
|
|
302
|
-
? routeChangeToTarget(rel)
|
|
303
|
-
: { kind: "personal" as const };
|
|
304
|
-
if (!route) return;
|
|
305
|
-
targetedArgv = buildTargetedPushArgv(route, passArgv);
|
|
306
|
-
}
|
|
307
|
-
const result = await runGuarded(targetedArgv);
|
|
308
|
-
if (
|
|
309
|
-
result === 0 &&
|
|
310
|
-
!stopped &&
|
|
311
|
-
eventSync &&
|
|
312
|
-
!batchForPublish?.overflowed
|
|
313
|
-
) {
|
|
314
|
-
const batch: TreeChangeBatch | null =
|
|
315
|
-
batchForPublish ??
|
|
316
|
-
(rel ? { paths: new Map([[path.join(hqRoot, rel), rel]]) } : null);
|
|
317
|
-
if (batch) eventSync.publishBatch(batch);
|
|
318
|
-
}
|
|
319
|
-
},
|
|
320
|
-
});
|
|
321
|
-
|
|
322
370
|
watcher.onChange((changedRelPath, batch) => {
|
|
323
371
|
if (stopped) return;
|
|
324
372
|
addPendingWatcherChange(changedRelPath, batch);
|
|
325
|
-
driver?.notifyChange();
|
|
326
373
|
});
|
|
327
374
|
watcher.start();
|
|
328
375
|
|
|
@@ -402,11 +449,6 @@ export async function runWatchLoop(
|
|
|
402
449
|
/* ignore */
|
|
403
450
|
}
|
|
404
451
|
eventSync = null;
|
|
405
|
-
try {
|
|
406
|
-
driver?.dispose();
|
|
407
|
-
} catch {
|
|
408
|
-
/* ignore */
|
|
409
|
-
}
|
|
410
452
|
try {
|
|
411
453
|
watcher?.dispose();
|
|
412
454
|
} catch {
|
|
@@ -427,10 +469,29 @@ export async function runWatchLoop(
|
|
|
427
469
|
});
|
|
428
470
|
detachSignal = onShutdownSignal(shutdown);
|
|
429
471
|
|
|
472
|
+
let pollTick = 0;
|
|
430
473
|
try {
|
|
431
474
|
while (!stopped) {
|
|
432
|
-
|
|
433
|
-
|
|
475
|
+
pollTick++;
|
|
476
|
+
const pending = takePendingWatcherChange();
|
|
477
|
+
const shouldRunFull =
|
|
478
|
+
pollTick === 1 ||
|
|
479
|
+
pending.bare ||
|
|
480
|
+
pending.batch?.overflowed === true ||
|
|
481
|
+
pollTick % FULL_RECONCILE_EVERY_TICKS === 0;
|
|
482
|
+
const result = shouldRunFull
|
|
483
|
+
? await runGuarded(passArgv)
|
|
484
|
+
: await runScopedDrain(pending.batch);
|
|
485
|
+
if (result === TRANSIENT_NETWORK_EXIT) {
|
|
486
|
+
// The machine was briefly offline (the discovery fetch failed after
|
|
487
|
+
// retries). That is NOT a crash — stay alive and retry on the next
|
|
488
|
+
// poll. Returning it here would exit the watcher and the menubar would
|
|
489
|
+
// report "watcher exited unexpectedly" for every network blip
|
|
490
|
+
// (HQ-SYNC-1W). Log to stderr so it still shows up as a breadcrumb.
|
|
491
|
+
process.stderr.write(
|
|
492
|
+
"hq-sync-runner: watch pass skipped — transient network failure; retrying next poll\n",
|
|
493
|
+
);
|
|
494
|
+
} else if (result !== 0) {
|
|
434
495
|
return result;
|
|
435
496
|
}
|
|
436
497
|
await Promise.race([sleep(pollMs), stoppedSignal]);
|
|
@@ -37,10 +37,48 @@ export function buildTargetedPushArgv(
|
|
|
37
37
|
return ["--companies", "--direction", "push", ...carried];
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
export function buildScopedPushArgv(
|
|
41
|
+
route: WatchRoute,
|
|
42
|
+
relPaths: readonly string[],
|
|
43
|
+
baseArgv: string[],
|
|
44
|
+
): string[] {
|
|
45
|
+
const scopeArgs = scopedPathArgs(route, relPaths);
|
|
46
|
+
const carried = carriedFlags(baseArgv);
|
|
47
|
+
if (route.kind === "company") {
|
|
48
|
+
return [
|
|
49
|
+
"--company",
|
|
50
|
+
route.slug,
|
|
51
|
+
"--direction",
|
|
52
|
+
"push",
|
|
53
|
+
...scopeArgs,
|
|
54
|
+
...carried,
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
return ["--personal", "--direction", "push", ...scopeArgs, ...carried];
|
|
58
|
+
}
|
|
59
|
+
|
|
40
60
|
export function buildFullFanoutPushArgv(baseArgv: string[]): string[] {
|
|
41
61
|
return ["--companies", "--direction", "push", ...carriedFlags(baseArgv)];
|
|
42
62
|
}
|
|
43
63
|
|
|
64
|
+
export function buildScopedDrainPullArgv(baseArgv: string[]): string[] {
|
|
65
|
+
const carried = carriedFlags(baseArgv);
|
|
66
|
+
if (baseArgv.includes("--personal")) {
|
|
67
|
+
return ["--personal", "--direction", "pull", ...carried];
|
|
68
|
+
}
|
|
69
|
+
const companyIdx = baseArgv.indexOf("--company");
|
|
70
|
+
if (companyIdx >= 0 && baseArgv[companyIdx + 1] !== undefined) {
|
|
71
|
+
return [
|
|
72
|
+
"--company",
|
|
73
|
+
baseArgv[companyIdx + 1],
|
|
74
|
+
"--direction",
|
|
75
|
+
"pull",
|
|
76
|
+
...carried,
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
return ["--companies", "--direction", "pull", ...carried];
|
|
80
|
+
}
|
|
81
|
+
|
|
44
82
|
/**
|
|
45
83
|
* Build the argv for a targeted pull pass from a routed change.
|
|
46
84
|
*/
|
|
@@ -55,7 +93,7 @@ export function buildTargetedPullArgv(
|
|
|
55
93
|
return ["--companies", "--direction", "pull", ...carried];
|
|
56
94
|
}
|
|
57
95
|
|
|
58
|
-
function routeKey(route: WatchRoute): string {
|
|
96
|
+
export function routeKey(route: WatchRoute): string {
|
|
59
97
|
return route.kind === "company" ? `company:${route.slug}` : "personal";
|
|
60
98
|
}
|
|
61
99
|
|
|
@@ -69,6 +107,24 @@ export function routesForBatch(batch: TreeChangeBatch): Map<string, WatchRoute>
|
|
|
69
107
|
return routes;
|
|
70
108
|
}
|
|
71
109
|
|
|
110
|
+
function scopedPathArgs(route: WatchRoute, relPaths: readonly string[]): string[] {
|
|
111
|
+
const args: string[] = [];
|
|
112
|
+
for (const relPath of relPaths) {
|
|
113
|
+
const scopePath = scopePathForRoute(route, relPath);
|
|
114
|
+
if (scopePath === null) continue;
|
|
115
|
+
args.push("--scope-path", scopePath);
|
|
116
|
+
}
|
|
117
|
+
return args;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function scopePathForRoute(route: WatchRoute, relPath: string): string | null {
|
|
121
|
+
const norm = relPath.split(path.sep).join("/").replace(/^\.\//, "");
|
|
122
|
+
if (route.kind === "personal") return norm;
|
|
123
|
+
const prefix = `companies/${route.slug}/`;
|
|
124
|
+
if (norm.startsWith(prefix)) return norm.slice(prefix.length);
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
72
128
|
/**
|
|
73
129
|
* Extract the `--hq-root` / `--on-conflict` pair-flags from a base argv so a
|
|
74
130
|
* re-targeted pass inherits the same root and conflict policy.
|