@indigoai-us/hq-cloud 6.14.18 → 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/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-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 +17 -2
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +22 -4
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +495 -3
- 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/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/src/bin/sync-runner-company.ts +29 -1
- 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 +591 -6
- package/src/bin/sync-runner.ts +50 -5
- 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/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/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,
|
|
@@ -664,6 +668,8 @@ export interface RunnerDeps {
|
|
|
664
668
|
operationLockAlreadyHeld?: boolean;
|
|
665
669
|
/** Internal watch-loop result override; one-shot callers keep exit 0. */
|
|
666
670
|
authRequiredExitCode?: number;
|
|
671
|
+
/** Internal watch-loop callback carrying uncapped per-path push outcomes. */
|
|
672
|
+
onPassResult?: (result: RunnerPassResult) => void;
|
|
667
673
|
/** Share function (push phase). Defaults to `cli/share.share`. */
|
|
668
674
|
share?: (options: ShareOptions) => Promise<ShareResult>;
|
|
669
675
|
/**
|
|
@@ -679,6 +685,20 @@ export interface RunnerDeps {
|
|
|
679
685
|
telemetryTimeoutMs?: number;
|
|
680
686
|
}
|
|
681
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
|
+
|
|
682
702
|
// ---------------------------------------------------------------------------
|
|
683
703
|
// JWT claim decoder — inlined to avoid pulling a dep just to read an idToken.
|
|
684
704
|
// We do NOT verify the signature here — Cognito already did that when it
|
|
@@ -812,6 +832,11 @@ interface ParsedArgs {
|
|
|
812
832
|
skipPersonal: boolean;
|
|
813
833
|
/** Repeatable company/personal-relative path scope for push-only passes. */
|
|
814
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[];
|
|
815
840
|
/**
|
|
816
841
|
* Bounded wait (seconds) for the per-root operation lock when another op is
|
|
817
842
|
* already running. `0` → refuse immediately (pre-wait behavior); omitted →
|
|
@@ -832,6 +857,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
832
857
|
let pollRemoteMs: number | undefined;
|
|
833
858
|
let skipPersonal = false;
|
|
834
859
|
const scopePaths: string[] = [];
|
|
860
|
+
const deleteScopePaths: string[] = [];
|
|
835
861
|
let eventPush = false;
|
|
836
862
|
let lockTimeoutSec: number | undefined;
|
|
837
863
|
|
|
@@ -913,6 +939,12 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
913
939
|
scopePaths.push(val);
|
|
914
940
|
break;
|
|
915
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
|
+
}
|
|
916
948
|
case "--lock-timeout": {
|
|
917
949
|
const val = argv[++i];
|
|
918
950
|
if (!val) return { error: "--lock-timeout requires a value (seconds)" };
|
|
@@ -964,6 +996,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
964
996
|
pollRemoteMs,
|
|
965
997
|
skipPersonal,
|
|
966
998
|
scopePaths,
|
|
999
|
+
deleteScopePaths,
|
|
967
1000
|
eventPush,
|
|
968
1001
|
lockTimeoutSec,
|
|
969
1002
|
};
|
|
@@ -1418,6 +1451,7 @@ export async function runRunner(
|
|
|
1418
1451
|
resolveDeletePolicy,
|
|
1419
1452
|
emit,
|
|
1420
1453
|
scopePaths: parsed.scopePaths,
|
|
1454
|
+
deleteScopePaths: parsed.deleteScopePaths,
|
|
1421
1455
|
telemetryClient: client,
|
|
1422
1456
|
telemetryClaims: claims,
|
|
1423
1457
|
telemetrySessionId,
|
|
@@ -1431,6 +1465,7 @@ export async function runRunner(
|
|
|
1431
1465
|
throw err;
|
|
1432
1466
|
}
|
|
1433
1467
|
const { errors, allConflicts, meaningfulOutcomeCount } = fanout;
|
|
1468
|
+
deps.onPassResult?.({ pushPathResults: fanout.pushPathResults });
|
|
1434
1469
|
const rollup = rollupAllComplete(plan, fanout.stateByCompany);
|
|
1435
1470
|
|
|
1436
1471
|
// The personal-vault target runs last and can pull an older copy of the
|
|
@@ -1860,7 +1895,7 @@ export interface RunnerLoopDeps {
|
|
|
1860
1895
|
* targeted push share one seam and one in-flight guard. The default ignores
|
|
1861
1896
|
* `deps` and forwards just the argv to `runRunner`.
|
|
1862
1897
|
*/
|
|
1863
|
-
runPass?: (passArgv: string[]) => Promise<number>;
|
|
1898
|
+
runPass?: (passArgv: string[]) => Promise<number | RunnerPassOutcome>;
|
|
1864
1899
|
/** Stage-0 backup retention backstop: a dry-run only, never a sync-tick action. */
|
|
1865
1900
|
runBackupPrune?: (hqRoot: string) => Promise<void>;
|
|
1866
1901
|
/**
|
|
@@ -1878,6 +1913,10 @@ export interface RunnerLoopDeps {
|
|
|
1878
1913
|
hqRoot: string;
|
|
1879
1914
|
debounceMs: number;
|
|
1880
1915
|
clock: Clock;
|
|
1916
|
+
captureLocalDeleteSnapshots: (
|
|
1917
|
+
relativePath: string,
|
|
1918
|
+
kind: "unlink" | "unlinkDir",
|
|
1919
|
+
) => LocalDeleteSnapshot[];
|
|
1881
1920
|
}) => WatcherSurface;
|
|
1882
1921
|
/**
|
|
1883
1922
|
* Register a one-shot shutdown signal handler. Defaults to listening for
|
|
@@ -1955,11 +1994,17 @@ export async function runRunnerWithLoop(
|
|
|
1955
1994
|
): Promise<number> {
|
|
1956
1995
|
const parsed = parseArgs(argv);
|
|
1957
1996
|
const runtime = {
|
|
1958
|
-
runPassWithOperationLockAlreadyHeld: (passArgv: string[]) =>
|
|
1959
|
-
|
|
1997
|
+
runPassWithOperationLockAlreadyHeld: async (passArgv: string[]) => {
|
|
1998
|
+
let result: RunnerPassResult | undefined;
|
|
1999
|
+
const exitCode = await runRunner(passArgv, {
|
|
1960
2000
|
operationLockAlreadyHeld: true,
|
|
1961
2001
|
authRequiredExitCode: AUTH_REQUIRED_PASS_EXIT,
|
|
1962
|
-
|
|
2002
|
+
onPassResult: (passResult) => {
|
|
2003
|
+
result = passResult;
|
|
2004
|
+
},
|
|
2005
|
+
});
|
|
2006
|
+
return { exitCode, ...(result ? { result } : {}) };
|
|
2007
|
+
},
|
|
1963
2008
|
authRequiredPassExit: AUTH_REQUIRED_PASS_EXIT,
|
|
1964
2009
|
defaultGetIdTokenClaims,
|
|
1965
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
|
+
});
|
package/src/cli/share.test.ts
CHANGED
|
@@ -2008,6 +2008,11 @@ describe("share", () => {
|
|
|
2008
2008
|
});
|
|
2009
2009
|
|
|
2010
2010
|
expect(result.filesDeleted).toBe(1);
|
|
2011
|
+
expect(result.pathResults).toContainEqual({
|
|
2012
|
+
path: "knowledge",
|
|
2013
|
+
status: "accepted",
|
|
2014
|
+
operation: "delete",
|
|
2015
|
+
});
|
|
2011
2016
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "knowledge");
|
|
2012
2017
|
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2013
2018
|
expect(journal.files["knowledge"]).toBeUndefined();
|
|
@@ -2198,6 +2203,44 @@ describe("share", () => {
|
|
|
2198
2203
|
);
|
|
2199
2204
|
});
|
|
2200
2205
|
|
|
2206
|
+
it("personalMode excludes generated core mirror symlinks but keeps canonical core files", async () => {
|
|
2207
|
+
const packagePolicy = path.join(
|
|
2208
|
+
tmpDir,
|
|
2209
|
+
"core",
|
|
2210
|
+
"packages",
|
|
2211
|
+
"hq-pack-example",
|
|
2212
|
+
"policies",
|
|
2213
|
+
"generated.md",
|
|
2214
|
+
);
|
|
2215
|
+
const generatedMirror = path.join(tmpDir, "core", "policies", "generated.md");
|
|
2216
|
+
const canonicalCoreFile = path.join(tmpDir, "core", "policies", "shipped.md");
|
|
2217
|
+
fs.mkdirSync(path.dirname(packagePolicy), { recursive: true });
|
|
2218
|
+
fs.mkdirSync(path.dirname(generatedMirror), { recursive: true });
|
|
2219
|
+
fs.writeFileSync(packagePolicy, "generated package policy\n");
|
|
2220
|
+
fs.symlinkSync(packagePolicy, generatedMirror);
|
|
2221
|
+
fs.writeFileSync(canonicalCoreFile, "shipped core policy\n");
|
|
2222
|
+
|
|
2223
|
+
const result = await share({
|
|
2224
|
+
paths: [path.join(tmpDir, "core")],
|
|
2225
|
+
entityContext: makeEntityContext({
|
|
2226
|
+
uid: "prs_01PERSONALTEST",
|
|
2227
|
+
slug: "__hq_personal_vault__",
|
|
2228
|
+
bucketName: "hq-vault-prs-01PERSONALTEST",
|
|
2229
|
+
}),
|
|
2230
|
+
hqRoot: tmpDir,
|
|
2231
|
+
personalMode: true,
|
|
2232
|
+
journalSlug: "personal-generated-core-mirror",
|
|
2233
|
+
});
|
|
2234
|
+
|
|
2235
|
+
expect(result.filesExcludedByPolicy).toBe(1);
|
|
2236
|
+
expect(result.filesUploaded).toBe(2);
|
|
2237
|
+
expect(vi.mocked(uploadSymlink)).not.toHaveBeenCalled();
|
|
2238
|
+
expect(vi.mocked(uploadFile).mock.calls.map((call) => call[2]).sort()).toEqual([
|
|
2239
|
+
"core/packages/hq-pack-example/policies/generated.md",
|
|
2240
|
+
"core/policies/shipped.md",
|
|
2241
|
+
]);
|
|
2242
|
+
});
|
|
2243
|
+
|
|
2201
2244
|
it(
|
|
2202
2245
|
"personal-vault override does NOT apply to company vaults: `owned-only` on cmp_ entity still " +
|
|
2203
2246
|
"refuses to tombstone a direction:'down' entry (multi-user curation intact)",
|
|
@@ -3033,7 +3076,7 @@ describe("share", () => {
|
|
|
3033
3076
|
});
|
|
3034
3077
|
});
|
|
3035
3078
|
|
|
3036
|
-
it("
|
|
3079
|
+
it("daemon-stopped deletion has no watcher intent and is safely refused", async () => {
|
|
3037
3080
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3038
3081
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3039
3082
|
// Journal entry from before remoteEtag tracking — no etag to compare
|
|
@@ -3078,6 +3121,12 @@ describe("share", () => {
|
|
|
3078
3121
|
reason: "missing-delete-intent",
|
|
3079
3122
|
});
|
|
3080
3123
|
expect(result.filesRefusedStale).toBe(1);
|
|
3124
|
+
expect(result.pathResults).toContainEqual({
|
|
3125
|
+
path: "legacy-no-etag.md",
|
|
3126
|
+
status: "refused",
|
|
3127
|
+
operation: "delete",
|
|
3128
|
+
reason: "missing-delete-intent",
|
|
3129
|
+
});
|
|
3081
3130
|
});
|
|
3082
3131
|
|
|
3083
3132
|
it("currency-gated: real-world /update-hq scenario — direction:'down' delete propagates when current", async () => {
|
|
@@ -3351,6 +3400,58 @@ describe("share", () => {
|
|
|
3351
3400
|
// passing in the allowed top-level directories — share() trusts its
|
|
3352
3401
|
// `paths` input.
|
|
3353
3402
|
|
|
3403
|
+
it("plans an intent-backed personal delete from an explicit missing scope root", async () => {
|
|
3404
|
+
const key = "deleted-top-level.md";
|
|
3405
|
+
const journalSlug = "personal-scoped-delete";
|
|
3406
|
+
const journalPath = path.join(
|
|
3407
|
+
stateDir,
|
|
3408
|
+
`sync-journal.${journalSlug}.json`,
|
|
3409
|
+
);
|
|
3410
|
+
fs.writeFileSync(
|
|
3411
|
+
journalPath,
|
|
3412
|
+
JSON.stringify({
|
|
3413
|
+
version: "2",
|
|
3414
|
+
lastSync: new Date().toISOString(),
|
|
3415
|
+
pulls: [],
|
|
3416
|
+
files: {
|
|
3417
|
+
[key]: {
|
|
3418
|
+
hash: "captured-hash",
|
|
3419
|
+
size: 8,
|
|
3420
|
+
syncedAt: new Date().toISOString(),
|
|
3421
|
+
direction: "up",
|
|
3422
|
+
remoteEtag: "captured-etag",
|
|
3423
|
+
kind: "file",
|
|
3424
|
+
localDeleteIntent: deleteIntent(
|
|
3425
|
+
"captured-etag",
|
|
3426
|
+
"captured-hash",
|
|
3427
|
+
),
|
|
3428
|
+
},
|
|
3429
|
+
},
|
|
3430
|
+
}),
|
|
3431
|
+
);
|
|
3432
|
+
|
|
3433
|
+
const result = await share({
|
|
3434
|
+
// computePersonalVaultPaths() has no path to contribute after this
|
|
3435
|
+
// top-level file is gone. The watcher-captured root is the sole scope.
|
|
3436
|
+
paths: [],
|
|
3437
|
+
deleteScopeRoots: [key],
|
|
3438
|
+
company: "acme",
|
|
3439
|
+
vaultConfig: mockConfig,
|
|
3440
|
+
hqRoot: tmpDir,
|
|
3441
|
+
personalMode: true,
|
|
3442
|
+
journalSlug,
|
|
3443
|
+
skipUnchanged: true,
|
|
3444
|
+
propagateDeletes: true,
|
|
3445
|
+
propagateDeletePolicy: "owned-only",
|
|
3446
|
+
});
|
|
3447
|
+
|
|
3448
|
+
expect(result.filesDeleted).toBe(1);
|
|
3449
|
+
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), key);
|
|
3450
|
+
expect(
|
|
3451
|
+
JSON.parse(fs.readFileSync(journalPath, "utf8")).files[key],
|
|
3452
|
+
).toBeUndefined();
|
|
3453
|
+
});
|
|
3454
|
+
|
|
3354
3455
|
// ── Decommission prefixes ──────────────────────────────────────────────────
|
|
3355
3456
|
//
|
|
3356
3457
|
// `decommissionPrefixes` is the "company was promoted to its own team
|
|
@@ -4059,10 +4160,12 @@ describe("share", () => {
|
|
|
4059
4160
|
total,
|
|
4060
4161
|
missing,
|
|
4061
4162
|
direction = "up" as "up" | "down",
|
|
4163
|
+
withIntents = true,
|
|
4062
4164
|
}: {
|
|
4063
4165
|
total: number;
|
|
4064
4166
|
missing: number;
|
|
4065
4167
|
direction?: "up" | "down";
|
|
4168
|
+
withIntents?: boolean;
|
|
4066
4169
|
}): { companyRoot: string; journalPath: string } {
|
|
4067
4170
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4068
4171
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
@@ -4075,7 +4178,7 @@ describe("share", () => {
|
|
|
4075
4178
|
direction: "up" | "down";
|
|
4076
4179
|
remoteEtag: string;
|
|
4077
4180
|
kind: "file";
|
|
4078
|
-
localDeleteIntent
|
|
4181
|
+
localDeleteIntent?: ReturnType<typeof deleteIntent>;
|
|
4079
4182
|
}
|
|
4080
4183
|
> = {};
|
|
4081
4184
|
for (let i = 0; i < total; i++) {
|
|
@@ -4087,7 +4190,9 @@ describe("share", () => {
|
|
|
4087
4190
|
direction,
|
|
4088
4191
|
remoteEtag: `etag-${i}`,
|
|
4089
4192
|
kind: "file",
|
|
4090
|
-
|
|
4193
|
+
...(withIntents
|
|
4194
|
+
? { localDeleteIntent: deleteIntent(`etag-${i}`, "h") }
|
|
4195
|
+
: {}),
|
|
4091
4196
|
};
|
|
4092
4197
|
if (i >= missing) {
|
|
4093
4198
|
fs.writeFileSync(path.join(companyRoot, key), "hi");
|
|
@@ -4110,7 +4215,11 @@ describe("share", () => {
|
|
|
4110
4215
|
});
|
|
4111
4216
|
|
|
4112
4217
|
it("trips at the 11/100 boundary (11% > 10%, abs >= 10): refuses every delete and emits bulk-asymmetry event", async () => {
|
|
4113
|
-
const { companyRoot, journalPath } = setupJournal({
|
|
4218
|
+
const { companyRoot, journalPath } = setupJournal({
|
|
4219
|
+
total: 100,
|
|
4220
|
+
missing: 11,
|
|
4221
|
+
withIntents: false,
|
|
4222
|
+
});
|
|
4114
4223
|
// currency-gated: remote HEAD would match every journal etag — guard
|
|
4115
4224
|
// must trip BEFORE any HEAD round-trip occurs.
|
|
4116
4225
|
vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => ({
|
|
@@ -4166,6 +4275,33 @@ describe("share", () => {
|
|
|
4166
4275
|
expect(perKeyRefusals.length).toBe(11);
|
|
4167
4276
|
});
|
|
4168
4277
|
|
|
4278
|
+
it("intent-backed 11/100 subtree disappearance bypasses the breaker and deletes every covered path", async () => {
|
|
4279
|
+
const { companyRoot } = setupJournal({ total: 100, missing: 11 });
|
|
4280
|
+
vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => ({
|
|
4281
|
+
etag: `etag-${parseInt(key.replace(/[^0-9]/g, ""), 10)}`,
|
|
4282
|
+
lastModified: new Date(),
|
|
4283
|
+
size: 5,
|
|
4284
|
+
}));
|
|
4285
|
+
const events: Array<{ type: string }> = [];
|
|
4286
|
+
|
|
4287
|
+
const result = await share({
|
|
4288
|
+
paths: [companyRoot],
|
|
4289
|
+
company: "acme",
|
|
4290
|
+
vaultConfig: mockConfig,
|
|
4291
|
+
hqRoot: tmpDir,
|
|
4292
|
+
skipUnchanged: true,
|
|
4293
|
+
propagateDeletes: true,
|
|
4294
|
+
propagateDeletePolicy: "currency-gated",
|
|
4295
|
+
onEvent: (e) => events.push(e as { type: string }),
|
|
4296
|
+
});
|
|
4297
|
+
|
|
4298
|
+
expect(result.filesDeleted).toBe(11);
|
|
4299
|
+
expect(deleteRemoteFile).toHaveBeenCalledTimes(11);
|
|
4300
|
+
expect(
|
|
4301
|
+
events.find((e) => e.type === "delete-refused-bulk-asymmetry"),
|
|
4302
|
+
).toBeUndefined();
|
|
4303
|
+
});
|
|
4304
|
+
|
|
4169
4305
|
it("does NOT trip at 9/100 (below ratio): all candidates delete normally", async () => {
|
|
4170
4306
|
const { companyRoot } = setupJournal({ total: 100, missing: 9 });
|
|
4171
4307
|
vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => ({
|
|
@@ -4232,7 +4368,11 @@ describe("share", () => {
|
|
|
4232
4368
|
});
|
|
4233
4369
|
|
|
4234
4370
|
it("fully-empty mirror (100/100) trips the guard — the canonical mass-delete failure mode", async () => {
|
|
4235
|
-
const { companyRoot, journalPath } = setupJournal({
|
|
4371
|
+
const { companyRoot, journalPath } = setupJournal({
|
|
4372
|
+
total: 50,
|
|
4373
|
+
missing: 50,
|
|
4374
|
+
withIntents: false,
|
|
4375
|
+
});
|
|
4236
4376
|
vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => ({
|
|
4237
4377
|
etag: `etag-${parseInt(key.replace(/[^0-9]/g, ""), 10)}`,
|
|
4238
4378
|
lastModified: new Date(),
|
|
@@ -4288,7 +4428,11 @@ describe("share", () => {
|
|
|
4288
4428
|
});
|
|
4289
4429
|
|
|
4290
4430
|
it("owned-only policy also goes through the guard: 11/100 missing direction:'up' entries refused", async () => {
|
|
4291
|
-
const { companyRoot, journalPath } = setupJournal({
|
|
4431
|
+
const { companyRoot, journalPath } = setupJournal({
|
|
4432
|
+
total: 100,
|
|
4433
|
+
missing: 11,
|
|
4434
|
+
withIntents: false,
|
|
4435
|
+
});
|
|
4292
4436
|
|
|
4293
4437
|
const events: Array<{ type: string }> = [];
|
|
4294
4438
|
const result = await share({
|