@indigoai-us/hq-cloud 6.14.7 → 6.14.9
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/backup-prune.d.ts +68 -0
- package/dist/backup-prune.d.ts.map +1 -0
- package/dist/backup-prune.js +196 -0
- package/dist/backup-prune.js.map +1 -0
- package/dist/backup-prune.test.d.ts +2 -0
- package/dist/backup-prune.test.d.ts.map +1 -0
- package/dist/backup-prune.test.js +89 -0
- package/dist/backup-prune.test.js.map +1 -0
- package/dist/bin/backup-prune-runner.d.ts +3 -0
- package/dist/bin/backup-prune-runner.d.ts.map +1 -0
- package/dist/bin/backup-prune-runner.js +50 -0
- package/dist/bin/backup-prune-runner.js.map +1 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +8 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-planning.d.ts +1 -1
- package/dist/bin/sync-runner-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +15 -1
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +28 -0
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +14 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +26 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +68 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/rescue-classify-ordering.test.js +105 -0
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/cli/rescue-core.d.ts +1 -0
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +478 -300
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-snapshot.d.ts +14 -0
- package/dist/cli/rescue-snapshot.d.ts.map +1 -0
- package/dist/cli/rescue-snapshot.js +39 -0
- package/dist/cli/rescue-snapshot.js.map +1 -0
- package/dist/cli/rescue-snapshot.test.d.ts +2 -0
- package/dist/cli/rescue-snapshot.test.d.ts.map +1 -0
- package/dist/cli/rescue-snapshot.test.js +46 -0
- package/dist/cli/rescue-snapshot.test.js.map +1 -0
- package/dist/cli/sync-scope.test.js +3 -1
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +6 -6
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +51 -41
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +102 -2
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/manifest-reconcile.d.ts +27 -0
- package/dist/manifest-reconcile.d.ts.map +1 -0
- package/dist/manifest-reconcile.js +120 -0
- package/dist/manifest-reconcile.js.map +1 -0
- package/dist/manifest-reconcile.test.d.ts +2 -0
- package/dist/manifest-reconcile.test.d.ts.map +1 -0
- package/dist/manifest-reconcile.test.js +97 -0
- package/dist/manifest-reconcile.test.js.map +1 -0
- package/dist/object-io.d.ts +19 -2
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +168 -32
- package/dist/object-io.js.map +1 -1
- package/dist/object-io.test.js +157 -1
- package/dist/object-io.test.js.map +1 -1
- package/dist/s3.d.ts +12 -1
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +51 -18
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +12 -1
- package/dist/s3.test.js.map +1 -1
- package/dist/signals/get.test.js +21 -1
- package/dist/signals/get.test.js.map +1 -1
- package/dist/signals/list.test.js +21 -1
- package/dist/signals/list.test.js.map +1 -1
- package/dist/sources/get.test.js +21 -1
- package/dist/sources/get.test.js.map +1 -1
- package/dist/sources/list.test.js +21 -1
- package/dist/sources/list.test.js.map +1 -1
- package/package.json +3 -2
- package/src/backup-prune.test.ts +98 -0
- package/src/backup-prune.ts +182 -0
- package/src/bin/backup-prune-runner.ts +33 -0
- package/src/bin/sync-runner-company.ts +7 -0
- package/src/bin/sync-runner-planning.ts +16 -2
- package/src/bin/sync-runner-watch-loop.ts +18 -0
- package/src/bin/sync-runner.test.ts +82 -1
- package/src/bin/sync-runner.ts +45 -0
- package/src/cli/rescue-classify-ordering.test.ts +121 -0
- package/src/cli/rescue-core.ts +261 -86
- package/src/cli/rescue-snapshot.test.ts +57 -0
- package/src/cli/rescue-snapshot.ts +51 -0
- package/src/cli/sync-scope.test.ts +3 -1
- package/src/cli/sync.test.ts +121 -2
- package/src/cli/sync.ts +71 -53
- package/src/manifest-reconcile.test.ts +107 -0
- package/src/manifest-reconcile.ts +160 -0
- package/src/object-io.test.ts +175 -0
- package/src/object-io.ts +213 -32
- package/src/s3.test.ts +18 -0
- package/src/s3.ts +64 -30
- package/src/signals/get.test.ts +26 -2
- package/src/signals/list.test.ts +26 -2
- package/src/sources/get.test.ts +26 -2
- package/src/sources/list.test.ts +26 -2
package/src/cli/sync.test.ts
CHANGED
|
@@ -11,7 +11,8 @@ import type { VaultServiceConfig } from "../types.js";
|
|
|
11
11
|
import { lockPathFor } from "../operation-lock.js";
|
|
12
12
|
|
|
13
13
|
// Mock s3 module at the top level
|
|
14
|
-
vi.mock("../s3.js", async () => {
|
|
14
|
+
vi.mock("../s3.js", async (importOriginal) => {
|
|
15
|
+
const actual = await importOriginal<typeof import("../s3.js")>();
|
|
15
16
|
const { vi: innerVi } = await import("vitest");
|
|
16
17
|
const innerFs = await import("fs");
|
|
17
18
|
const innerPath = await import("path");
|
|
@@ -22,6 +23,7 @@ vi.mock("../s3.js", async () => {
|
|
|
22
23
|
];
|
|
23
24
|
|
|
24
25
|
return {
|
|
26
|
+
...actual,
|
|
25
27
|
toPosixKey: (key: string) => key.split("\\").join("/"),
|
|
26
28
|
uploadFile: innerVi.fn().mockResolvedValue(undefined),
|
|
27
29
|
downloadFile: innerVi.fn().mockImplementation(async (_ctx: unknown, _key: string, localPath: string) => {
|
|
@@ -139,6 +141,10 @@ describe("sync", () => {
|
|
|
139
141
|
delete process.env.HQ_STATE_DIR;
|
|
140
142
|
});
|
|
141
143
|
|
|
144
|
+
it("keeps classifyVaultKey available from the partial S3 mock", () => {
|
|
145
|
+
expect(s3Module.classifyVaultKey("docs/readme.md", "company")).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
|
|
142
148
|
it("runs reindex against hqRoot after a sync that downloaded files", async () => {
|
|
143
149
|
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
144
150
|
// skipLock: the surrounding sync run already holds the per-root lock.
|
|
@@ -2566,6 +2572,68 @@ describe("sync", () => {
|
|
|
2566
2572
|
expect(journal.files["knowledge/readme.md"].remoteEtag).toBe("def456");
|
|
2567
2573
|
});
|
|
2568
2574
|
|
|
2575
|
+
it("checkpoints completed downloads so a failed bulk pass resumes only its tail", async () => {
|
|
2576
|
+
process.env.HQ_SYNC_TRANSFER_CONCURRENCY = "1";
|
|
2577
|
+
const remoteFiles = Array.from({ length: 9 }, (_, index) => ({
|
|
2578
|
+
key: `bulk/file-${index}.md`,
|
|
2579
|
+
size: 10,
|
|
2580
|
+
lastModified: new Date(),
|
|
2581
|
+
etag: `"bulk-${index}"`,
|
|
2582
|
+
}));
|
|
2583
|
+
const defaultDownload = vi.mocked(s3Module.downloadFile).getMockImplementation();
|
|
2584
|
+
if (!defaultDownload) throw new Error("missing default download mock");
|
|
2585
|
+
|
|
2586
|
+
try {
|
|
2587
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce(remoteFiles);
|
|
2588
|
+
vi.mocked(s3Module.downloadFile).mockImplementation(
|
|
2589
|
+
async (ctx, key, localPath) => {
|
|
2590
|
+
if (key === "bulk/file-8.md") {
|
|
2591
|
+
// This models the next transfer crashing the process. The first
|
|
2592
|
+
// eight entries must already be durable before the final writer.
|
|
2593
|
+
const checkpoint = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2594
|
+
expect(Object.keys(checkpoint.files)).toHaveLength(8);
|
|
2595
|
+
throw new Error("simulated crash after checkpoint");
|
|
2596
|
+
}
|
|
2597
|
+
return defaultDownload(ctx, key, localPath);
|
|
2598
|
+
},
|
|
2599
|
+
);
|
|
2600
|
+
|
|
2601
|
+
const first = await sync({
|
|
2602
|
+
company: "acme",
|
|
2603
|
+
vaultConfig: mockConfig,
|
|
2604
|
+
hqRoot: tmpDir,
|
|
2605
|
+
skipReindex: true,
|
|
2606
|
+
onEvent: () => undefined,
|
|
2607
|
+
});
|
|
2608
|
+
expect(first.filesDownloaded).toBe(8);
|
|
2609
|
+
|
|
2610
|
+
const checkpoint = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2611
|
+
expect(Object.keys(checkpoint.files)).toHaveLength(8);
|
|
2612
|
+
|
|
2613
|
+
vi.mocked(s3Module.downloadFile).mockImplementation(defaultDownload);
|
|
2614
|
+
vi.mocked(s3Module.downloadFile).mockClear();
|
|
2615
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce(remoteFiles);
|
|
2616
|
+
|
|
2617
|
+
await sync({
|
|
2618
|
+
company: "acme",
|
|
2619
|
+
vaultConfig: mockConfig,
|
|
2620
|
+
hqRoot: tmpDir,
|
|
2621
|
+
skipReindex: true,
|
|
2622
|
+
onEvent: () => undefined,
|
|
2623
|
+
});
|
|
2624
|
+
|
|
2625
|
+
expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledTimes(1);
|
|
2626
|
+
expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledWith(
|
|
2627
|
+
expect.anything(),
|
|
2628
|
+
"bulk/file-8.md",
|
|
2629
|
+
expect.any(String),
|
|
2630
|
+
);
|
|
2631
|
+
} finally {
|
|
2632
|
+
vi.mocked(s3Module.downloadFile).mockImplementation(defaultDownload);
|
|
2633
|
+
delete process.env.HQ_SYNC_TRANSFER_CONCURRENCY;
|
|
2634
|
+
}
|
|
2635
|
+
});
|
|
2636
|
+
|
|
2569
2637
|
// ── Stage-1 plan event ─────────────────────────────────────────────────
|
|
2570
2638
|
|
|
2571
2639
|
it("emits a plan event before any progress events", async () => {
|
|
@@ -3753,11 +3821,62 @@ describe("scope-invalid key hardening (companies/ prefix in a company vault —
|
|
|
3753
3821
|
expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
|
|
3754
3822
|
const skipEvents = events.filter((e) => e.type === "skip-invalid-scoped-key");
|
|
3755
3823
|
expect(skipEvents).toEqual([
|
|
3756
|
-
{
|
|
3824
|
+
{
|
|
3825
|
+
type: "skip-invalid-scoped-key",
|
|
3826
|
+
path: "companies/acme/knowledge/poison.md",
|
|
3827
|
+
errorCode: "INVALID_KEY_COMPANIES_SCOPED",
|
|
3828
|
+
},
|
|
3757
3829
|
]);
|
|
3758
3830
|
expect(events.filter((e) => e.type === "error")).toEqual([]);
|
|
3759
3831
|
});
|
|
3760
3832
|
|
|
3833
|
+
it("skips a control-character remote key before presigning while transferring valid keys", async () => {
|
|
3834
|
+
const invalidKey = "docs/bad\x00key.md";
|
|
3835
|
+
vi.mocked(s3Module.listRemoteFiles).mockReset();
|
|
3836
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValue([
|
|
3837
|
+
{ key: invalidKey, size: 10, lastModified: new Date(), etag: '"bad"' },
|
|
3838
|
+
{ key: "docs/good.md", size: 5, lastModified: new Date(), etag: '"good"' },
|
|
3839
|
+
]);
|
|
3840
|
+
vi.mocked(s3Module.downloadFile).mockReset();
|
|
3841
|
+
vi.mocked(s3Module.downloadFile).mockImplementation(
|
|
3842
|
+
async (_ctx: unknown, key: string, localPath: string) => {
|
|
3843
|
+
if (key === invalidKey) {
|
|
3844
|
+
throw Object.assign(new Error("presign rejected invalid key"), {
|
|
3845
|
+
code: "INVALID_KEY_CONTROL_CHARS",
|
|
3846
|
+
});
|
|
3847
|
+
}
|
|
3848
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
3849
|
+
fs.writeFileSync(localPath, "mock file content");
|
|
3850
|
+
return { metadata: {} };
|
|
3851
|
+
},
|
|
3852
|
+
);
|
|
3853
|
+
const events: Array<{ type: string; [k: string]: unknown }> = [];
|
|
3854
|
+
|
|
3855
|
+
const result = await sync({
|
|
3856
|
+
company: "acme",
|
|
3857
|
+
vaultConfig: mockConfig,
|
|
3858
|
+
hqRoot: tmpDir,
|
|
3859
|
+
onEvent: (event) => events.push(event as never),
|
|
3860
|
+
});
|
|
3861
|
+
|
|
3862
|
+
expect(result.aborted).toBe(false);
|
|
3863
|
+
expect(result.filesDownloaded).toBe(1);
|
|
3864
|
+
expect(
|
|
3865
|
+
fs.existsSync(path.join(tmpDir, "companies", "acme", "docs", "good.md")),
|
|
3866
|
+
).toBe(true);
|
|
3867
|
+
expect(vi.mocked(s3Module.downloadFile).mock.calls.map((call) => call[1])).toEqual([
|
|
3868
|
+
"docs/good.md",
|
|
3869
|
+
]);
|
|
3870
|
+
expect(events.filter((event) => event.type === "skip-invalid-scoped-key")).toEqual([
|
|
3871
|
+
{
|
|
3872
|
+
type: "skip-invalid-scoped-key",
|
|
3873
|
+
path: invalidKey,
|
|
3874
|
+
errorCode: "INVALID_KEY_CONTROL_CHARS",
|
|
3875
|
+
},
|
|
3876
|
+
]);
|
|
3877
|
+
expect(events.filter((event) => event.type === "error")).toEqual([]);
|
|
3878
|
+
});
|
|
3879
|
+
|
|
3761
3880
|
it("cleans a doubled-tree journal entry via tombstone (local delete + journal drop) without HEAD-verifying the invalid key", async () => {
|
|
3762
3881
|
// A doubled local tree (companies/acme/companies/acme/…) previously
|
|
3763
3882
|
// poisoned the bucket; the poisoned objects were deleted server-side.
|
package/src/cli/sync.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
headRemoteFile,
|
|
27
27
|
primeObjectTransport,
|
|
28
28
|
toPosixKey,
|
|
29
|
+
classifyVaultKey,
|
|
29
30
|
} from "../s3.js";
|
|
30
31
|
import type { DownloadModeWarning, RemoteFile } from "../s3.js";
|
|
31
32
|
import {
|
|
@@ -348,12 +349,10 @@ export type SyncProgressEvent =
|
|
|
348
349
|
}
|
|
349
350
|
| {
|
|
350
351
|
/**
|
|
351
|
-
* Emitted by the PULL leg once per remote key skipped because
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
* Such keys are bucket poisoning from a client with a stale doubled
|
|
356
|
-
* local tree writing via direct S3 (incident 2026-07-11). Like
|
|
352
|
+
* Emitted by the PULL leg once per remote key skipped because the server
|
|
353
|
+
* will never presign it (for example, a `companies/…`-prefixed key in a
|
|
354
|
+
* company-scoped vault or a key with control characters). Such keys are
|
|
355
|
+
* bucket poisoning from an outdated direct-S3 client. Like
|
|
357
356
|
* `skip-size-limit`, this is deliberately NOT `type: "error"` — one bad
|
|
358
357
|
* object must never error a whole company sync (erroring gated
|
|
359
358
|
* heartbeat toolset refreshes fleet-wide in the incident). Surfaced for
|
|
@@ -363,6 +362,8 @@ export type SyncProgressEvent =
|
|
|
363
362
|
*/
|
|
364
363
|
type: "skip-invalid-scoped-key";
|
|
365
364
|
path: string;
|
|
365
|
+
/** Server-compatible invalid-key code, such as INVALID_KEY_CONTROL_CHARS. */
|
|
366
|
+
errorCode?: string;
|
|
366
367
|
};
|
|
367
368
|
|
|
368
369
|
export interface SyncOptions {
|
|
@@ -602,6 +603,8 @@ interface PullRunContext {
|
|
|
602
603
|
*/
|
|
603
604
|
currentExcludeSet: string[];
|
|
604
605
|
fileTombstones: ReadonlyMap<string, CompanyTombstone>;
|
|
606
|
+
/** Successful downloads since the last durable journal checkpoint. */
|
|
607
|
+
downloadsSinceJournalCheckpoint: number;
|
|
605
608
|
}
|
|
606
609
|
|
|
607
610
|
interface PullCounters {
|
|
@@ -647,6 +650,8 @@ export function resolveAutoPruneCap(): number {
|
|
|
647
650
|
|
|
648
651
|
/** Max time to wait on the best-effort new-files notification POST. */
|
|
649
652
|
const NOTIFY_FILE_ADDED_TIMEOUT_MS = 5000;
|
|
653
|
+
/** Bound crash recovery to at most this many completed pull downloads. */
|
|
654
|
+
const DOWNLOAD_JOURNAL_CHECKPOINT_BATCH_SIZE = 8;
|
|
650
655
|
|
|
651
656
|
/**
|
|
652
657
|
* Server cap on files per `/v1/notify/file-added` report. The endpoint rejects
|
|
@@ -812,7 +817,7 @@ async function syncWithOperationLockHeld(
|
|
|
812
817
|
const plan = planPull(run);
|
|
813
818
|
|
|
814
819
|
emitPullPlan(run.emit, plan);
|
|
815
|
-
|
|
820
|
+
reportInvalidRemoteKeys(run, plan);
|
|
816
821
|
|
|
817
822
|
const scopePlan = planScopeShrink(run);
|
|
818
823
|
const scopeRun = executeScopeShrink(run, scopePlan);
|
|
@@ -915,6 +920,7 @@ async function buildPullContext(options: SyncOptions): Promise<PullRunContext> {
|
|
|
915
920
|
currentPrefixSet,
|
|
916
921
|
currentExcludeSet,
|
|
917
922
|
fileTombstones,
|
|
923
|
+
downloadsSinceJournalCheckpoint: 0,
|
|
918
924
|
};
|
|
919
925
|
}
|
|
920
926
|
|
|
@@ -946,30 +952,30 @@ function emitPullPlan(emit: SyncEventEmitter, plan: PullPlan): void {
|
|
|
946
952
|
}
|
|
947
953
|
|
|
948
954
|
/**
|
|
949
|
-
* Surface
|
|
950
|
-
* `
|
|
955
|
+
* Surface permanently-unpresignable remote keys the planner skipped (see the
|
|
956
|
+
* `invalidRemoteKeys` doc on PullPlan). One warning event per key on the
|
|
951
957
|
* existing sync event channel (deliberately NOT `type: "error"` — see the
|
|
952
958
|
* `skip-invalid-scoped-key` event doc), plus one best-effort cloud telemetry
|
|
953
959
|
* event on the channel sync already uses (emitCloudTelemetry). Per policy
|
|
954
960
|
* hq-alert-baseline-calibration the documented healthy baseline is ZERO
|
|
955
|
-
*
|
|
956
|
-
*
|
|
961
|
+
* invalid-key occurrences fleet-wide, so consumers can alert on any non-zero
|
|
962
|
+
* count with no threshold tuning.
|
|
957
963
|
*/
|
|
958
|
-
function
|
|
959
|
-
if (plan.
|
|
960
|
-
|
|
961
|
-
|
|
964
|
+
function reportInvalidRemoteKeys(run: PullRunContext, plan: PullPlan): void {
|
|
965
|
+
if (plan.invalidRemoteKeys.length === 0) return;
|
|
966
|
+
const countsByErrorCode = new Map<string, number>();
|
|
967
|
+
for (const { path, errorCode } of plan.invalidRemoteKeys) {
|
|
968
|
+
run.emit({ type: "skip-invalid-scoped-key", path, errorCode });
|
|
969
|
+
countsByErrorCode.set(errorCode, (countsByErrorCode.get(errorCode) ?? 0) + 1);
|
|
970
|
+
}
|
|
971
|
+
for (const [errorCode, count] of countsByErrorCode) {
|
|
972
|
+
void emitCloudTelemetry(new VaultClient(run.vaultConfig), {
|
|
973
|
+
eventName: "sync_invalid_scoped_key_skipped",
|
|
974
|
+
source: "hq-sync",
|
|
975
|
+
companyUid: run.ctx.uid,
|
|
976
|
+
properties: { count, errorCode, leg: "pull" },
|
|
977
|
+
}, { claims: run.options.telemetryClaims });
|
|
962
978
|
}
|
|
963
|
-
void emitCloudTelemetry(new VaultClient(run.vaultConfig), {
|
|
964
|
-
eventName: "sync_invalid_scoped_key_skipped",
|
|
965
|
-
source: "hq-sync",
|
|
966
|
-
companyUid: run.ctx.uid,
|
|
967
|
-
properties: {
|
|
968
|
-
count: plan.invalidScopedKeys.length,
|
|
969
|
-
errorCode: "INVALID_KEY_COMPANIES_SCOPED",
|
|
970
|
-
leg: "pull",
|
|
971
|
-
},
|
|
972
|
-
}, { claims: run.options.telemetryClaims });
|
|
973
979
|
}
|
|
974
980
|
|
|
975
981
|
/**
|
|
@@ -1534,6 +1540,19 @@ async function downloadOne(
|
|
|
1534
1540
|
message: err instanceof Error ? err.message : String(err),
|
|
1535
1541
|
});
|
|
1536
1542
|
}
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
run.downloadsSinceJournalCheckpoint++;
|
|
1547
|
+
if (
|
|
1548
|
+
run.downloadsSinceJournalCheckpoint >=
|
|
1549
|
+
DOWNLOAD_JOURNAL_CHECKPOINT_BATCH_SIZE
|
|
1550
|
+
) {
|
|
1551
|
+
// writeJournal is already fsync + rename atomic. Persist completed work
|
|
1552
|
+
// before scheduling more objects so an abrupt process exit only replays a
|
|
1553
|
+
// small tail, not an entire bulk download.
|
|
1554
|
+
writeJournal(run.journalSlug, run.journal);
|
|
1555
|
+
run.downloadsSinceJournalCheckpoint = 0;
|
|
1537
1556
|
}
|
|
1538
1557
|
}
|
|
1539
1558
|
|
|
@@ -1973,14 +1992,13 @@ interface PullPlan {
|
|
|
1973
1992
|
*/
|
|
1974
1993
|
filesToTombstoneDelete: number;
|
|
1975
1994
|
/**
|
|
1976
|
-
* Remote keys skipped because
|
|
1977
|
-
*
|
|
1978
|
-
*
|
|
1979
|
-
*
|
|
1980
|
-
* telemetry. Healthy baseline is an empty array (policy
|
|
1995
|
+
* Remote keys skipped because the server will never presign them. Counted in
|
|
1996
|
+
* `filesExcludedByPolicy`; carried with the server-compatible error code so
|
|
1997
|
+
* the caller can log one warning per key and emit telemetry. Healthy
|
|
1998
|
+
* baseline is an empty array (policy
|
|
1981
1999
|
* hq-alert-baseline-calibration: zero expected, any occurrence is signal).
|
|
1982
2000
|
*/
|
|
1983
|
-
|
|
2001
|
+
invalidRemoteKeys: Array<{ path: string; errorCode: string }>;
|
|
1984
2002
|
}
|
|
1985
2003
|
|
|
1986
2004
|
/**
|
|
@@ -2057,9 +2075,9 @@ function computePullPlan(
|
|
|
2057
2075
|
excludePrefixes: readonly ScopePrefixInput[] = [],
|
|
2058
2076
|
): PullPlan {
|
|
2059
2077
|
const items: PullPlanItem[] = [];
|
|
2060
|
-
//
|
|
2061
|
-
//
|
|
2062
|
-
const
|
|
2078
|
+
// Remote keys the server will never presign — collected so the caller can
|
|
2079
|
+
// warn + emit telemetry without allowing one poison object to fail a pull.
|
|
2080
|
+
const invalidRemoteKeys: Array<{ path: string; errorCode: string }> = [];
|
|
2063
2081
|
// Clean/current/non-divergent files missing locally — collected here, then
|
|
2064
2082
|
// resolved post-loop by the bulk-asymmetry decision (tombstone vs. restore).
|
|
2065
2083
|
const intentionalDeleteCandidates: Array<{
|
|
@@ -2068,6 +2086,23 @@ function computePullPlan(
|
|
|
2068
2086
|
}> = [];
|
|
2069
2087
|
|
|
2070
2088
|
for (const remoteFile of remoteFiles) {
|
|
2089
|
+
const invalidKey = classifyVaultKey(
|
|
2090
|
+
remoteFile.key,
|
|
2091
|
+
personalMode ? "personal" : "company",
|
|
2092
|
+
);
|
|
2093
|
+
if (invalidKey !== null) {
|
|
2094
|
+
invalidRemoteKeys.push({
|
|
2095
|
+
path: remoteFile.key,
|
|
2096
|
+
errorCode: invalidKey.code,
|
|
2097
|
+
});
|
|
2098
|
+
items.push({
|
|
2099
|
+
action: "skip-excluded-policy",
|
|
2100
|
+
remoteFile,
|
|
2101
|
+
localPath: companyRoot,
|
|
2102
|
+
});
|
|
2103
|
+
continue;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2071
2106
|
const localPath = resolveContainedVaultPath(companyRoot, remoteFile.key);
|
|
2072
2107
|
if (localPath === null) {
|
|
2073
2108
|
items.push({ action: "skip-excluded-policy", remoteFile, localPath: companyRoot });
|
|
@@ -2085,23 +2120,6 @@ function computePullPlan(
|
|
|
2085
2120
|
continue;
|
|
2086
2121
|
}
|
|
2087
2122
|
|
|
2088
|
-
// Scope-invalid key filter (incident 2026-07-11). A company vault bucket
|
|
2089
|
-
// is already company-scoped, so a literal `companies/…` key can only be
|
|
2090
|
-
// bucket poisoning — a client with a stale doubled local tree
|
|
2091
|
-
// (companies/{slug}/companies/{slug}/…) pushing via the direct-S3 STS
|
|
2092
|
-
// transport, which bypasses the server validator. The presign download
|
|
2093
|
-
// API rejects these keys (INVALID_KEY_COMPANIES_SCOPED), so attempting to
|
|
2094
|
-
// download them errors the WHOLE company sync. Skip them here — one bad
|
|
2095
|
-
// object must never take down a company sync — and record them on the
|
|
2096
|
-
// plan so the caller can warn + emit telemetry (baseline is zero; any
|
|
2097
|
-
// occurrence is signal). Mirrors the personalMode/malformed-key filters
|
|
2098
|
-
// above. Upload-side counterpart: validateVaultUploadKey in s3.ts.
|
|
2099
|
-
if (!personalMode && remoteFile.key.startsWith("companies/")) {
|
|
2100
|
-
invalidScopedKeys.push(remoteFile.key);
|
|
2101
|
-
items.push({ action: "skip-excluded-policy", remoteFile, localPath });
|
|
2102
|
-
continue;
|
|
2103
|
-
}
|
|
2104
|
-
|
|
2105
2123
|
if (
|
|
2106
2124
|
personalMode &&
|
|
2107
2125
|
remoteFile.key.startsWith("companies/") &&
|
|
@@ -2662,7 +2680,7 @@ function computePullPlan(
|
|
|
2662
2680
|
filesOutOfScope,
|
|
2663
2681
|
filesToTombstoneDelete,
|
|
2664
2682
|
tombstones,
|
|
2665
|
-
|
|
2683
|
+
invalidRemoteKeys,
|
|
2666
2684
|
};
|
|
2667
2685
|
}
|
|
2668
2686
|
|
|
@@ -2747,7 +2765,7 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
|
2747
2765
|
);
|
|
2748
2766
|
} else if (event.type === "skip-invalid-scoped-key") {
|
|
2749
2767
|
console.warn(
|
|
2750
|
-
` ! ${event.path} skipped — invalid
|
|
2768
|
+
` ! ${event.path} skipped — invalid vault key (${event.errorCode ?? "INVALID_KEY_COMPANIES_SCOPED"})`,
|
|
2751
2769
|
);
|
|
2752
2770
|
}
|
|
2753
2771
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import yaml from "js-yaml";
|
|
6
|
+
|
|
7
|
+
import { reconcileCompanyManifest } from "./manifest-reconcile.js";
|
|
8
|
+
import type { EntityInfo } from "./vault-client.js";
|
|
9
|
+
|
|
10
|
+
let hqRoot: string | undefined;
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
if (hqRoot) {
|
|
14
|
+
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
15
|
+
hqRoot = undefined;
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("reconcileCompanyManifest", () => {
|
|
20
|
+
it("atomically restores one successful cloud entry after the personal leg without replacing existing manifest state", async () => {
|
|
21
|
+
hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-manifest-reconcile-"));
|
|
22
|
+
const companiesDir = path.join(hqRoot, "companies");
|
|
23
|
+
fs.mkdirSync(path.join(companiesDir, "acme"), { recursive: true });
|
|
24
|
+
fs.mkdirSync(path.join(companiesDir, "failed"), { recursive: true });
|
|
25
|
+
fs.mkdirSync(path.join(companiesDir, "deleted"), { recursive: true });
|
|
26
|
+
|
|
27
|
+
// This is the older manifest the personal-vault leg pulled after the
|
|
28
|
+
// cloud-company legs completed. The final merge must retain this state and
|
|
29
|
+
// add only the live, successful cloud target.
|
|
30
|
+
const manifestPath = path.join(companiesDir, "manifest.yaml");
|
|
31
|
+
fs.writeFileSync(
|
|
32
|
+
manifestPath,
|
|
33
|
+
`version: 3\nsettings:\n keep: true\ncompanies:\n local-only:\n name: Local Only\n repos:\n - repos/local\n preserved:\n name: Keep Me\n cloud_uid: cmp_preserved\n`,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const entities: Record<string, EntityInfo> = {
|
|
37
|
+
cmp_acme: {
|
|
38
|
+
uid: "cmp_acme",
|
|
39
|
+
slug: "acme",
|
|
40
|
+
type: "company",
|
|
41
|
+
status: "active",
|
|
42
|
+
name: "Acme Inc",
|
|
43
|
+
bucketName: "hq-vault-cmp-acme",
|
|
44
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
45
|
+
},
|
|
46
|
+
cmp_failed: {
|
|
47
|
+
uid: "cmp_failed",
|
|
48
|
+
slug: "failed",
|
|
49
|
+
type: "company",
|
|
50
|
+
status: "active",
|
|
51
|
+
name: "Failed Corp",
|
|
52
|
+
bucketName: "hq-vault-cmp-failed",
|
|
53
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
54
|
+
},
|
|
55
|
+
cmp_deleted: {
|
|
56
|
+
uid: "cmp_deleted",
|
|
57
|
+
slug: "deleted",
|
|
58
|
+
type: "company",
|
|
59
|
+
status: "deleted",
|
|
60
|
+
name: "Deleted Corp",
|
|
61
|
+
bucketName: "hq-vault-cmp-deleted",
|
|
62
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
const options = {
|
|
66
|
+
hqRoot,
|
|
67
|
+
targets: [
|
|
68
|
+
{ uid: "cmp_acme", slug: "acme" },
|
|
69
|
+
{ uid: "cmp_failed", slug: "failed" },
|
|
70
|
+
{ uid: "cmp_deleted", slug: "deleted" },
|
|
71
|
+
{ uid: "cmp_missing-directory", slug: "missing-directory" },
|
|
72
|
+
{ uid: "prs_me", slug: "personal", personalMode: true },
|
|
73
|
+
],
|
|
74
|
+
completedCompanySlugs: new Set(["acme", "deleted", "missing-directory", "personal"]),
|
|
75
|
+
getEntity: async (uid: string) => {
|
|
76
|
+
const entity = entities[uid];
|
|
77
|
+
if (!entity) throw new Error("entity not found");
|
|
78
|
+
return entity;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
await reconcileCompanyManifest(options);
|
|
83
|
+
const firstWrite = fs.readFileSync(manifestPath, "utf8");
|
|
84
|
+
await reconcileCompanyManifest(options);
|
|
85
|
+
|
|
86
|
+
expect(fs.readFileSync(manifestPath, "utf8")).toBe(firstWrite);
|
|
87
|
+
expect(yaml.load(firstWrite)).toEqual({
|
|
88
|
+
version: 3,
|
|
89
|
+
settings: { keep: true },
|
|
90
|
+
companies: {
|
|
91
|
+
"local-only": {
|
|
92
|
+
name: "Local Only",
|
|
93
|
+
repos: ["repos/local"],
|
|
94
|
+
},
|
|
95
|
+
preserved: {
|
|
96
|
+
name: "Keep Me",
|
|
97
|
+
cloud_uid: "cmp_preserved",
|
|
98
|
+
},
|
|
99
|
+
acme: {
|
|
100
|
+
name: "Acme Inc",
|
|
101
|
+
cloud_uid: "cmp_acme",
|
|
102
|
+
bucket_name: "hq-vault-cmp-acme",
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconcile successfully pulled cloud companies into the local manifest.
|
|
3
|
+
*
|
|
4
|
+
* The personal-vault leg also carries `companies/manifest.yaml`, so this runs
|
|
5
|
+
* only after the complete fanout has settled. That makes the locally
|
|
6
|
+
* materialized cloud companies authoritative for their own manifest entries
|
|
7
|
+
* without discarding entries the personal vault already had.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import yaml from "js-yaml";
|
|
14
|
+
|
|
15
|
+
import type { EntityInfo } from "./vault-client.js";
|
|
16
|
+
|
|
17
|
+
export interface ManifestReconcileTarget {
|
|
18
|
+
uid: string;
|
|
19
|
+
slug: string;
|
|
20
|
+
personalMode?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ManifestReconcileOptions {
|
|
24
|
+
hqRoot: string;
|
|
25
|
+
targets: readonly ManifestReconcileTarget[];
|
|
26
|
+
completedCompanySlugs: ReadonlySet<string>;
|
|
27
|
+
getEntity: (uid: string) => Promise<EntityInfo>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ManifestCompany {
|
|
31
|
+
name?: unknown;
|
|
32
|
+
cloud_uid?: unknown;
|
|
33
|
+
bucket_name?: unknown;
|
|
34
|
+
[key: string]: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ManifestDocument {
|
|
38
|
+
companies?: Record<string, ManifestCompany>;
|
|
39
|
+
[key: string]: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
43
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isLiveCompany(entity: EntityInfo, uid: string): boolean {
|
|
47
|
+
return (
|
|
48
|
+
entity.uid === uid &&
|
|
49
|
+
entity.type === "company" &&
|
|
50
|
+
entity.status === "active" &&
|
|
51
|
+
typeof entity.name === "string" &&
|
|
52
|
+
entity.name.length > 0 &&
|
|
53
|
+
typeof entity.bucketName === "string" &&
|
|
54
|
+
entity.bucketName.length > 0
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function hasCompanyDirectory(companiesDir: string, slug: string): boolean {
|
|
59
|
+
const resolvedCompaniesDir = path.resolve(companiesDir);
|
|
60
|
+
const companyDir = path.resolve(resolvedCompaniesDir, slug);
|
|
61
|
+
if (path.dirname(companyDir) !== resolvedCompaniesDir) return false;
|
|
62
|
+
try {
|
|
63
|
+
return fs.statSync(companyDir).isDirectory();
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function loadManifest(manifestPath: string): ManifestDocument | null {
|
|
70
|
+
try {
|
|
71
|
+
const raw = fs.readFileSync(manifestPath, "utf8");
|
|
72
|
+
const parsed = yaml.load(raw);
|
|
73
|
+
return isRecord(parsed) ? (parsed as ManifestDocument) : null;
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function writeManifestAtomically(manifestPath: string, document: ManifestDocument): void {
|
|
81
|
+
const temporaryPath = path.join(
|
|
82
|
+
path.dirname(manifestPath),
|
|
83
|
+
`.manifest-${process.pid}-${randomUUID()}.yaml`,
|
|
84
|
+
);
|
|
85
|
+
fs.writeFileSync(temporaryPath, yaml.dump(document, { lineWidth: -1 }), "utf8");
|
|
86
|
+
fs.renameSync(temporaryPath, manifestPath);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Atomically merge live, successfully pulled company targets into
|
|
91
|
+
* `companies/manifest.yaml`. Targets that are personal, incomplete, deleted,
|
|
92
|
+
* no longer resolvable, or not materialized on disk are intentionally ignored.
|
|
93
|
+
*/
|
|
94
|
+
export async function reconcileCompanyManifest(
|
|
95
|
+
options: ManifestReconcileOptions,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
const companiesDir = path.join(options.hqRoot, "companies");
|
|
98
|
+
const candidates: Array<{ slug: string; entity: EntityInfo }> = [];
|
|
99
|
+
|
|
100
|
+
for (const target of options.targets) {
|
|
101
|
+
if (
|
|
102
|
+
target.personalMode === true ||
|
|
103
|
+
!options.completedCompanySlugs.has(target.slug) ||
|
|
104
|
+
!hasCompanyDirectory(companiesDir, target.slug)
|
|
105
|
+
) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const entity = await options.getEntity(target.uid);
|
|
111
|
+
if (isLiveCompany(entity, target.uid)) {
|
|
112
|
+
candidates.push({ slug: target.slug, entity });
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// The target is not presently a live, resolvable company. Never mint a
|
|
116
|
+
// manifest entry from stale fanout data.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (candidates.length === 0) return;
|
|
121
|
+
|
|
122
|
+
const manifestPath = path.join(companiesDir, "manifest.yaml");
|
|
123
|
+
const document = loadManifest(manifestPath);
|
|
124
|
+
if (!document) return;
|
|
125
|
+
|
|
126
|
+
let companies: Record<string, ManifestCompany>;
|
|
127
|
+
if (document.companies === undefined) {
|
|
128
|
+
companies = {};
|
|
129
|
+
document.companies = companies;
|
|
130
|
+
} else if (isRecord(document.companies)) {
|
|
131
|
+
companies = document.companies as Record<string, ManifestCompany>;
|
|
132
|
+
} else {
|
|
133
|
+
// A malformed `companies` key is user state; refusing to replace it is
|
|
134
|
+
// safer than losing a manifest that cannot be interpreted unambiguously.
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let changed = false;
|
|
139
|
+
for (const { slug, entity } of candidates) {
|
|
140
|
+
const existing = companies[slug];
|
|
141
|
+
if (existing !== undefined && !isRecord(existing)) continue;
|
|
142
|
+
const entry: ManifestCompany = existing ?? {};
|
|
143
|
+
if (
|
|
144
|
+
entry.name === entity.name &&
|
|
145
|
+
entry.cloud_uid === entity.uid &&
|
|
146
|
+
entry.bucket_name === entity.bucketName
|
|
147
|
+
) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
companies[slug] = {
|
|
151
|
+
...entry,
|
|
152
|
+
name: entity.name,
|
|
153
|
+
cloud_uid: entity.uid,
|
|
154
|
+
bucket_name: entity.bucketName,
|
|
155
|
+
};
|
|
156
|
+
changed = true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (changed) writeManifestAtomically(manifestPath, document);
|
|
160
|
+
}
|