@indigoai-us/hq-cloud 6.14.17 → 6.14.19
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/.github/workflows/ci.yml +35 -0
- package/dist/active-company.d.ts +43 -0
- package/dist/active-company.d.ts.map +1 -0
- package/dist/active-company.js +132 -0
- package/dist/active-company.js.map +1 -0
- package/dist/active-company.test.d.ts +2 -0
- package/dist/active-company.test.d.ts.map +1 -0
- package/dist/active-company.test.js +149 -0
- package/dist/active-company.test.js.map +1 -0
- package/dist/bin/sync-runner-company.d.ts +7 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +21 -1
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-planning.d.ts +27 -1
- package/dist/bin/sync-runner-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +33 -4
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner-planning.test.d.ts +2 -0
- package/dist/bin/sync-runner-planning.test.d.ts.map +1 -0
- package/dist/bin/sync-runner-planning.test.js +115 -0
- package/dist/bin/sync-runner-planning.test.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts +10 -2
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +201 -25
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-routes.js +14 -2
- package/dist/bin/sync-runner-watch-routes.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +39 -9
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +114 -20
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +852 -8
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/rescue-core.js +27 -1
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-drop-dir-symlink.test.d.ts +2 -0
- package/dist/cli/rescue-drop-dir-symlink.test.d.ts.map +1 -0
- package/dist/cli/rescue-drop-dir-symlink.test.js +206 -0
- package/dist/cli/rescue-drop-dir-symlink.test.js.map +1 -0
- package/dist/cli/share.d.ts +15 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +100 -55
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +119 -6
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +19 -25
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +121 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/journal.d.ts +3 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +13 -2
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +26 -1
- package/dist/journal.test.js.map +1 -1
- package/dist/manifest-reconcile.d.ts +118 -5
- package/dist/manifest-reconcile.d.ts.map +1 -1
- package/dist/manifest-reconcile.js +319 -62
- package/dist/manifest-reconcile.js.map +1 -1
- package/dist/manifest-reconcile.test.js +824 -2
- package/dist/manifest-reconcile.test.js.map +1 -1
- package/dist/personal-vault.d.ts +12 -0
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +20 -0
- package/dist/personal-vault.js.map +1 -1
- package/dist/qmd-reindex.d.ts +88 -11
- package/dist/qmd-reindex.d.ts.map +1 -1
- package/dist/qmd-reindex.js +354 -65
- package/dist/qmd-reindex.js.map +1 -1
- package/dist/qmd-reindex.test.d.ts +4 -0
- package/dist/qmd-reindex.test.d.ts.map +1 -1
- package/dist/qmd-reindex.test.js +285 -3
- package/dist/qmd-reindex.test.js.map +1 -1
- package/dist/s3.d.ts +13 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +34 -1
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +60 -1
- package/dist/s3.test.js.map +1 -1
- package/dist/telemetry.d.ts +22 -0
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +79 -0
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +117 -1
- package/dist/telemetry.test.js.map +1 -1
- package/dist/watcher.d.ts +26 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +75 -12
- package/dist/watcher.js.map +1 -1
- package/package.json +1 -1
- package/pnpm-workspace.yaml +1 -1
- package/src/active-company.test.ts +188 -0
- package/src/active-company.ts +168 -0
- package/src/bin/sync-runner-company.ts +29 -1
- package/src/bin/sync-runner-planning.test.ts +131 -0
- package/src/bin/sync-runner-planning.ts +60 -7
- package/src/bin/sync-runner-watch-loop.ts +322 -32
- package/src/bin/sync-runner-watch-routes.ts +14 -1
- package/src/bin/sync-runner.test.ts +1021 -16
- package/src/bin/sync-runner.ts +179 -28
- package/src/cli/rescue-core.ts +26 -1
- package/src/cli/rescue-drop-dir-symlink.test.ts +224 -0
- package/src/cli/share.test.ts +150 -6
- package/src/cli/share.ts +143 -48
- package/src/cli/sync.test.ts +150 -1
- package/src/cli/sync.ts +25 -29
- package/src/journal.test.ts +45 -0
- package/src/journal.ts +18 -1
- package/src/manifest-reconcile.test.ts +1019 -3
- package/src/manifest-reconcile.ts +424 -66
- package/src/personal-vault.ts +23 -0
- package/src/qmd-reindex.test.ts +324 -4
- package/src/qmd-reindex.ts +414 -76
- package/src/s3.test.ts +79 -0
- package/src/s3.ts +48 -1
- package/src/telemetry.test.ts +128 -0
- package/src/telemetry.ts +80 -0
- package/src/watcher.ts +111 -14
- package/test/e2e/watcher-real-chokidar.test.ts +62 -2
- package/test/e2e/watcher-recursive-backend.test.ts +67 -1
- package/test/joiner-manifest-reconcile.integration.test.ts +283 -0
package/src/bin/sync-runner.ts
CHANGED
|
@@ -125,7 +125,11 @@ import {
|
|
|
125
125
|
} from "../lib/net-errors.js";
|
|
126
126
|
import { PARTIAL_SYNC_EXIT } from "../lib/exit-codes.js";
|
|
127
127
|
import { describeError } from "../lib/describe-error.js";
|
|
128
|
-
import type {
|
|
128
|
+
import type {
|
|
129
|
+
Clock,
|
|
130
|
+
LocalDeleteSnapshot,
|
|
131
|
+
TreeChangeBatch,
|
|
132
|
+
} from "../watcher.js";
|
|
129
133
|
import type { PushReceiver, SyncEngineFn } from "../sync/push-receiver.js";
|
|
130
134
|
import {
|
|
131
135
|
type EventSyncHandles,
|
|
@@ -140,10 +144,18 @@ import {
|
|
|
140
144
|
buildFanoutPlan,
|
|
141
145
|
emitFanoutPlan,
|
|
142
146
|
resolveMembershipsForRun,
|
|
147
|
+
type RunnerTarget,
|
|
148
|
+
type SetupNeededReason,
|
|
143
149
|
} from "./sync-runner-planning.js";
|
|
150
|
+
import {
|
|
151
|
+
seedActiveCompany,
|
|
152
|
+
type SeedActiveCompanyOptions,
|
|
153
|
+
type SeedActiveCompanyResult,
|
|
154
|
+
} from "../active-company.js";
|
|
144
155
|
import {
|
|
145
156
|
reconcileCompanyManifest,
|
|
146
157
|
type ManifestReconcileOptions,
|
|
158
|
+
type ManifestReconcileResult,
|
|
147
159
|
} from "../manifest-reconcile.js";
|
|
148
160
|
import { executeCompanyFanout } from "./sync-runner-company.js";
|
|
149
161
|
import { rollupAllComplete } from "./sync-runner-rollup.js";
|
|
@@ -333,11 +345,28 @@ export { PERSONAL_VAULT_EXCLUDED_TOP_LEVEL, computePersonalVaultPaths };
|
|
|
333
345
|
* specific company".
|
|
334
346
|
*/
|
|
335
347
|
export type RunnerEvent =
|
|
336
|
-
| {
|
|
348
|
+
| {
|
|
349
|
+
/**
|
|
350
|
+
* The run cannot proceed. `reason` and `pendingInviteCount` are additive
|
|
351
|
+
* and optional by design: a consumer that only reads `type` behaves
|
|
352
|
+
* exactly as before, while /setup and /hq-sync can tell the user WHY they
|
|
353
|
+
* look solo — most usefully, that an invite is still waiting.
|
|
354
|
+
*/
|
|
355
|
+
type: "setup-needed";
|
|
356
|
+
reason?: SetupNeededReason;
|
|
357
|
+
pendingInviteCount?: number;
|
|
358
|
+
}
|
|
337
359
|
| { type: "auth-error"; message: string }
|
|
338
360
|
| {
|
|
361
|
+
/**
|
|
362
|
+
* The resolved fanout plan. Carries whole `RunnerTarget`s: the runtime
|
|
363
|
+
* payload has always been the plan entries verbatim, so the previous
|
|
364
|
+
* `{uid, slug, name?}` declaration understated the wire contract (it
|
|
365
|
+
* already omitted `bucketName`, `personalMode`, and `journalSlug`).
|
|
366
|
+
* Consumers deserialize leniently and ignore fields they do not know.
|
|
367
|
+
*/
|
|
339
368
|
type: "fanout-plan";
|
|
340
|
-
companies:
|
|
369
|
+
companies: RunnerTarget[];
|
|
341
370
|
}
|
|
342
371
|
| ({
|
|
343
372
|
/**
|
|
@@ -632,11 +661,15 @@ export interface RunnerDeps {
|
|
|
632
661
|
*/
|
|
633
662
|
reconcileManifest?: (
|
|
634
663
|
options: ManifestReconcileOptions,
|
|
635
|
-
) => Promise<
|
|
664
|
+
) => Promise<ManifestReconcileResult>;
|
|
665
|
+
/** Internal test seam for `.hq/config.json` activeCompany seeding. */
|
|
666
|
+
seedActiveCompany?: (options: SeedActiveCompanyOptions) => SeedActiveCompanyResult;
|
|
636
667
|
/** Internal: set when runRunner is invoked under the per-root operation lock. */
|
|
637
668
|
operationLockAlreadyHeld?: boolean;
|
|
638
669
|
/** Internal watch-loop result override; one-shot callers keep exit 0. */
|
|
639
670
|
authRequiredExitCode?: number;
|
|
671
|
+
/** Internal watch-loop callback carrying uncapped per-path push outcomes. */
|
|
672
|
+
onPassResult?: (result: RunnerPassResult) => void;
|
|
640
673
|
/** Share function (push phase). Defaults to `cli/share.share`. */
|
|
641
674
|
share?: (options: ShareOptions) => Promise<ShareResult>;
|
|
642
675
|
/**
|
|
@@ -652,6 +685,20 @@ export interface RunnerDeps {
|
|
|
652
685
|
telemetryTimeoutMs?: number;
|
|
653
686
|
}
|
|
654
687
|
|
|
688
|
+
export interface RunnerPassResult {
|
|
689
|
+
pushPathResults: Array<{
|
|
690
|
+
relativePath: string;
|
|
691
|
+
status: "accepted" | "refused";
|
|
692
|
+
operation: "delete" | "tombstone";
|
|
693
|
+
reason?: string;
|
|
694
|
+
}>;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
export interface RunnerPassOutcome {
|
|
698
|
+
exitCode: number;
|
|
699
|
+
result?: RunnerPassResult;
|
|
700
|
+
}
|
|
701
|
+
|
|
655
702
|
// ---------------------------------------------------------------------------
|
|
656
703
|
// JWT claim decoder — inlined to avoid pulling a dep just to read an idToken.
|
|
657
704
|
// We do NOT verify the signature here — Cognito already did that when it
|
|
@@ -698,10 +745,11 @@ async function runClaimDance(
|
|
|
698
745
|
client: VaultClientSurface,
|
|
699
746
|
claims: IdTokenClaims,
|
|
700
747
|
stderr: { write: (chunk: string) => boolean | void },
|
|
701
|
-
|
|
748
|
+
reportDiagnostic: RunnerDiagnosticReporter = () => undefined,
|
|
749
|
+
): Promise<number> {
|
|
702
750
|
try {
|
|
703
751
|
const pending = await client.listMyPendingInvitesByEmail();
|
|
704
|
-
if (pending.length === 0) return;
|
|
752
|
+
if (pending.length === 0) return 0;
|
|
705
753
|
|
|
706
754
|
const displayName =
|
|
707
755
|
claims.name ??
|
|
@@ -710,10 +758,15 @@ async function runClaimDance(
|
|
|
710
758
|
"";
|
|
711
759
|
const ownerSub = claims.sub ?? "";
|
|
712
760
|
if (!ownerSub || !displayName) {
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
761
|
+
reportDiagnostic({
|
|
762
|
+
component: "claim-dance",
|
|
763
|
+
event: "runner.claim_dance.skipped",
|
|
764
|
+
message: "skipping claim-dance — idToken missing sub/name",
|
|
765
|
+
context: { pendingInviteCount: pending.length },
|
|
766
|
+
});
|
|
767
|
+
// The invites are real and still pending; the caller must be able to say
|
|
768
|
+
// so even though we could not claim them.
|
|
769
|
+
return pending.length;
|
|
717
770
|
}
|
|
718
771
|
|
|
719
772
|
const person = await client.ensureMyPersonEntity({
|
|
@@ -721,9 +774,19 @@ async function runClaimDance(
|
|
|
721
774
|
displayName,
|
|
722
775
|
});
|
|
723
776
|
await client.claimPendingInvitesByEmail(person.uid);
|
|
777
|
+
return pending.length;
|
|
724
778
|
} catch (err) {
|
|
725
|
-
|
|
726
|
-
|
|
779
|
+
// Non-throwing by contract: a claim-dance failure must never take down a
|
|
780
|
+
// sync. But it stops being invisible — a systematic failure here is exactly
|
|
781
|
+
// what makes a joiner look permanently solo.
|
|
782
|
+
reportDiagnostic({
|
|
783
|
+
component: "claim-dance",
|
|
784
|
+
event: "runner.claim_dance.skipped",
|
|
785
|
+
message: "claim-dance skipped",
|
|
786
|
+
err,
|
|
787
|
+
context: {},
|
|
788
|
+
});
|
|
789
|
+
return 0;
|
|
727
790
|
}
|
|
728
791
|
}
|
|
729
792
|
|
|
@@ -769,6 +832,11 @@ interface ParsedArgs {
|
|
|
769
832
|
skipPersonal: boolean;
|
|
770
833
|
/** Repeatable company/personal-relative path scope for push-only passes. */
|
|
771
834
|
scopePaths: string[];
|
|
835
|
+
/**
|
|
836
|
+
* Internal watcher-only delete roots. Unlike `scopePaths`, these remain
|
|
837
|
+
* meaningful after the corresponding local file or directory is absent.
|
|
838
|
+
*/
|
|
839
|
+
deleteScopePaths: string[];
|
|
772
840
|
/**
|
|
773
841
|
* Bounded wait (seconds) for the per-root operation lock when another op is
|
|
774
842
|
* already running. `0` → refuse immediately (pre-wait behavior); omitted →
|
|
@@ -789,6 +857,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
789
857
|
let pollRemoteMs: number | undefined;
|
|
790
858
|
let skipPersonal = false;
|
|
791
859
|
const scopePaths: string[] = [];
|
|
860
|
+
const deleteScopePaths: string[] = [];
|
|
792
861
|
let eventPush = false;
|
|
793
862
|
let lockTimeoutSec: number | undefined;
|
|
794
863
|
|
|
@@ -870,6 +939,12 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
870
939
|
scopePaths.push(val);
|
|
871
940
|
break;
|
|
872
941
|
}
|
|
942
|
+
case "--delete-scope-path": {
|
|
943
|
+
const val = argv[++i];
|
|
944
|
+
if (!val) return { error: "--delete-scope-path requires a value" };
|
|
945
|
+
deleteScopePaths.push(val);
|
|
946
|
+
break;
|
|
947
|
+
}
|
|
873
948
|
case "--lock-timeout": {
|
|
874
949
|
const val = argv[++i];
|
|
875
950
|
if (!val) return { error: "--lock-timeout requires a value (seconds)" };
|
|
@@ -921,6 +996,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
921
996
|
pollRemoteMs,
|
|
922
997
|
skipPersonal,
|
|
923
998
|
scopePaths,
|
|
999
|
+
deleteScopePaths,
|
|
924
1000
|
eventPush,
|
|
925
1001
|
lockTimeoutSec,
|
|
926
1002
|
};
|
|
@@ -1276,11 +1352,18 @@ export async function runRunner(
|
|
|
1276
1352
|
client,
|
|
1277
1353
|
claims,
|
|
1278
1354
|
stderr,
|
|
1279
|
-
runClaimDance,
|
|
1355
|
+
runClaimDance: (client, claims, stderr) =>
|
|
1356
|
+
runClaimDance(client, claims, stderr, reportDiagnostic),
|
|
1280
1357
|
listMemberships: listMembershipsWithRetry,
|
|
1281
1358
|
});
|
|
1282
1359
|
if (resolution.status === "setup-needed") {
|
|
1283
|
-
emit({
|
|
1360
|
+
emit({
|
|
1361
|
+
type: "setup-needed",
|
|
1362
|
+
reason: resolution.reason,
|
|
1363
|
+
...(resolution.pendingInviteCount === undefined
|
|
1364
|
+
? {}
|
|
1365
|
+
: { pendingInviteCount: resolution.pendingInviteCount }),
|
|
1366
|
+
});
|
|
1284
1367
|
return 0;
|
|
1285
1368
|
}
|
|
1286
1369
|
memberships = resolution.memberships;
|
|
@@ -1335,7 +1418,7 @@ export async function runRunner(
|
|
|
1335
1418
|
return isTransientNetworkError(err) ? TRANSIENT_NETWORK_EXIT : 1;
|
|
1336
1419
|
}
|
|
1337
1420
|
if (targetPlan.status === "setup-needed") {
|
|
1338
|
-
emit({ type: "setup-needed" });
|
|
1421
|
+
emit({ type: "setup-needed", reason: targetPlan.reason });
|
|
1339
1422
|
return 0;
|
|
1340
1423
|
}
|
|
1341
1424
|
const plan = targetPlan.plan;
|
|
@@ -1368,6 +1451,7 @@ export async function runRunner(
|
|
|
1368
1451
|
resolveDeletePolicy,
|
|
1369
1452
|
emit,
|
|
1370
1453
|
scopePaths: parsed.scopePaths,
|
|
1454
|
+
deleteScopePaths: parsed.deleteScopePaths,
|
|
1371
1455
|
telemetryClient: client,
|
|
1372
1456
|
telemetryClaims: claims,
|
|
1373
1457
|
telemetrySessionId,
|
|
@@ -1381,24 +1465,49 @@ export async function runRunner(
|
|
|
1381
1465
|
throw err;
|
|
1382
1466
|
}
|
|
1383
1467
|
const { errors, allConflicts, meaningfulOutcomeCount } = fanout;
|
|
1468
|
+
deps.onPassResult?.({ pushPathResults: fanout.pushPathResults });
|
|
1384
1469
|
const rollup = rollupAllComplete(plan, fanout.stateByCompany);
|
|
1385
1470
|
|
|
1386
1471
|
// The personal-vault target runs last and can pull an older copy of the
|
|
1387
1472
|
// manifest. Reconcile only after the entire pull fanout has settled so the
|
|
1388
1473
|
// successfully materialized cloud directories restore their own entries.
|
|
1389
|
-
|
|
1474
|
+
//
|
|
1475
|
+
// `--companies` only. A single-company run (`--company <slug>`) and a
|
|
1476
|
+
// `--personal` run both see just one leg of the fanout, so neither has the
|
|
1477
|
+
// full picture the manifest is reconciled against; treating a partial view as
|
|
1478
|
+
// authoritative is how entries get lost. A push-only pass materializes
|
|
1479
|
+
// nothing new locally, so it has nothing to reconcile either.
|
|
1480
|
+
if (parsed.companies && parsed.direction !== "push") {
|
|
1481
|
+
const completedCompanySlugs = new Set(
|
|
1482
|
+
Array.from(fanout.stateByCompany.entries())
|
|
1483
|
+
.filter(([, state]) => state.status === "complete")
|
|
1484
|
+
.map(([slug]) => slug),
|
|
1485
|
+
);
|
|
1486
|
+
// The personal vault is never a manifest entry. The reconciler filters it
|
|
1487
|
+
// too, but that is its own internal invariant — excluding it here means
|
|
1488
|
+
// the personal target is never even handed across the seam.
|
|
1489
|
+
const companyTargets = plan.filter(
|
|
1490
|
+
(target) => target.personalMode !== true && target.slug !== "personal",
|
|
1491
|
+
);
|
|
1390
1492
|
try {
|
|
1391
|
-
const
|
|
1392
|
-
Array.from(fanout.stateByCompany.entries())
|
|
1393
|
-
.filter(([, state]) => state.status === "complete")
|
|
1394
|
-
.map(([slug]) => slug),
|
|
1395
|
-
);
|
|
1396
|
-
await (deps.reconcileManifest ?? reconcileCompanyManifest)({
|
|
1493
|
+
const result = await (deps.reconcileManifest ?? reconcileCompanyManifest)({
|
|
1397
1494
|
hqRoot: parsed.hqRoot,
|
|
1398
|
-
targets:
|
|
1495
|
+
targets: companyTargets,
|
|
1399
1496
|
completedCompanySlugs,
|
|
1400
|
-
|
|
1497
|
+
reportDiagnostic: (diagnostic) =>
|
|
1498
|
+
reportDiagnostic({ component: "manifest-reconcile", ...diagnostic }),
|
|
1401
1499
|
});
|
|
1500
|
+
// Only on an actual write. A steady-state sync stays silent, so an
|
|
1501
|
+
// unexpected diagnostic rate is itself the signal that skip-if-unchanged
|
|
1502
|
+
// has regressed.
|
|
1503
|
+
if (result.written) {
|
|
1504
|
+
reportDiagnostic({
|
|
1505
|
+
component: "manifest-reconcile",
|
|
1506
|
+
event: "manifest-reconciled",
|
|
1507
|
+
message: "restored company entries in companies/manifest.yaml",
|
|
1508
|
+
context: { added: result.added, updated: result.updated },
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1402
1511
|
} catch (err) {
|
|
1403
1512
|
reportDiagnostic({
|
|
1404
1513
|
component: "manifest-reconcile",
|
|
@@ -1408,6 +1517,38 @@ export async function runRunner(
|
|
|
1408
1517
|
context: { hqRoot: parsed.hqRoot },
|
|
1409
1518
|
});
|
|
1410
1519
|
}
|
|
1520
|
+
|
|
1521
|
+
// A joiner whose manifest entry was just restored still has nothing telling
|
|
1522
|
+
// HQ which company is current. Seed it only when the answer cannot be wrong
|
|
1523
|
+
// — deliberately its own try, so a manifest failure does not also cost the
|
|
1524
|
+
// user their routing.
|
|
1525
|
+
try {
|
|
1526
|
+
const localCompanySlugs = companyTargets
|
|
1527
|
+
.map((target) => target.slug)
|
|
1528
|
+
.filter((slug) => completedCompanySlugs.has(slug));
|
|
1529
|
+
const seeded = (deps.seedActiveCompany ?? seedActiveCompany)({
|
|
1530
|
+
hqRoot: parsed.hqRoot,
|
|
1531
|
+
companySlugs: localCompanySlugs,
|
|
1532
|
+
reportDiagnostic: (diagnostic) =>
|
|
1533
|
+
reportDiagnostic({ component: "active-company", ...diagnostic }),
|
|
1534
|
+
});
|
|
1535
|
+
if (seeded.written) {
|
|
1536
|
+
reportDiagnostic({
|
|
1537
|
+
component: "active-company",
|
|
1538
|
+
event: "active-company-seeded",
|
|
1539
|
+
message: "seeded activeCompany in .hq/config.json",
|
|
1540
|
+
context: { activeCompany: seeded.activeCompany },
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
} catch (err) {
|
|
1544
|
+
reportDiagnostic({
|
|
1545
|
+
component: "active-company",
|
|
1546
|
+
event: "runner.active_company.failed",
|
|
1547
|
+
message: "seeding activeCompany failed",
|
|
1548
|
+
err,
|
|
1549
|
+
context: { hqRoot: parsed.hqRoot },
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1411
1552
|
}
|
|
1412
1553
|
|
|
1413
1554
|
if (rollup.needsReindex) {
|
|
@@ -1754,7 +1895,7 @@ export interface RunnerLoopDeps {
|
|
|
1754
1895
|
* targeted push share one seam and one in-flight guard. The default ignores
|
|
1755
1896
|
* `deps` and forwards just the argv to `runRunner`.
|
|
1756
1897
|
*/
|
|
1757
|
-
runPass?: (passArgv: string[]) => Promise<number>;
|
|
1898
|
+
runPass?: (passArgv: string[]) => Promise<number | RunnerPassOutcome>;
|
|
1758
1899
|
/** Stage-0 backup retention backstop: a dry-run only, never a sync-tick action. */
|
|
1759
1900
|
runBackupPrune?: (hqRoot: string) => Promise<void>;
|
|
1760
1901
|
/**
|
|
@@ -1772,6 +1913,10 @@ export interface RunnerLoopDeps {
|
|
|
1772
1913
|
hqRoot: string;
|
|
1773
1914
|
debounceMs: number;
|
|
1774
1915
|
clock: Clock;
|
|
1916
|
+
captureLocalDeleteSnapshots: (
|
|
1917
|
+
relativePath: string,
|
|
1918
|
+
kind: "unlink" | "unlinkDir",
|
|
1919
|
+
) => LocalDeleteSnapshot[];
|
|
1775
1920
|
}) => WatcherSurface;
|
|
1776
1921
|
/**
|
|
1777
1922
|
* Register a one-shot shutdown signal handler. Defaults to listening for
|
|
@@ -1849,11 +1994,17 @@ export async function runRunnerWithLoop(
|
|
|
1849
1994
|
): Promise<number> {
|
|
1850
1995
|
const parsed = parseArgs(argv);
|
|
1851
1996
|
const runtime = {
|
|
1852
|
-
runPassWithOperationLockAlreadyHeld: (passArgv: string[]) =>
|
|
1853
|
-
|
|
1997
|
+
runPassWithOperationLockAlreadyHeld: async (passArgv: string[]) => {
|
|
1998
|
+
let result: RunnerPassResult | undefined;
|
|
1999
|
+
const exitCode = await runRunner(passArgv, {
|
|
1854
2000
|
operationLockAlreadyHeld: true,
|
|
1855
2001
|
authRequiredExitCode: AUTH_REQUIRED_PASS_EXIT,
|
|
1856
|
-
|
|
2002
|
+
onPassResult: (passResult) => {
|
|
2003
|
+
result = passResult;
|
|
2004
|
+
},
|
|
2005
|
+
});
|
|
2006
|
+
return { exitCode, ...(result ? { result } : {}) };
|
|
2007
|
+
},
|
|
1857
2008
|
authRequiredPassExit: AUTH_REQUIRED_PASS_EXIT,
|
|
1858
2009
|
defaultGetIdTokenClaims,
|
|
1859
2010
|
defaultGetAccessToken: () =>
|
package/src/cli/rescue-core.ts
CHANGED
|
@@ -1515,6 +1515,31 @@ function masterSyncSymlinkTarget(localPath: string): string | null {
|
|
|
1515
1515
|
return tgt.includes("/personal/") || tgt.startsWith("personal/") ? tgt : null;
|
|
1516
1516
|
}
|
|
1517
1517
|
|
|
1518
|
+
/**
|
|
1519
|
+
* Drop a reindex symlink with lstat + unlink semantics: treat the path as a
|
|
1520
|
+
* link and remove the link itself, never following or recursing into its
|
|
1521
|
+
* target.
|
|
1522
|
+
*
|
|
1523
|
+
* `fs.rmSync(p, { force: true })` stat-follows on some platforms (notably
|
|
1524
|
+
* macOS): a symlink whose target is a directory is seen as a directory, and
|
|
1525
|
+
* with `recursive` unset it throws `EISDIR` ("Path is a directory"). During a
|
|
1526
|
+
* rescue apply that abort rolls back the whole update — the exact failure this
|
|
1527
|
+
* guards against (a directory-target overlay symlink such as
|
|
1528
|
+
* core/knowledge/<name> -> personal/knowledge/<name>). `unlink(2)` removes the
|
|
1529
|
+
* link regardless of what it points at, so file- and directory-target reindex
|
|
1530
|
+
* symlinks are dropped identically. reindex.sh re-lays the link afterward.
|
|
1531
|
+
*
|
|
1532
|
+
* A missing path is a no-op (force-equivalent); a non-symlink is left untouched
|
|
1533
|
+
* so we can never recurse into or delete a real directory (the classifier only
|
|
1534
|
+
* ever routes real symlinks here — this stays defensive).
|
|
1535
|
+
*/
|
|
1536
|
+
function dropReindexSymlink(localPath: string): void {
|
|
1537
|
+
const st = lstatOrNull(localPath);
|
|
1538
|
+
if (!st) return; // already gone — nothing to drop
|
|
1539
|
+
if (!st.isSymbolicLink()) return; // never follow/recurse into a real target
|
|
1540
|
+
fs.unlinkSync(localPath);
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1518
1543
|
function isUnderPreserve(cfg: Config, rel: string): boolean {
|
|
1519
1544
|
for (const sp of cfg.preserveSubpaths) {
|
|
1520
1545
|
if (rel === sp || rel.startsWith(sp + "/")) return true;
|
|
@@ -1972,7 +1997,7 @@ function buildRescueActions(ctx: WalkCtx): RescueAction[] {
|
|
|
1972
1997
|
break;
|
|
1973
1998
|
case "drop-reindex-symlink":
|
|
1974
1999
|
add(`drop reindex symlink: ${decision.rel}`, [decision.rel], () => {
|
|
1975
|
-
|
|
2000
|
+
dropReindexSymlink(localPath);
|
|
1976
2001
|
ctx.appendLog(`symlink-dropped\t${decision.rel}\t(reindex regenerable)\n`);
|
|
1977
2002
|
});
|
|
1978
2003
|
break;
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression for the rescue "drop reindex symlink" fix in
|
|
3
|
+
* src/cli/rescue-core.ts (dropReindexSymlink).
|
|
4
|
+
*
|
|
5
|
+
* The bug: the apply step removed a reindex overlay symlink with
|
|
6
|
+
* `fs.rmSync(localPath, { force: true })`. `rmSync` stat-FOLLOWS on some
|
|
7
|
+
* platforms (notably macOS), so a symlink whose target is a directory — e.g.
|
|
8
|
+
* core/knowledge/ad-creative-engine -> personal/knowledge/ad-creative-engine —
|
|
9
|
+
* is seen as a directory and, with `recursive` unset, throws `EISDIR`
|
|
10
|
+
* ("Path is a directory"). During a rescue apply that abort rolls back the
|
|
11
|
+
* whole update, so /update-hq fails on any install carrying a directory-target
|
|
12
|
+
* overlay symlink. The fix removes the link with lstat + unlink semantics
|
|
13
|
+
* (`fs.unlinkSync`), which drops the link itself regardless of what it points
|
|
14
|
+
* at and never recurses into the target.
|
|
15
|
+
*
|
|
16
|
+
* The macOS `rmSync` follow-behavior is not reproducible with a plain path on
|
|
17
|
+
* Linux CI, so we reproduce it deterministically: `fs` is mocked so `rmSync`
|
|
18
|
+
* throws `EISDIR` on exactly a directory-target symlink (every other call is
|
|
19
|
+
* delegated to the real implementation). Under that fault the OLD code aborts
|
|
20
|
+
* the apply and rolls the whole update back; the fixed code drops the link with
|
|
21
|
+
* `unlinkSync` and the rescue still succeeds.
|
|
22
|
+
*
|
|
23
|
+
* Mirrors rescue-mtime-preserve.test.ts: shim `git clone` to a local fixture
|
|
24
|
+
* and run the real rescue (non-dry-run, --no-backup) so the overlay lays files
|
|
25
|
+
* down and the symlink-drop action really runs.
|
|
26
|
+
*/
|
|
27
|
+
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
|
28
|
+
import { execFileSync } from "child_process";
|
|
29
|
+
import * as fs from "fs";
|
|
30
|
+
import * as os from "os";
|
|
31
|
+
import * as path from "path";
|
|
32
|
+
import { runRescue } from "./rescue-core.js";
|
|
33
|
+
|
|
34
|
+
// Simulate the reporter's platform (macOS): fs.rmSync stat-follows a
|
|
35
|
+
// symlink-to-directory and throws EISDIR ("Path is a directory"). Only the
|
|
36
|
+
// dir-target symlink trips this; every other rmSync call — and every other fs
|
|
37
|
+
// function — is the real implementation, so clone/overlay/backup all work.
|
|
38
|
+
vi.mock("fs", async (importOriginal) => {
|
|
39
|
+
const real = await importOriginal<typeof import("fs")>();
|
|
40
|
+
const patchedRmSync: typeof real.rmSync = ((p: fs.PathLike, opts?: fs.RmOptions) => {
|
|
41
|
+
try {
|
|
42
|
+
const lst = real.lstatSync(p as fs.PathLike);
|
|
43
|
+
if (lst.isSymbolicLink() && real.statSync(p as fs.PathLike).isDirectory()) {
|
|
44
|
+
const err = new Error(
|
|
45
|
+
`EISDIR: illegal operation on a directory, Path is a directory: ${String(p)}`,
|
|
46
|
+
) as NodeJS.ErrnoException;
|
|
47
|
+
err.code = "ERR_FS_EISDIR";
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
} catch (e) {
|
|
51
|
+
if ((e as NodeJS.ErrnoException).code === "ERR_FS_EISDIR") throw e;
|
|
52
|
+
// lstat/stat failed (missing path / broken link) — fall through to real rm.
|
|
53
|
+
}
|
|
54
|
+
return real.rmSync(p, opts);
|
|
55
|
+
}) as typeof real.rmSync;
|
|
56
|
+
return { ...real, rmSync: patchedRmSync };
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
function has(bin: string, ...args: string[]): boolean {
|
|
60
|
+
try {
|
|
61
|
+
execFileSync(bin, args, { stdio: "ignore" });
|
|
62
|
+
return true;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const toolsAvailable = has("git", "--version") && has("rsync", "--version");
|
|
68
|
+
|
|
69
|
+
const EPOCH = 1609459200; // 2021-01-01T00:00:00Z
|
|
70
|
+
|
|
71
|
+
/** Run the rescue in-process, capturing its stdout/stderr. */
|
|
72
|
+
function runRescueCapture(argv: string[], env: NodeJS.ProcessEnv) {
|
|
73
|
+
let stdout = "";
|
|
74
|
+
let stderr = "";
|
|
75
|
+
const origOut = process.stdout.write.bind(process.stdout);
|
|
76
|
+
const origErr = process.stderr.write.bind(process.stderr);
|
|
77
|
+
process.stdout.write = ((chunk: unknown) => {
|
|
78
|
+
stdout += String(chunk);
|
|
79
|
+
return true;
|
|
80
|
+
}) as typeof process.stdout.write;
|
|
81
|
+
process.stderr.write = ((chunk: unknown) => {
|
|
82
|
+
stderr += String(chunk);
|
|
83
|
+
return true;
|
|
84
|
+
}) as typeof process.stderr.write;
|
|
85
|
+
let status: number;
|
|
86
|
+
try {
|
|
87
|
+
status = runRescue(argv, { env }).status;
|
|
88
|
+
} finally {
|
|
89
|
+
process.stdout.write = origOut;
|
|
90
|
+
process.stderr.write = origErr;
|
|
91
|
+
}
|
|
92
|
+
return { status, stdout, stderr };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe.skipIf(!toolsAvailable)("rescue drops directory-target reindex symlinks", () => {
|
|
96
|
+
let workDir: string;
|
|
97
|
+
let upstream: string;
|
|
98
|
+
let shimDir: string;
|
|
99
|
+
let floorSha: string;
|
|
100
|
+
let env: NodeJS.ProcessEnv;
|
|
101
|
+
|
|
102
|
+
const gitAt = (cwd: string, ...args: string[]) =>
|
|
103
|
+
execFileSync("git", args, {
|
|
104
|
+
cwd,
|
|
105
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
106
|
+
env: {
|
|
107
|
+
...process.env,
|
|
108
|
+
GIT_AUTHOR_NAME: "t",
|
|
109
|
+
GIT_AUTHOR_EMAIL: "t@t",
|
|
110
|
+
GIT_COMMITTER_NAME: "t",
|
|
111
|
+
GIT_COMMITTER_EMAIL: "t@t",
|
|
112
|
+
GIT_AUTHOR_DATE: `${EPOCH} +0000`,
|
|
113
|
+
GIT_COMMITTER_DATE: `${EPOCH} +0000`,
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
.toString()
|
|
117
|
+
.trim();
|
|
118
|
+
|
|
119
|
+
beforeAll(() => {
|
|
120
|
+
workDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rescue-dirsymlink-"));
|
|
121
|
+
|
|
122
|
+
// --- "upstream" repo: a single core/ file so the wipe set includes core/ --
|
|
123
|
+
upstream = path.join(workDir, "upstream");
|
|
124
|
+
fs.mkdirSync(path.join(upstream, "core/knowledge"), { recursive: true });
|
|
125
|
+
gitAt(workDir, "init", "-b", "main", "upstream");
|
|
126
|
+
fs.writeFileSync(path.join(upstream, "core/knowledge/README.md"), "shipped\n");
|
|
127
|
+
gitAt(upstream, "add", "-A");
|
|
128
|
+
gitAt(upstream, "commit", "-m", "floor");
|
|
129
|
+
floorSha = gitAt(upstream, "rev-parse", "HEAD");
|
|
130
|
+
|
|
131
|
+
// --- git shim: redirect `git clone <github-url>` to the local fixture -----
|
|
132
|
+
const realGit =
|
|
133
|
+
execFileSync("bash", ["-lc", "command -v git"]).toString().trim() || "/usr/bin/git";
|
|
134
|
+
shimDir = path.join(workDir, "shim");
|
|
135
|
+
fs.mkdirSync(shimDir, { recursive: true });
|
|
136
|
+
const shim = `#!/usr/bin/env bash
|
|
137
|
+
if [ "$1" = "clone" ]; then
|
|
138
|
+
args=()
|
|
139
|
+
for a in "$@"; do
|
|
140
|
+
case "$a" in
|
|
141
|
+
https://github.com/*) a=${JSON.stringify(upstream)} ;;
|
|
142
|
+
esac
|
|
143
|
+
args+=("$a")
|
|
144
|
+
done
|
|
145
|
+
exec ${JSON.stringify(realGit)} "\${args[@]}"
|
|
146
|
+
fi
|
|
147
|
+
exec ${JSON.stringify(realGit)} "$@"
|
|
148
|
+
`;
|
|
149
|
+
fs.writeFileSync(path.join(shimDir, "git"), shim, { mode: 0o755 });
|
|
150
|
+
env = { ...process.env, PATH: `${shimDir}:${process.env.PATH ?? ""}` };
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
afterAll(() => {
|
|
154
|
+
if (workDir) fs.rmSync(workDir, { recursive: true, force: true });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Build a fresh local HQ root carrying a directory-target reindex symlink
|
|
159
|
+
* (core/knowledge/ad-creative-engine -> <hqRoot>/personal/knowledge/ad-creative-engine),
|
|
160
|
+
* whose target directory holds a sentinel file that must survive the drop.
|
|
161
|
+
*/
|
|
162
|
+
function makeHqRoot(): { hqRoot: string; link: string; targetDir: string; sentinel: string } {
|
|
163
|
+
const hqRoot = fs.mkdtempSync(path.join(workDir, "hq-"));
|
|
164
|
+
// HQ-root shape check: companies/ + one of .claude/|core/|personal/.
|
|
165
|
+
fs.mkdirSync(path.join(hqRoot, "companies"), { recursive: true });
|
|
166
|
+
fs.mkdirSync(path.join(hqRoot, "core/knowledge"), { recursive: true });
|
|
167
|
+
// Unchanged vs upstream so the overlay is a no-op for it.
|
|
168
|
+
fs.writeFileSync(path.join(hqRoot, "core/knowledge/README.md"), "shipped\n");
|
|
169
|
+
|
|
170
|
+
const targetDir = path.join(hqRoot, "personal/knowledge/ad-creative-engine");
|
|
171
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
172
|
+
const sentinel = path.join(targetDir, "brief.md");
|
|
173
|
+
fs.writeFileSync(sentinel, "authored locally\n");
|
|
174
|
+
|
|
175
|
+
const link = path.join(hqRoot, "core/knowledge/ad-creative-engine");
|
|
176
|
+
// Absolute target that includes "/personal/" — matches the overlay
|
|
177
|
+
// convention masterSyncSymlinkTarget() recognizes.
|
|
178
|
+
fs.symlinkSync(targetDir, link);
|
|
179
|
+
|
|
180
|
+
return { hqRoot, link, targetDir, sentinel };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function rescueArgs(hqRoot: string): string[] {
|
|
184
|
+
return [
|
|
185
|
+
"--hq-root", hqRoot,
|
|
186
|
+
"--source", "test/repo",
|
|
187
|
+
"--ref", "main",
|
|
188
|
+
"--floor-sha", floorSha,
|
|
189
|
+
"--yes",
|
|
190
|
+
"--no-backup",
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
it("sanity: the mocked rmSync reproduces the macOS EISDIR on a dir-target symlink", () => {
|
|
195
|
+
const { link, targetDir, sentinel } = makeHqRoot();
|
|
196
|
+
// The old apply path used exactly this call — it must throw under the fault.
|
|
197
|
+
expect(() => fs.rmSync(link, { force: true })).toThrow(/Path is a directory/);
|
|
198
|
+
// ...and the fixed path (unlink) removes the link while sparing the target.
|
|
199
|
+
fs.unlinkSync(link);
|
|
200
|
+
expect(fs.existsSync(link)).toBe(false);
|
|
201
|
+
expect(fs.existsSync(targetDir)).toBe(true);
|
|
202
|
+
expect(fs.readFileSync(sentinel, "utf-8")).toBe("authored locally\n");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("does not abort the apply on a directory-target symlink (drops it, spares the target)", () => {
|
|
206
|
+
const { hqRoot, link, targetDir, sentinel } = makeHqRoot();
|
|
207
|
+
|
|
208
|
+
const r = runRescueCapture(rescueArgs(hqRoot), env);
|
|
209
|
+
|
|
210
|
+
// The apply must survive the simulated-macOS rmSync fault: the fix removes
|
|
211
|
+
// the link with unlink semantics, so the whole update no longer rolls back.
|
|
212
|
+
// (The old rmSync-based drop throws EISDIR here → non-zero + rollback.)
|
|
213
|
+
if (r.status !== 0) {
|
|
214
|
+
throw new Error(`rescue aborted on the dir-target symlink (${r.status}):\n${r.stdout}\n${r.stderr}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// The link itself is gone (reindex.sh re-lays it later)...
|
|
218
|
+
expect(fs.existsSync(link)).toBe(false);
|
|
219
|
+
// ...but the target directory and its contents are untouched — the drop
|
|
220
|
+
// must never follow/recurse into the target.
|
|
221
|
+
expect(fs.existsSync(targetDir)).toBe(true);
|
|
222
|
+
expect(fs.readFileSync(sentinel, "utf-8")).toBe("authored locally\n");
|
|
223
|
+
});
|
|
224
|
+
});
|