@indigoai-us/hq-cloud 6.14.18 → 6.14.20
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-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +8 -16
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner-planning.test.js +138 -0
- package/dist/bin/sync-runner-planning.test.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 +36 -6
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +550 -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-planning.test.ts +153 -0
- package/src/bin/sync-runner-planning.ts +10 -17
- 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 +662 -6
- package/src/bin/sync-runner.ts +65 -7
- 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
|
@@ -33,9 +33,20 @@ import type {
|
|
|
33
33
|
VaultClientSurface,
|
|
34
34
|
WatcherSurface,
|
|
35
35
|
} from "./sync-runner.js";
|
|
36
|
-
import {
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
import {
|
|
37
|
+
FakeClock,
|
|
38
|
+
type LocalDeleteSnapshot,
|
|
39
|
+
type TreeChangeBatch,
|
|
40
|
+
} from "../watcher.js";
|
|
41
|
+
import {
|
|
42
|
+
adaptivePollMs,
|
|
43
|
+
journalEntriesForWatcherDelete,
|
|
44
|
+
} from "./sync-runner-watch-loop.js";
|
|
45
|
+
import {
|
|
46
|
+
PERSONAL_VAULT_JOURNAL_SLUG,
|
|
47
|
+
readJournal,
|
|
48
|
+
writeJournal,
|
|
49
|
+
} from "../journal.js";
|
|
39
50
|
import { HQ_CLOUD_VERSION } from "../version.js";
|
|
40
51
|
import { lockPathFor, OPERATION_LOCKED_EXIT } from "../operation-lock.js";
|
|
41
52
|
import { TRANSIENT_NETWORK_EXIT } from "../lib/net-errors.js";
|
|
@@ -231,6 +242,9 @@ function makeDeps(overrides: Partial<RunnerDeps> = {}): TestDeps {
|
|
|
231
242
|
updated: true,
|
|
232
243
|
embedded: false,
|
|
233
244
|
pendingDirty: false,
|
|
245
|
+
lockBusy: false,
|
|
246
|
+
timedOut: false,
|
|
247
|
+
corruptionQuarantined: false,
|
|
234
248
|
})),
|
|
235
249
|
reconcileManifest: vi
|
|
236
250
|
.fn()
|
|
@@ -2159,6 +2173,9 @@ describe("all-complete aggregate", () => {
|
|
|
2159
2173
|
updated: true,
|
|
2160
2174
|
embedded: false,
|
|
2161
2175
|
pendingDirty: false,
|
|
2176
|
+
lockBusy: false,
|
|
2177
|
+
timedOut: false,
|
|
2178
|
+
corruptionQuarantined: false,
|
|
2162
2179
|
}));
|
|
2163
2180
|
const deps = makeDeps({
|
|
2164
2181
|
createVaultClient: () =>
|
|
@@ -5577,13 +5594,13 @@ describe("runRunnerWithLoop — operation lock", () => {
|
|
|
5577
5594
|
// pass (events must never announce bytes that are not in S3 yet).
|
|
5578
5595
|
|
|
5579
5596
|
interface BatchWatcherStub extends WatcherSurface {
|
|
5580
|
-
emit(changedRelPath?: string, batch?:
|
|
5597
|
+
emit(changedRelPath?: string, batch?: TreeChangeBatch): void;
|
|
5581
5598
|
disposed: boolean;
|
|
5582
5599
|
}
|
|
5583
5600
|
|
|
5584
5601
|
function makeBatchWatcherStub(): BatchWatcherStub {
|
|
5585
5602
|
const listeners = new Set<
|
|
5586
|
-
(p?: string, b?:
|
|
5603
|
+
(p?: string, b?: TreeChangeBatch) => void
|
|
5587
5604
|
>();
|
|
5588
5605
|
return {
|
|
5589
5606
|
disposed: false,
|
|
@@ -5597,7 +5614,7 @@ function makeBatchWatcherStub(): BatchWatcherStub {
|
|
|
5597
5614
|
this.disposed = true;
|
|
5598
5615
|
listeners.clear();
|
|
5599
5616
|
},
|
|
5600
|
-
emit(changedRelPath?: string, batch?:
|
|
5617
|
+
emit(changedRelPath?: string, batch?: TreeChangeBatch) {
|
|
5601
5618
|
for (const l of [...listeners]) l(changedRelPath, batch);
|
|
5602
5619
|
},
|
|
5603
5620
|
};
|
|
@@ -5774,6 +5791,94 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
5774
5791
|
await loop;
|
|
5775
5792
|
});
|
|
5776
5793
|
|
|
5794
|
+
it("does not publish an exit-zero deletion that the scoped push refused", async () => {
|
|
5795
|
+
const publishBatch = vi.fn();
|
|
5796
|
+
const startEventSync = vi.fn().mockResolvedValue({
|
|
5797
|
+
publishBatch,
|
|
5798
|
+
receiver: { start: async () => {}, dispose: async () => {}, connected: true },
|
|
5799
|
+
ownDeviceId: "dev-test",
|
|
5800
|
+
dispose: vi.fn().mockResolvedValue(undefined),
|
|
5801
|
+
});
|
|
5802
|
+
const runPass = vi
|
|
5803
|
+
.fn()
|
|
5804
|
+
.mockResolvedValueOnce(0)
|
|
5805
|
+
.mockResolvedValueOnce({
|
|
5806
|
+
exitCode: 0,
|
|
5807
|
+
result: {
|
|
5808
|
+
pushPathResults: [{
|
|
5809
|
+
relativePath: "companies/indigo/deleted.md",
|
|
5810
|
+
status: "refused",
|
|
5811
|
+
operation: "delete",
|
|
5812
|
+
reason: "missing-delete-intent",
|
|
5813
|
+
}],
|
|
5814
|
+
},
|
|
5815
|
+
})
|
|
5816
|
+
.mockResolvedValue(0);
|
|
5817
|
+
const { loop, watcher, tick, shutdown } = runLoop({
|
|
5818
|
+
claims: ENROLLED_CLAIMS,
|
|
5819
|
+
startEventSync,
|
|
5820
|
+
runPass,
|
|
5821
|
+
});
|
|
5822
|
+
await microtasks();
|
|
5823
|
+
watcher.emit("companies/indigo/deleted.md", {
|
|
5824
|
+
paths: new Map([[
|
|
5825
|
+
"/tmp/hq/companies/indigo/deleted.md",
|
|
5826
|
+
"companies/indigo/deleted.md",
|
|
5827
|
+
]]),
|
|
5828
|
+
});
|
|
5829
|
+
tick();
|
|
5830
|
+
await microtasks();
|
|
5831
|
+
|
|
5832
|
+
expect(publishBatch).not.toHaveBeenCalled();
|
|
5833
|
+
shutdown();
|
|
5834
|
+
await loop;
|
|
5835
|
+
});
|
|
5836
|
+
|
|
5837
|
+
it("does not publish unlinkDir when a covered descendant delete was refused", async () => {
|
|
5838
|
+
const publishBatch = vi.fn();
|
|
5839
|
+
const startEventSync = vi.fn().mockResolvedValue({
|
|
5840
|
+
publishBatch,
|
|
5841
|
+
receiver: { start: async () => {}, dispose: async () => {}, connected: true },
|
|
5842
|
+
ownDeviceId: "dev-test",
|
|
5843
|
+
dispose: vi.fn().mockResolvedValue(undefined),
|
|
5844
|
+
});
|
|
5845
|
+
const runPass = vi
|
|
5846
|
+
.fn()
|
|
5847
|
+
.mockResolvedValueOnce(0)
|
|
5848
|
+
.mockResolvedValueOnce({
|
|
5849
|
+
exitCode: 0,
|
|
5850
|
+
result: {
|
|
5851
|
+
pushPathResults: [{
|
|
5852
|
+
relativePath: "companies/indigo/archive/deleted.md",
|
|
5853
|
+
status: "refused",
|
|
5854
|
+
operation: "delete",
|
|
5855
|
+
reason: "missing-delete-intent",
|
|
5856
|
+
}],
|
|
5857
|
+
},
|
|
5858
|
+
})
|
|
5859
|
+
.mockResolvedValue(0);
|
|
5860
|
+
const { loop, watcher, tick, shutdown } = runLoop({
|
|
5861
|
+
claims: ENROLLED_CLAIMS,
|
|
5862
|
+
startEventSync,
|
|
5863
|
+
runPass,
|
|
5864
|
+
});
|
|
5865
|
+
await microtasks();
|
|
5866
|
+
const absolutePath = "/tmp/hq/companies/indigo/archive";
|
|
5867
|
+
watcher.emit("companies/indigo/archive", {
|
|
5868
|
+
paths: new Map([[
|
|
5869
|
+
absolutePath,
|
|
5870
|
+
"companies/indigo/archive",
|
|
5871
|
+
]]),
|
|
5872
|
+
changes: new Map([[absolutePath, { kind: "unlinkDir" }]]),
|
|
5873
|
+
});
|
|
5874
|
+
tick();
|
|
5875
|
+
await microtasks();
|
|
5876
|
+
|
|
5877
|
+
expect(publishBatch).not.toHaveBeenCalled();
|
|
5878
|
+
shutdown();
|
|
5879
|
+
await loop;
|
|
5880
|
+
});
|
|
5881
|
+
|
|
5777
5882
|
it("F04: mixed-route watcher batches publish only routes that were pushed", async () => {
|
|
5778
5883
|
const publishBatch = vi.fn();
|
|
5779
5884
|
const startEventSync = vi.fn().mockResolvedValue({
|
|
@@ -6354,6 +6459,77 @@ describe("US-004 — --personal for an AGENT machine identity (HARD GATE)", () =
|
|
|
6354
6459
|
"custom:entityUid": AGENT_UID,
|
|
6355
6460
|
};
|
|
6356
6461
|
|
|
6462
|
+
it("uses the newly minted machine ID token for --personal claims when no cache claims are available", async () => {
|
|
6463
|
+
const machineIdToken = `header.${Buffer.from(JSON.stringify(agentClaims)).toString("base64url")}.signature`;
|
|
6464
|
+
const listByType = vi.fn((type: string) =>
|
|
6465
|
+
Promise.resolve(type === "agent" ? [agentSelfEntity] : []),
|
|
6466
|
+
);
|
|
6467
|
+
const syncSpy = vi.fn().mockResolvedValue(defaultSyncResult());
|
|
6468
|
+
const deps = makeDeps({
|
|
6469
|
+
getAccessToken: vi.fn().mockResolvedValue(machineIdToken),
|
|
6470
|
+
// Simulate a newly minted token before any cache read is available.
|
|
6471
|
+
getIdTokenClaims: () => null,
|
|
6472
|
+
sync: syncSpy as unknown as RunnerDeps["sync"],
|
|
6473
|
+
createVaultClient: () => ({
|
|
6474
|
+
...makeVaultStub(),
|
|
6475
|
+
entity: { get: () => Promise.resolve(undefined as never), listByType },
|
|
6476
|
+
}),
|
|
6477
|
+
});
|
|
6478
|
+
|
|
6479
|
+
expect(await runRunner(["--personal"], deps)).toBe(0);
|
|
6480
|
+
expect(listByType).toHaveBeenCalledWith("agent");
|
|
6481
|
+
expect(syncSpy).toHaveBeenCalledTimes(1);
|
|
6482
|
+
expect(deps.stderr.events().some((event) => event.type === "auth-error")).toBe(false);
|
|
6483
|
+
});
|
|
6484
|
+
|
|
6485
|
+
it("uses the freshly authenticated machine claims over a stale human cache for --companies", async () => {
|
|
6486
|
+
const machineIdToken = `header.${Buffer.from(JSON.stringify(agentClaims)).toString("base64url")}.signature`;
|
|
6487
|
+
const listByType = vi.fn((type: string) =>
|
|
6488
|
+
Promise.resolve(type === "agent" ? [agentSelfEntity] : []),
|
|
6489
|
+
);
|
|
6490
|
+
const syncSpy = vi.fn().mockResolvedValue(defaultSyncResult());
|
|
6491
|
+
const deps = makeDeps({
|
|
6492
|
+
getAccessToken: vi.fn().mockResolvedValue(machineIdToken),
|
|
6493
|
+
// A pre-bootstrap human token must not make this machine run select a
|
|
6494
|
+
// person vault (or drop its own personal target altogether).
|
|
6495
|
+
getIdTokenClaims: () => ({ sub: "human-sub" }),
|
|
6496
|
+
sync: syncSpy as unknown as RunnerDeps["sync"],
|
|
6497
|
+
createVaultClient: () => ({
|
|
6498
|
+
...makeVaultStub({ memberships: [{ companyUid: "cmp_team" }] }),
|
|
6499
|
+
entity: {
|
|
6500
|
+
get: (uid: string) =>
|
|
6501
|
+
Promise.resolve(
|
|
6502
|
+
uid === "cmp_team"
|
|
6503
|
+
? ({
|
|
6504
|
+
uid,
|
|
6505
|
+
type: "company",
|
|
6506
|
+
slug: "team",
|
|
6507
|
+
bucketName: "hq-vault-cmp-team",
|
|
6508
|
+
status: "active",
|
|
6509
|
+
} as EntityInfo)
|
|
6510
|
+
: (undefined as never),
|
|
6511
|
+
),
|
|
6512
|
+
listByType,
|
|
6513
|
+
},
|
|
6514
|
+
}),
|
|
6515
|
+
});
|
|
6516
|
+
|
|
6517
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
6518
|
+
expect(listByType).toHaveBeenCalledWith("agent");
|
|
6519
|
+
expect(syncSpy).toHaveBeenCalledTimes(2);
|
|
6520
|
+
const plan = deps.stdout.events().find((event) => event.type === "fanout-plan") as Extract<
|
|
6521
|
+
RunnerEvent,
|
|
6522
|
+
{ type: "fanout-plan" }
|
|
6523
|
+
>;
|
|
6524
|
+
expect(plan.companies).toContainEqual(
|
|
6525
|
+
expect.objectContaining({
|
|
6526
|
+
uid: AGENT_UID,
|
|
6527
|
+
slug: "personal",
|
|
6528
|
+
personalMode: true,
|
|
6529
|
+
}),
|
|
6530
|
+
);
|
|
6531
|
+
});
|
|
6532
|
+
|
|
6357
6533
|
// -----------------------------------------------------------------------
|
|
6358
6534
|
// (1) RESOLUTION — UNBLOCKED by US-013.
|
|
6359
6535
|
//
|
|
@@ -7001,3 +7177,483 @@ describe("US-002 — manifest reconciliation wiring", () => {
|
|
|
7001
7177
|
expect(deps.stderr.raw()).not.toContain("manifest-reconciled");
|
|
7002
7178
|
});
|
|
7003
7179
|
});
|
|
7180
|
+
|
|
7181
|
+
describe("watcher delete intents without pre-seeded authorization", () => {
|
|
7182
|
+
function startDeleteHarness(
|
|
7183
|
+
keys: string[],
|
|
7184
|
+
options: { gateInitialPull?: boolean } = {},
|
|
7185
|
+
) {
|
|
7186
|
+
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-delete-"));
|
|
7187
|
+
const companyRoot = path.join(hqRoot, "companies", "indigo");
|
|
7188
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
7189
|
+
const remote = new Map<string, string>();
|
|
7190
|
+
const files: Record<string, {
|
|
7191
|
+
hash: string; size: number; syncedAt: string; direction: "down";
|
|
7192
|
+
remoteEtag: string; kind: "file";
|
|
7193
|
+
}> = {};
|
|
7194
|
+
for (const key of keys) {
|
|
7195
|
+
const absolutePath = path.join(companyRoot, key);
|
|
7196
|
+
const content = `synced:${key}`;
|
|
7197
|
+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
7198
|
+
fs.writeFileSync(absolutePath, content);
|
|
7199
|
+
remote.set(key, content);
|
|
7200
|
+
files[key] = {
|
|
7201
|
+
hash: `hash:${key}`,
|
|
7202
|
+
size: Buffer.byteLength(content),
|
|
7203
|
+
syncedAt: new Date().toISOString(),
|
|
7204
|
+
direction: "down",
|
|
7205
|
+
remoteEtag: `etag:${key}`,
|
|
7206
|
+
kind: "file",
|
|
7207
|
+
};
|
|
7208
|
+
}
|
|
7209
|
+
writeJournal("indigo", {
|
|
7210
|
+
version: "2", lastSync: new Date().toISOString(), files, pulls: [],
|
|
7211
|
+
});
|
|
7212
|
+
|
|
7213
|
+
const watcher = makeBatchWatcherStub();
|
|
7214
|
+
const sleep = makeSteppableSleep();
|
|
7215
|
+
let capture: ((
|
|
7216
|
+
relativePath: string,
|
|
7217
|
+
kind: "unlink" | "unlinkDir",
|
|
7218
|
+
) => LocalDeleteSnapshot[]) | undefined;
|
|
7219
|
+
let triggerShutdown = () => {};
|
|
7220
|
+
let downloads = 0;
|
|
7221
|
+
let releaseInitialPull = () => {};
|
|
7222
|
+
const initialPullGate = new Promise<void>((resolve) => {
|
|
7223
|
+
releaseInitialPull = resolve;
|
|
7224
|
+
});
|
|
7225
|
+
let initialPullGated = options.gateInitialPull === true;
|
|
7226
|
+
const runPass = vi.fn(async (argv: string[]) => {
|
|
7227
|
+
const direction = argv[argv.indexOf("--direction") + 1];
|
|
7228
|
+
if (direction === "push") {
|
|
7229
|
+
const journal = readJournal("indigo");
|
|
7230
|
+
const pathResults: Array<{
|
|
7231
|
+
relativePath: string; status: "accepted" | "refused";
|
|
7232
|
+
operation: "delete"; reason?: string;
|
|
7233
|
+
}> = [];
|
|
7234
|
+
const scopes = argv.flatMap((value, index) =>
|
|
7235
|
+
argv[index - 1] === "--scope-path" ? [value] : [],
|
|
7236
|
+
);
|
|
7237
|
+
for (const scope of scopes) {
|
|
7238
|
+
for (const key of Object.keys(journal.files)) {
|
|
7239
|
+
if (key !== scope && !key.startsWith(`${scope}/`)) continue;
|
|
7240
|
+
const absolutePath = path.join(companyRoot, key);
|
|
7241
|
+
const entry = journal.files[key];
|
|
7242
|
+
if (fs.existsSync(absolutePath)) {
|
|
7243
|
+
remote.set(key, fs.readFileSync(absolutePath, "utf8"));
|
|
7244
|
+
continue;
|
|
7245
|
+
}
|
|
7246
|
+
const intent = entry.localDeleteIntent;
|
|
7247
|
+
const current =
|
|
7248
|
+
intent?.remoteEtag === entry.remoteEtag &&
|
|
7249
|
+
intent?.localHash === entry.hash &&
|
|
7250
|
+
intent?.localKind === entry.kind;
|
|
7251
|
+
if (current) {
|
|
7252
|
+
remote.delete(key);
|
|
7253
|
+
delete journal.files[key];
|
|
7254
|
+
pathResults.push({
|
|
7255
|
+
relativePath: `companies/indigo/${key}`,
|
|
7256
|
+
status: "accepted",
|
|
7257
|
+
operation: "delete",
|
|
7258
|
+
});
|
|
7259
|
+
} else {
|
|
7260
|
+
pathResults.push({
|
|
7261
|
+
relativePath: `companies/indigo/${key}`,
|
|
7262
|
+
status: "refused",
|
|
7263
|
+
operation: "delete",
|
|
7264
|
+
reason: "missing-delete-intent",
|
|
7265
|
+
});
|
|
7266
|
+
}
|
|
7267
|
+
}
|
|
7268
|
+
const added = path.join(companyRoot, scope);
|
|
7269
|
+
if (fs.existsSync(added) && fs.lstatSync(added).isFile()) {
|
|
7270
|
+
remote.set(scope, fs.readFileSync(added, "utf8"));
|
|
7271
|
+
}
|
|
7272
|
+
}
|
|
7273
|
+
writeJournal("indigo", journal);
|
|
7274
|
+
return { exitCode: 0, result: { pushPathResults: pathResults } };
|
|
7275
|
+
}
|
|
7276
|
+
if (direction === "pull" || direction === "both") {
|
|
7277
|
+
if (initialPullGated) {
|
|
7278
|
+
initialPullGated = false;
|
|
7279
|
+
await initialPullGate;
|
|
7280
|
+
}
|
|
7281
|
+
const journal = readJournal("indigo");
|
|
7282
|
+
for (const [key, content] of remote) {
|
|
7283
|
+
const absolutePath = path.join(companyRoot, key);
|
|
7284
|
+
if (fs.existsSync(absolutePath)) continue;
|
|
7285
|
+
const entry = journal.files[key];
|
|
7286
|
+
const intent = entry?.localDeleteIntent;
|
|
7287
|
+
const currentIntent =
|
|
7288
|
+
intent?.remoteEtag === entry?.remoteEtag &&
|
|
7289
|
+
intent?.localHash === entry?.hash &&
|
|
7290
|
+
intent?.localKind === entry?.kind;
|
|
7291
|
+
if (currentIntent) continue;
|
|
7292
|
+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
7293
|
+
fs.writeFileSync(absolutePath, content);
|
|
7294
|
+
downloads++;
|
|
7295
|
+
}
|
|
7296
|
+
}
|
|
7297
|
+
return 0;
|
|
7298
|
+
});
|
|
7299
|
+
const loop = runRunnerWithLoop(
|
|
7300
|
+
[
|
|
7301
|
+
"--companies", "--watch", "--event-push", "--direction", "both",
|
|
7302
|
+
"--hq-root", hqRoot, "--poll-remote-ms", "60000",
|
|
7303
|
+
],
|
|
7304
|
+
{
|
|
7305
|
+
runPass,
|
|
7306
|
+
createWatcher: (opts) => {
|
|
7307
|
+
capture = opts.captureLocalDeleteSnapshots;
|
|
7308
|
+
return watcher;
|
|
7309
|
+
},
|
|
7310
|
+
sleep: sleep.sleep,
|
|
7311
|
+
onShutdownSignal: (handler) => {
|
|
7312
|
+
triggerShutdown = handler;
|
|
7313
|
+
return () => {};
|
|
7314
|
+
},
|
|
7315
|
+
},
|
|
7316
|
+
);
|
|
7317
|
+
return {
|
|
7318
|
+
hqRoot, companyRoot, remote, watcher, loop, tick: sleep.tick,
|
|
7319
|
+
capture: () => capture!, downloads: () => downloads,
|
|
7320
|
+
releaseInitialPull,
|
|
7321
|
+
shutdown: () => triggerShutdown(),
|
|
7322
|
+
};
|
|
7323
|
+
}
|
|
7324
|
+
|
|
7325
|
+
async function settle(): Promise<void> {
|
|
7326
|
+
await flushLoopMicrotasks(12);
|
|
7327
|
+
}
|
|
7328
|
+
|
|
7329
|
+
async function stopHarness(h: ReturnType<typeof startDeleteHarness>) {
|
|
7330
|
+
h.shutdown();
|
|
7331
|
+
h.tick();
|
|
7332
|
+
await h.loop;
|
|
7333
|
+
fs.rmSync(h.hqRoot, { recursive: true, force: true });
|
|
7334
|
+
}
|
|
7335
|
+
|
|
7336
|
+
it("live unlink binds the pre-delete revision, deletes remote, and survives immediate plus later full pulls", async () => {
|
|
7337
|
+
const h = startDeleteHarness(["knowledge/gone.md"]);
|
|
7338
|
+
await settle();
|
|
7339
|
+
const absolutePath = path.join(h.companyRoot, "knowledge/gone.md");
|
|
7340
|
+
fs.unlinkSync(absolutePath);
|
|
7341
|
+
const snapshots = h.capture()(
|
|
7342
|
+
"companies/indigo/knowledge/gone.md", "unlink",
|
|
7343
|
+
);
|
|
7344
|
+
expect(snapshots).toHaveLength(1);
|
|
7345
|
+
expect(readJournal("indigo").files["knowledge/gone.md"]?.localDeleteIntent)
|
|
7346
|
+
.toMatchObject({
|
|
7347
|
+
remoteEtag: "etag:knowledge/gone.md",
|
|
7348
|
+
localHash: "hash:knowledge/gone.md",
|
|
7349
|
+
localKind: "file",
|
|
7350
|
+
});
|
|
7351
|
+
h.watcher.emit("companies/indigo/knowledge/gone.md", {
|
|
7352
|
+
paths: new Map([[absolutePath, "companies/indigo/knowledge/gone.md"]]),
|
|
7353
|
+
changes: new Map([
|
|
7354
|
+
[absolutePath, { kind: "unlink", deleteSnapshots: snapshots }],
|
|
7355
|
+
]),
|
|
7356
|
+
} as TreeChangeBatch);
|
|
7357
|
+
h.tick();
|
|
7358
|
+
await settle();
|
|
7359
|
+
expect(h.remote.has("knowledge/gone.md")).toBe(false);
|
|
7360
|
+
expect(fs.existsSync(absolutePath)).toBe(false);
|
|
7361
|
+
expect(h.downloads()).toBe(0);
|
|
7362
|
+
|
|
7363
|
+
for (let i = 0; i < 8; i++) {
|
|
7364
|
+
h.tick();
|
|
7365
|
+
await settle();
|
|
7366
|
+
}
|
|
7367
|
+
expect(fs.existsSync(absolutePath)).toBe(false);
|
|
7368
|
+
expect(h.downloads()).toBe(0);
|
|
7369
|
+
await stopHarness(h);
|
|
7370
|
+
});
|
|
7371
|
+
|
|
7372
|
+
it("persists the intent before an in-flight pull plans the missing path", async () => {
|
|
7373
|
+
const h = startDeleteHarness(
|
|
7374
|
+
["knowledge/in-flight.md"],
|
|
7375
|
+
{ gateInitialPull: true },
|
|
7376
|
+
);
|
|
7377
|
+
await settle();
|
|
7378
|
+
const relativePath = "companies/indigo/knowledge/in-flight.md";
|
|
7379
|
+
const absolutePath = path.join(
|
|
7380
|
+
h.companyRoot,
|
|
7381
|
+
"knowledge/in-flight.md",
|
|
7382
|
+
);
|
|
7383
|
+
fs.unlinkSync(absolutePath);
|
|
7384
|
+
const snapshots = h.capture()(relativePath, "unlink");
|
|
7385
|
+
|
|
7386
|
+
expect(snapshots).toHaveLength(1);
|
|
7387
|
+
expect(
|
|
7388
|
+
readJournal("indigo").files["knowledge/in-flight.md"]
|
|
7389
|
+
?.localDeleteIntent,
|
|
7390
|
+
).toBeDefined();
|
|
7391
|
+
|
|
7392
|
+
h.watcher.emit(relativePath, {
|
|
7393
|
+
paths: new Map([[absolutePath, relativePath]]),
|
|
7394
|
+
changes: new Map([
|
|
7395
|
+
[absolutePath, { kind: "unlink", deleteSnapshots: snapshots }],
|
|
7396
|
+
]),
|
|
7397
|
+
} as TreeChangeBatch);
|
|
7398
|
+
h.releaseInitialPull();
|
|
7399
|
+
await settle();
|
|
7400
|
+
|
|
7401
|
+
expect(h.downloads()).toBe(0);
|
|
7402
|
+
expect(fs.existsSync(absolutePath)).toBe(false);
|
|
7403
|
+
|
|
7404
|
+
h.tick();
|
|
7405
|
+
await settle();
|
|
7406
|
+
expect(h.remote.has("knowledge/in-flight.md")).toBe(false);
|
|
7407
|
+
expect(h.downloads()).toBe(0);
|
|
7408
|
+
await stopHarness(h);
|
|
7409
|
+
});
|
|
7410
|
+
|
|
7411
|
+
it("uses direct journal-key lookup for file unlink snapshots", () => {
|
|
7412
|
+
const exactEntry = {
|
|
7413
|
+
hash: "hash",
|
|
7414
|
+
size: 4,
|
|
7415
|
+
syncedAt: new Date().toISOString(),
|
|
7416
|
+
direction: "down" as const,
|
|
7417
|
+
remoteEtag: "etag",
|
|
7418
|
+
kind: "file" as const,
|
|
7419
|
+
};
|
|
7420
|
+
const files = new Proxy(
|
|
7421
|
+
{ "knowledge/exact.md": exactEntry },
|
|
7422
|
+
{
|
|
7423
|
+
ownKeys: () => {
|
|
7424
|
+
throw new Error("file unlink must not enumerate the journal");
|
|
7425
|
+
},
|
|
7426
|
+
},
|
|
7427
|
+
);
|
|
7428
|
+
|
|
7429
|
+
expect(
|
|
7430
|
+
journalEntriesForWatcherDelete(
|
|
7431
|
+
files,
|
|
7432
|
+
"knowledge/exact.md",
|
|
7433
|
+
"unlink",
|
|
7434
|
+
),
|
|
7435
|
+
).toEqual([["knowledge/exact.md", exactEntry]]);
|
|
7436
|
+
});
|
|
7437
|
+
|
|
7438
|
+
it("keeps a deleted top-level personal file in the scoped push delete roots", async () => {
|
|
7439
|
+
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-personal-delete-"));
|
|
7440
|
+
const key = "personal-note.md";
|
|
7441
|
+
const absolutePath = path.join(hqRoot, key);
|
|
7442
|
+
fs.writeFileSync(absolutePath, "personal content");
|
|
7443
|
+
const remote = new Map([[key, "personal content"]]);
|
|
7444
|
+
writeJournal(PERSONAL_VAULT_JOURNAL_SLUG, {
|
|
7445
|
+
version: "2",
|
|
7446
|
+
lastSync: new Date().toISOString(),
|
|
7447
|
+
pulls: [],
|
|
7448
|
+
files: {
|
|
7449
|
+
[key]: {
|
|
7450
|
+
hash: "personal-hash",
|
|
7451
|
+
size: 16,
|
|
7452
|
+
syncedAt: new Date().toISOString(),
|
|
7453
|
+
direction: "down",
|
|
7454
|
+
remoteEtag: "personal-etag",
|
|
7455
|
+
kind: "file",
|
|
7456
|
+
},
|
|
7457
|
+
},
|
|
7458
|
+
});
|
|
7459
|
+
|
|
7460
|
+
const shareSpy = vi.fn(async (options: ShareOptions) => {
|
|
7461
|
+
const journal = readJournal(PERSONAL_VAULT_JOURNAL_SLUG);
|
|
7462
|
+
const entry = journal.files[key];
|
|
7463
|
+
const intent = entry?.localDeleteIntent;
|
|
7464
|
+
const currentIntent =
|
|
7465
|
+
intent?.remoteEtag === entry?.remoteEtag &&
|
|
7466
|
+
intent?.localHash === entry?.hash &&
|
|
7467
|
+
intent?.localKind === entry?.kind;
|
|
7468
|
+
if (
|
|
7469
|
+
!fs.existsSync(absolutePath) &&
|
|
7470
|
+
options.deleteScopeRoots?.includes(key) &&
|
|
7471
|
+
currentIntent
|
|
7472
|
+
) {
|
|
7473
|
+
remote.delete(key);
|
|
7474
|
+
delete journal.files[key];
|
|
7475
|
+
writeJournal(PERSONAL_VAULT_JOURNAL_SLUG, journal);
|
|
7476
|
+
return defaultShareResult({
|
|
7477
|
+
filesDeleted: 1,
|
|
7478
|
+
pathResults: [
|
|
7479
|
+
{ path: key, status: "accepted", operation: "delete" },
|
|
7480
|
+
],
|
|
7481
|
+
});
|
|
7482
|
+
}
|
|
7483
|
+
return defaultShareResult();
|
|
7484
|
+
});
|
|
7485
|
+
let downloads = 0;
|
|
7486
|
+
const syncSpy = vi.fn(async () => {
|
|
7487
|
+
if (remote.has(key) && !fs.existsSync(absolutePath)) {
|
|
7488
|
+
const entry =
|
|
7489
|
+
readJournal(PERSONAL_VAULT_JOURNAL_SLUG).files[key];
|
|
7490
|
+
const intent = entry?.localDeleteIntent;
|
|
7491
|
+
const currentIntent =
|
|
7492
|
+
intent?.remoteEtag === entry?.remoteEtag &&
|
|
7493
|
+
intent?.localHash === entry?.hash &&
|
|
7494
|
+
intent?.localKind === entry?.kind;
|
|
7495
|
+
if (!currentIntent) {
|
|
7496
|
+
fs.writeFileSync(absolutePath, remote.get(key)!);
|
|
7497
|
+
downloads++;
|
|
7498
|
+
}
|
|
7499
|
+
}
|
|
7500
|
+
return defaultSyncResult();
|
|
7501
|
+
});
|
|
7502
|
+
const runnerDeps = makeDeps({
|
|
7503
|
+
share: shareSpy,
|
|
7504
|
+
sync: syncSpy,
|
|
7505
|
+
createVaultClient: () =>
|
|
7506
|
+
makeVaultStub({
|
|
7507
|
+
listPersons: () =>
|
|
7508
|
+
Promise.resolve([
|
|
7509
|
+
{
|
|
7510
|
+
uid: "prs_personal",
|
|
7511
|
+
type: "person",
|
|
7512
|
+
bucketName: "hq-vault-personal",
|
|
7513
|
+
status: "active",
|
|
7514
|
+
} as unknown as EntityInfo,
|
|
7515
|
+
]),
|
|
7516
|
+
}),
|
|
7517
|
+
operationLockAlreadyHeld: true,
|
|
7518
|
+
});
|
|
7519
|
+
const watcher = makeBatchWatcherStub();
|
|
7520
|
+
const sleep = makeSteppableSleep();
|
|
7521
|
+
let capture: ((
|
|
7522
|
+
relativePath: string,
|
|
7523
|
+
kind: "unlink" | "unlinkDir",
|
|
7524
|
+
) => LocalDeleteSnapshot[]) | undefined;
|
|
7525
|
+
let triggerShutdown = () => {};
|
|
7526
|
+
const loop = runRunnerWithLoop(
|
|
7527
|
+
[
|
|
7528
|
+
"--personal",
|
|
7529
|
+
"--watch",
|
|
7530
|
+
"--event-push",
|
|
7531
|
+
"--direction",
|
|
7532
|
+
"both",
|
|
7533
|
+
"--hq-root",
|
|
7534
|
+
hqRoot,
|
|
7535
|
+
"--poll-remote-ms",
|
|
7536
|
+
"60000",
|
|
7537
|
+
],
|
|
7538
|
+
{
|
|
7539
|
+
runPass: (passArgv) => runRunner(passArgv, runnerDeps),
|
|
7540
|
+
createWatcher: (options) => {
|
|
7541
|
+
capture = options.captureLocalDeleteSnapshots;
|
|
7542
|
+
return watcher;
|
|
7543
|
+
},
|
|
7544
|
+
sleep: sleep.sleep,
|
|
7545
|
+
onShutdownSignal: (handler) => {
|
|
7546
|
+
triggerShutdown = handler;
|
|
7547
|
+
return () => {};
|
|
7548
|
+
},
|
|
7549
|
+
},
|
|
7550
|
+
);
|
|
7551
|
+
await vi.waitFor(() => expect(syncSpy).toHaveBeenCalled());
|
|
7552
|
+
await settle();
|
|
7553
|
+
shareSpy.mockClear();
|
|
7554
|
+
|
|
7555
|
+
fs.unlinkSync(absolutePath);
|
|
7556
|
+
const snapshots = capture!(key, "unlink");
|
|
7557
|
+
expect(snapshots).toHaveLength(1);
|
|
7558
|
+
expect(snapshots[0].deleteScopeRoot).toBe(key);
|
|
7559
|
+
expect(
|
|
7560
|
+
readJournal(PERSONAL_VAULT_JOURNAL_SLUG).files[key]
|
|
7561
|
+
?.localDeleteIntent,
|
|
7562
|
+
).toBeDefined();
|
|
7563
|
+
watcher.emit(key, {
|
|
7564
|
+
paths: new Map([[absolutePath, key]]),
|
|
7565
|
+
changes: new Map([
|
|
7566
|
+
[absolutePath, { kind: "unlink", deleteSnapshots: snapshots }],
|
|
7567
|
+
]),
|
|
7568
|
+
} as TreeChangeBatch);
|
|
7569
|
+
sleep.tick();
|
|
7570
|
+
await settle();
|
|
7571
|
+
|
|
7572
|
+
expect(remote.has(key)).toBe(false);
|
|
7573
|
+
expect(downloads).toBe(0);
|
|
7574
|
+
expect(fs.existsSync(absolutePath)).toBe(false);
|
|
7575
|
+
const scopedShareOptions = shareSpy.mock.calls.at(-1)?.[0];
|
|
7576
|
+
expect(scopedShareOptions?.paths).not.toContain(absolutePath);
|
|
7577
|
+
expect(scopedShareOptions?.deleteScopeRoots).toEqual([key]);
|
|
7578
|
+
|
|
7579
|
+
triggerShutdown();
|
|
7580
|
+
await loop;
|
|
7581
|
+
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
7582
|
+
});
|
|
7583
|
+
|
|
7584
|
+
it("rename uploads the new path, deletes the old path, and neither resurrects", async () => {
|
|
7585
|
+
const h = startDeleteHarness(["knowledge/old.md"]);
|
|
7586
|
+
await settle();
|
|
7587
|
+
const oldPath = path.join(h.companyRoot, "knowledge/old.md");
|
|
7588
|
+
const newPath = path.join(h.companyRoot, "knowledge/new.md");
|
|
7589
|
+
const snapshots = h.capture()(
|
|
7590
|
+
"companies/indigo/knowledge/old.md", "unlink",
|
|
7591
|
+
);
|
|
7592
|
+
fs.renameSync(oldPath, newPath);
|
|
7593
|
+
h.watcher.emit("companies/indigo/knowledge/old.md", {
|
|
7594
|
+
paths: new Map([
|
|
7595
|
+
[oldPath, "companies/indigo/knowledge/old.md"],
|
|
7596
|
+
[newPath, "companies/indigo/knowledge/new.md"],
|
|
7597
|
+
]),
|
|
7598
|
+
changes: new Map([
|
|
7599
|
+
[oldPath, { kind: "unlink", deleteSnapshots: snapshots }],
|
|
7600
|
+
[newPath, { kind: "add" }],
|
|
7601
|
+
]),
|
|
7602
|
+
} as TreeChangeBatch);
|
|
7603
|
+
h.tick();
|
|
7604
|
+
await settle();
|
|
7605
|
+
expect(h.remote.has("knowledge/old.md")).toBe(false);
|
|
7606
|
+
expect(h.remote.get("knowledge/new.md")).toBe("synced:knowledge/old.md");
|
|
7607
|
+
expect(fs.existsSync(oldPath)).toBe(false);
|
|
7608
|
+
expect(fs.existsSync(newPath)).toBe(true);
|
|
7609
|
+
await stopHarness(h);
|
|
7610
|
+
});
|
|
7611
|
+
|
|
7612
|
+
it("unlinkDir records every tracked descendant across a 12-file breaker-sized subtree", async () => {
|
|
7613
|
+
const keys = Array.from(
|
|
7614
|
+
{ length: 12 }, (_, i) => `knowledge/moved/f-${i}.md`,
|
|
7615
|
+
);
|
|
7616
|
+
const h = startDeleteHarness(keys);
|
|
7617
|
+
await settle();
|
|
7618
|
+
const subtree = path.join(h.companyRoot, "knowledge/moved");
|
|
7619
|
+
const snapshots = h.capture()(
|
|
7620
|
+
"companies/indigo/knowledge/moved", "unlinkDir",
|
|
7621
|
+
);
|
|
7622
|
+
expect(snapshots).toHaveLength(12);
|
|
7623
|
+
fs.rmSync(subtree, { recursive: true, force: true });
|
|
7624
|
+
h.watcher.emit("companies/indigo/knowledge/moved", {
|
|
7625
|
+
paths: new Map([[subtree, "companies/indigo/knowledge/moved"]]),
|
|
7626
|
+
changes: new Map([
|
|
7627
|
+
[subtree, { kind: "unlinkDir", deleteSnapshots: snapshots }],
|
|
7628
|
+
]),
|
|
7629
|
+
} as TreeChangeBatch);
|
|
7630
|
+
h.tick();
|
|
7631
|
+
await settle();
|
|
7632
|
+
expect(h.remote.size).toBe(0);
|
|
7633
|
+
expect(Object.keys(readJournal("indigo").files)).toHaveLength(0);
|
|
7634
|
+
await stopHarness(h);
|
|
7635
|
+
});
|
|
7636
|
+
|
|
7637
|
+
it("concurrent recreation before delete execution invalidates the observation and preserves remote", async () => {
|
|
7638
|
+
const h = startDeleteHarness(["knowledge/race.md"]);
|
|
7639
|
+
await settle();
|
|
7640
|
+
const absolutePath = path.join(h.companyRoot, "knowledge/race.md");
|
|
7641
|
+
fs.unlinkSync(absolutePath);
|
|
7642
|
+
const snapshots = h.capture()(
|
|
7643
|
+
"companies/indigo/knowledge/race.md", "unlink",
|
|
7644
|
+
);
|
|
7645
|
+
h.watcher.emit("companies/indigo/knowledge/race.md", {
|
|
7646
|
+
paths: new Map([[absolutePath, "companies/indigo/knowledge/race.md"]]),
|
|
7647
|
+
changes: new Map([
|
|
7648
|
+
[absolutePath, { kind: "unlink", deleteSnapshots: snapshots }],
|
|
7649
|
+
]),
|
|
7650
|
+
} as TreeChangeBatch);
|
|
7651
|
+
fs.writeFileSync(absolutePath, "concurrent recreation");
|
|
7652
|
+
h.tick();
|
|
7653
|
+
await settle();
|
|
7654
|
+
expect(h.remote.has("knowledge/race.md")).toBe(true);
|
|
7655
|
+
expect(readJournal("indigo").files["knowledge/race.md"]?.localDeleteIntent)
|
|
7656
|
+
.toBeUndefined();
|
|
7657
|
+
await stopHarness(h);
|
|
7658
|
+
});
|
|
7659
|
+
});
|