@indigoai-us/hq-cloud 6.14.9 → 6.14.11
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/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +89 -14
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +131 -3
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +138 -41
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +271 -25
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/journal.d.ts +7 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +22 -1
- package/dist/journal.js.map +1 -1
- package/dist/personal-vault-exclusions.d.ts +20 -4
- package/dist/personal-vault-exclusions.d.ts.map +1 -1
- package/dist/personal-vault-exclusions.js +37 -3
- package/dist/personal-vault-exclusions.js.map +1 -1
- package/dist/personal-vault-exclusions.test.js +19 -0
- package/dist/personal-vault-exclusions.test.js.map +1 -1
- package/dist/personal-vault.d.ts +6 -0
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +12 -0
- package/dist/personal-vault.js.map +1 -1
- package/dist/s3.d.ts +23 -1
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +74 -3
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +71 -1
- package/dist/s3.test.js.map +1 -1
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/share.test.ts +176 -3
- package/src/cli/share.ts +114 -15
- package/src/cli/sync.test.ts +330 -24
- package/src/cli/sync.ts +184 -50
- package/src/journal.ts +28 -0
- package/src/personal-vault-exclusions.test.ts +23 -0
- package/src/personal-vault-exclusions.ts +39 -3
- package/src/personal-vault.ts +15 -0
- package/src/s3.test.ts +91 -0
- package/src/s3.ts +88 -2
- package/src/types.ts +13 -0
package/src/cli/share.test.ts
CHANGED
|
@@ -71,6 +71,19 @@ function makeEntityContext(overrides: Partial<EntityContext> = {}): EntityContex
|
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
function deleteIntent(
|
|
75
|
+
remoteEtag: string,
|
|
76
|
+
localHash: string,
|
|
77
|
+
localKind: "file" | "symlink" = "file",
|
|
78
|
+
) {
|
|
79
|
+
return {
|
|
80
|
+
version: 1 as const,
|
|
81
|
+
remoteEtag,
|
|
82
|
+
localHash,
|
|
83
|
+
localKind,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
74
87
|
describe("wrapFilterWithIgnoreVisibility", () => {
|
|
75
88
|
it("records noteworthy base-ignore exclusions while counting all rejections", () => {
|
|
76
89
|
const hqRoot = path.join("/tmp", "hqroot");
|
|
@@ -1950,6 +1963,12 @@ describe("share", () => {
|
|
|
1950
1963
|
syncedAt: new Date().toISOString(),
|
|
1951
1964
|
direction: "up",
|
|
1952
1965
|
remoteEtag: "knowledge-etag",
|
|
1966
|
+
kind: "symlink",
|
|
1967
|
+
localDeleteIntent: deleteIntent(
|
|
1968
|
+
"knowledge-etag",
|
|
1969
|
+
"irrelevant-not-checked",
|
|
1970
|
+
"symlink",
|
|
1971
|
+
),
|
|
1953
1972
|
},
|
|
1954
1973
|
},
|
|
1955
1974
|
}),
|
|
@@ -2112,6 +2131,53 @@ describe("share", () => {
|
|
|
2112
2131
|
},
|
|
2113
2132
|
);
|
|
2114
2133
|
|
|
2134
|
+
it("personalMode excludes generated namespaced skill wrappers while preserving local skills", async () => {
|
|
2135
|
+
const generatedSource = path.join(
|
|
2136
|
+
tmpDir,
|
|
2137
|
+
"companies",
|
|
2138
|
+
"acme",
|
|
2139
|
+
"skills",
|
|
2140
|
+
"demo",
|
|
2141
|
+
"scripts",
|
|
2142
|
+
);
|
|
2143
|
+
const generatedWrapper = path.join(
|
|
2144
|
+
tmpDir,
|
|
2145
|
+
".claude",
|
|
2146
|
+
"skills",
|
|
2147
|
+
"acme:demo",
|
|
2148
|
+
);
|
|
2149
|
+
const localSkill = path.join(tmpDir, ".claude", "skills", "local-skill");
|
|
2150
|
+
fs.mkdirSync(generatedSource, { recursive: true });
|
|
2151
|
+
fs.writeFileSync(path.join(generatedSource, "run.sh"), "#!/bin/sh\n");
|
|
2152
|
+
fs.mkdirSync(generatedWrapper, { recursive: true });
|
|
2153
|
+
fs.symlinkSync(generatedSource, path.join(generatedWrapper, "scripts"), "dir");
|
|
2154
|
+
fs.mkdirSync(localSkill, { recursive: true });
|
|
2155
|
+
fs.writeFileSync(path.join(localSkill, "SKILL.md"), "# local\n");
|
|
2156
|
+
|
|
2157
|
+
const result = await share({
|
|
2158
|
+
paths: [path.join(tmpDir, ".claude")],
|
|
2159
|
+
entityContext: makeEntityContext({
|
|
2160
|
+
uid: "prs_01PERSONALTEST",
|
|
2161
|
+
slug: "__hq_personal_vault__",
|
|
2162
|
+
bucketName: "hq-vault-prs-01PERSONALTEST",
|
|
2163
|
+
}),
|
|
2164
|
+
hqRoot: tmpDir,
|
|
2165
|
+
personalMode: true,
|
|
2166
|
+
journalSlug: "personal-generated-wrapper",
|
|
2167
|
+
});
|
|
2168
|
+
|
|
2169
|
+
expect(result.filesUploaded).toBe(1);
|
|
2170
|
+
expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
|
|
2171
|
+
expect(uploadSymlink).not.toHaveBeenCalled();
|
|
2172
|
+
expect(uploadFile).toHaveBeenCalledWith(
|
|
2173
|
+
expect.anything(),
|
|
2174
|
+
path.join(localSkill, "SKILL.md"),
|
|
2175
|
+
".claude/skills/local-skill/SKILL.md",
|
|
2176
|
+
undefined,
|
|
2177
|
+
expect.anything(),
|
|
2178
|
+
);
|
|
2179
|
+
});
|
|
2180
|
+
|
|
2115
2181
|
it(
|
|
2116
2182
|
"personal-vault override does NOT apply to company vaults: `owned-only` on cmp_ entity still " +
|
|
2117
2183
|
"refuses to tombstone a direction:'down' entry (multi-user curation intact)",
|
|
@@ -2189,6 +2255,11 @@ describe("share", () => {
|
|
|
2189
2255
|
syncedAt: new Date().toISOString(),
|
|
2190
2256
|
direction: "up",
|
|
2191
2257
|
remoteEtag: "gone-etag",
|
|
2258
|
+
kind: "file",
|
|
2259
|
+
localDeleteIntent: deleteIntent(
|
|
2260
|
+
"gone-etag",
|
|
2261
|
+
"irrelevant-not-checked-here",
|
|
2262
|
+
),
|
|
2192
2263
|
},
|
|
2193
2264
|
},
|
|
2194
2265
|
}),
|
|
@@ -2231,6 +2302,9 @@ describe("share", () => {
|
|
|
2231
2302
|
size: 42,
|
|
2232
2303
|
syncedAt: new Date().toISOString(),
|
|
2233
2304
|
direction: "up",
|
|
2305
|
+
remoteEtag: "removed-etag",
|
|
2306
|
+
kind: "file",
|
|
2307
|
+
localDeleteIntent: deleteIntent("removed-etag", "h"),
|
|
2234
2308
|
},
|
|
2235
2309
|
},
|
|
2236
2310
|
}),
|
|
@@ -2318,6 +2392,9 @@ describe("share", () => {
|
|
|
2318
2392
|
size: 1,
|
|
2319
2393
|
syncedAt: new Date().toISOString(),
|
|
2320
2394
|
direction: "up",
|
|
2395
|
+
remoteEtag: "in-scope-etag",
|
|
2396
|
+
kind: "file",
|
|
2397
|
+
localDeleteIntent: deleteIntent("in-scope-etag", "h"),
|
|
2321
2398
|
},
|
|
2322
2399
|
"other/also-gone.md": {
|
|
2323
2400
|
hash: "h",
|
|
@@ -2371,6 +2448,9 @@ describe("share", () => {
|
|
|
2371
2448
|
size: 5,
|
|
2372
2449
|
syncedAt: new Date().toISOString(),
|
|
2373
2450
|
direction: "up",
|
|
2451
|
+
remoteEtag: "flaky-etag",
|
|
2452
|
+
kind: "file",
|
|
2453
|
+
localDeleteIntent: deleteIntent("flaky-etag", "h"),
|
|
2374
2454
|
},
|
|
2375
2455
|
},
|
|
2376
2456
|
}),
|
|
@@ -2446,6 +2526,9 @@ describe("share", () => {
|
|
|
2446
2526
|
"i-uploaded.md": {
|
|
2447
2527
|
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
2448
2528
|
direction: "up",
|
|
2529
|
+
remoteEtag: "uploaded-etag",
|
|
2530
|
+
kind: "file",
|
|
2531
|
+
localDeleteIntent: deleteIntent("uploaded-etag", "h"),
|
|
2449
2532
|
},
|
|
2450
2533
|
},
|
|
2451
2534
|
}),
|
|
@@ -2482,6 +2565,9 @@ describe("share", () => {
|
|
|
2482
2565
|
"pulled.md": {
|
|
2483
2566
|
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
2484
2567
|
direction: "down",
|
|
2568
|
+
remoteEtag: "pulled-etag",
|
|
2569
|
+
kind: "file",
|
|
2570
|
+
localDeleteIntent: deleteIntent("pulled-etag", "h"),
|
|
2485
2571
|
},
|
|
2486
2572
|
},
|
|
2487
2573
|
}),
|
|
@@ -2526,6 +2612,9 @@ describe("share", () => {
|
|
|
2526
2612
|
"active/current.md": {
|
|
2527
2613
|
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
2528
2614
|
direction: "up",
|
|
2615
|
+
remoteEtag: "active-etag",
|
|
2616
|
+
kind: "file",
|
|
2617
|
+
localDeleteIntent: deleteIntent("active-etag", "h"),
|
|
2529
2618
|
},
|
|
2530
2619
|
},
|
|
2531
2620
|
}),
|
|
@@ -2586,6 +2675,8 @@ describe("share", () => {
|
|
|
2586
2675
|
hash: "h", size: 100, syncedAt: new Date().toISOString(),
|
|
2587
2676
|
direction: "down",
|
|
2588
2677
|
remoteEtag: "abc123",
|
|
2678
|
+
kind: "file",
|
|
2679
|
+
localDeleteIntent: deleteIntent("abc123", "h"),
|
|
2589
2680
|
},
|
|
2590
2681
|
},
|
|
2591
2682
|
}),
|
|
@@ -2637,6 +2728,8 @@ describe("share", () => {
|
|
|
2637
2728
|
syncedAt: new Date().toISOString(),
|
|
2638
2729
|
direction: "down",
|
|
2639
2730
|
remoteEtag: "abc123",
|
|
2731
|
+
kind: "file",
|
|
2732
|
+
localDeleteIntent: deleteIntent("abc123", "h"),
|
|
2640
2733
|
removedAt: new Date().toISOString(),
|
|
2641
2734
|
removedReason: "local-delete",
|
|
2642
2735
|
},
|
|
@@ -2688,6 +2781,8 @@ describe("share", () => {
|
|
|
2688
2781
|
hash: "h", size: 50, syncedAt: new Date().toISOString(),
|
|
2689
2782
|
direction: "down",
|
|
2690
2783
|
remoteEtag: "stale-etag",
|
|
2784
|
+
kind: "file",
|
|
2785
|
+
localDeleteIntent: deleteIntent("stale-etag", "h"),
|
|
2691
2786
|
},
|
|
2692
2787
|
},
|
|
2693
2788
|
}),
|
|
@@ -2733,6 +2828,64 @@ describe("share", () => {
|
|
|
2733
2828
|
expect(result.filesTombstoned).toBe(0);
|
|
2734
2829
|
});
|
|
2735
2830
|
|
|
2831
|
+
it("currency-gated: preserves a file recreated after the delete plan", async () => {
|
|
2832
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2833
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2834
|
+
const recreatedPath = path.join(companyRoot, "racy.md");
|
|
2835
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2836
|
+
fs.writeFileSync(
|
|
2837
|
+
journalPath,
|
|
2838
|
+
JSON.stringify({
|
|
2839
|
+
version: "2",
|
|
2840
|
+
lastSync: new Date().toISOString(),
|
|
2841
|
+
files: {
|
|
2842
|
+
"racy.md": {
|
|
2843
|
+
hash: "synced-hash",
|
|
2844
|
+
size: 10,
|
|
2845
|
+
syncedAt: new Date().toISOString(),
|
|
2846
|
+
direction: "up",
|
|
2847
|
+
remoteEtag: "racy-etag",
|
|
2848
|
+
kind: "file",
|
|
2849
|
+
localDeleteIntent: deleteIntent("racy-etag", "synced-hash"),
|
|
2850
|
+
},
|
|
2851
|
+
},
|
|
2852
|
+
pulls: [],
|
|
2853
|
+
}),
|
|
2854
|
+
);
|
|
2855
|
+
|
|
2856
|
+
// The currency HEAD establishes the remote revision, then a local writer
|
|
2857
|
+
// recreates the file before the planned DeleteObject can run.
|
|
2858
|
+
vi.mocked(headRemoteFile).mockImplementationOnce(async () => {
|
|
2859
|
+
fs.writeFileSync(recreatedPath, "recreated after planning");
|
|
2860
|
+
return { lastModified: new Date(), etag: '"racy-etag"', size: 10 };
|
|
2861
|
+
});
|
|
2862
|
+
|
|
2863
|
+
const events: Array<{ type: string; path?: string; reason?: string }> = [];
|
|
2864
|
+
const result = await share({
|
|
2865
|
+
paths: [companyRoot],
|
|
2866
|
+
company: "acme",
|
|
2867
|
+
vaultConfig: mockConfig,
|
|
2868
|
+
hqRoot: tmpDir,
|
|
2869
|
+
skipUnchanged: true,
|
|
2870
|
+
propagateDeletes: true,
|
|
2871
|
+
propagateDeletePolicy: "currency-gated",
|
|
2872
|
+
onEvent: (e) => events.push(e as { type: string }),
|
|
2873
|
+
});
|
|
2874
|
+
|
|
2875
|
+
expect(result.filesDeleted).toBe(0);
|
|
2876
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
2877
|
+
expect(fs.readFileSync(recreatedPath, "utf-8")).toBe("recreated after planning");
|
|
2878
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2879
|
+
expect(journal.files["racy.md"]).toBeDefined();
|
|
2880
|
+
expect(events).toContainEqual(
|
|
2881
|
+
expect.objectContaining({
|
|
2882
|
+
type: "delete-refused-stale-etag",
|
|
2883
|
+
path: "racy.md",
|
|
2884
|
+
reason: "intent-changed",
|
|
2885
|
+
}),
|
|
2886
|
+
);
|
|
2887
|
+
});
|
|
2888
|
+
|
|
2736
2889
|
it("currency-gated: refuses delete for a divergent-local entry (journal-honesty — never delete a remote the local never genuinely matched)", async () => {
|
|
2737
2890
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2738
2891
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
@@ -2814,6 +2967,8 @@ describe("share", () => {
|
|
|
2814
2967
|
hash: "h", size: 25, syncedAt: new Date().toISOString(),
|
|
2815
2968
|
direction: "down",
|
|
2816
2969
|
remoteEtag: "doesnt-matter",
|
|
2970
|
+
kind: "file",
|
|
2971
|
+
localDeleteIntent: deleteIntent("doesnt-matter", "h"),
|
|
2817
2972
|
},
|
|
2818
2973
|
},
|
|
2819
2974
|
}),
|
|
@@ -2894,12 +3049,13 @@ describe("share", () => {
|
|
|
2894
3049
|
|
|
2895
3050
|
expect(result.filesDeleted).toBe(0);
|
|
2896
3051
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
2897
|
-
// HEAD must NOT be called —
|
|
3052
|
+
// HEAD must NOT be called — the explicit-intent gate rejects legacy
|
|
3053
|
+
// entries before any remote operation.
|
|
2898
3054
|
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
2899
3055
|
const refused = events.find((e) => e.type === "delete-refused-stale-etag");
|
|
2900
3056
|
expect(refused).toMatchObject({
|
|
2901
|
-
journalEtag: "<
|
|
2902
|
-
reason: "
|
|
3057
|
+
journalEtag: "<missing-delete-intent>",
|
|
3058
|
+
reason: "missing-delete-intent",
|
|
2903
3059
|
});
|
|
2904
3060
|
expect(result.filesRefusedStale).toBe(1);
|
|
2905
3061
|
});
|
|
@@ -2925,6 +3081,8 @@ describe("share", () => {
|
|
|
2925
3081
|
hash: "h", size: 200, syncedAt: new Date().toISOString(),
|
|
2926
3082
|
direction: "down",
|
|
2927
3083
|
remoteEtag: "upstream-etag",
|
|
3084
|
+
kind: "file",
|
|
3085
|
+
localDeleteIntent: deleteIntent("upstream-etag", "h"),
|
|
2928
3086
|
},
|
|
2929
3087
|
},
|
|
2930
3088
|
}),
|
|
@@ -3059,6 +3217,8 @@ describe("share", () => {
|
|
|
3059
3217
|
hash: "h", size: 5, syncedAt: new Date().toISOString(),
|
|
3060
3218
|
direction: "up",
|
|
3061
3219
|
remoteEtag: "regular-etag",
|
|
3220
|
+
kind: "file",
|
|
3221
|
+
localDeleteIntent: deleteIntent("regular-etag", "h"),
|
|
3062
3222
|
},
|
|
3063
3223
|
},
|
|
3064
3224
|
}),
|
|
@@ -3385,6 +3545,8 @@ describe("share", () => {
|
|
|
3385
3545
|
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
3386
3546
|
direction: "up",
|
|
3387
3547
|
remoteEtag: "abc",
|
|
3548
|
+
kind: "file",
|
|
3549
|
+
localDeleteIntent: deleteIntent("abc", "h"),
|
|
3388
3550
|
},
|
|
3389
3551
|
},
|
|
3390
3552
|
}),
|
|
@@ -3892,6 +4054,8 @@ describe("share", () => {
|
|
|
3892
4054
|
syncedAt: string;
|
|
3893
4055
|
direction: "up" | "down";
|
|
3894
4056
|
remoteEtag: string;
|
|
4057
|
+
kind: "file";
|
|
4058
|
+
localDeleteIntent: ReturnType<typeof deleteIntent>;
|
|
3895
4059
|
}
|
|
3896
4060
|
> = {};
|
|
3897
4061
|
for (let i = 0; i < total; i++) {
|
|
@@ -3902,6 +4066,8 @@ describe("share", () => {
|
|
|
3902
4066
|
syncedAt: new Date().toISOString(),
|
|
3903
4067
|
direction,
|
|
3904
4068
|
remoteEtag: `etag-${i}`,
|
|
4069
|
+
kind: "file",
|
|
4070
|
+
localDeleteIntent: deleteIntent(`etag-${i}`, "h"),
|
|
3905
4071
|
};
|
|
3906
4072
|
if (i >= missing) {
|
|
3907
4073
|
fs.writeFileSync(path.join(companyRoot, key), "hi");
|
|
@@ -4135,17 +4301,22 @@ describe("share", () => {
|
|
|
4135
4301
|
const files: Record<string, {
|
|
4136
4302
|
hash: string; size: number; syncedAt: string;
|
|
4137
4303
|
direction: "up" | "down"; remoteEtag: string;
|
|
4304
|
+
kind: "file"; localDeleteIntent: ReturnType<typeof deleteIntent>;
|
|
4138
4305
|
}> = {};
|
|
4139
4306
|
const now = new Date().toISOString();
|
|
4140
4307
|
for (let i = 0; i < 50; i++) {
|
|
4141
4308
|
files[`d-${i.toString().padStart(4, "0")}.md`] = {
|
|
4142
4309
|
hash: "h", size: 5, syncedAt: now,
|
|
4143
4310
|
direction: "down", remoteEtag: `down-etag-${i}`,
|
|
4311
|
+
kind: "file",
|
|
4312
|
+
localDeleteIntent: deleteIntent(`down-etag-${i}`, "h"),
|
|
4144
4313
|
};
|
|
4145
4314
|
}
|
|
4146
4315
|
files["only-up.md"] = {
|
|
4147
4316
|
hash: "h", size: 5, syncedAt: now,
|
|
4148
4317
|
direction: "up", remoteEtag: "up-etag",
|
|
4318
|
+
kind: "file",
|
|
4319
|
+
localDeleteIntent: deleteIntent("up-etag", "h"),
|
|
4149
4320
|
};
|
|
4150
4321
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
4151
4322
|
fs.writeFileSync(journalPath, JSON.stringify({ version: "1", lastSync: now, files }));
|
|
@@ -4293,6 +4464,8 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
4293
4464
|
syncedAt: new Date().toISOString(),
|
|
4294
4465
|
direction: "down",
|
|
4295
4466
|
remoteEtag: "v2-etag",
|
|
4467
|
+
kind: "file",
|
|
4468
|
+
localDeleteIntent: deleteIntent("v2-etag", "h"),
|
|
4296
4469
|
},
|
|
4297
4470
|
},
|
|
4298
4471
|
pulls: [],
|
package/src/cli/share.ts
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
wrapFilterWithPersonalVaultDefaults,
|
|
42
42
|
type PersonalVaultExclusion,
|
|
43
43
|
} from "../personal-vault-exclusions.js";
|
|
44
|
+
import { isPersonalVaultDeletionExcluded } from "../personal-vault.js";
|
|
44
45
|
import { resolveConflict } from "./conflict.js";
|
|
45
46
|
import type { ConflictStrategy } from "./conflict.js";
|
|
46
47
|
import type { SyncProgressEvent } from "./sync.js";
|
|
@@ -1101,7 +1102,7 @@ async function buildSharePlans(run: PushRunContext): Promise<SharePlans> {
|
|
|
1101
1102
|
run.journal,
|
|
1102
1103
|
run.options.decommissionPrefixes ?? [],
|
|
1103
1104
|
run.propagateDeletePolicy,
|
|
1104
|
-
new Set([...deletePlan.toDelete, ...deletePlan.toTombstone]),
|
|
1105
|
+
new Set([...deletePlan.toDelete.map((item) => item.key), ...deletePlan.toTombstone]),
|
|
1105
1106
|
)
|
|
1106
1107
|
: [];
|
|
1107
1108
|
|
|
@@ -1240,7 +1241,11 @@ async function executeUploads(
|
|
|
1240
1241
|
// second HQ root that shares this machine's sync journal) would otherwise read as
|
|
1241
1242
|
// `localChanged && !remoteChanged`, slip past the conflict-scoped guard below, and
|
|
1242
1243
|
// rewind the server's value — the `.last-run` watermark clobber. Skip before HEAD.
|
|
1243
|
-
|
|
1244
|
+
// board.json is the one push-side exception: local board appends are
|
|
1245
|
+
// legitimate, but must flow through the conditional PUT fence below. It
|
|
1246
|
+
// remains cloud-authoritative on pull so server automation still wins a
|
|
1247
|
+
// pull conflict.
|
|
1248
|
+
if (relativePath !== "board.json" && isCloudAuthoritative(relativePath)) {
|
|
1244
1249
|
run.emit({ type: "reconciled", path: relativePath, direction: "push" });
|
|
1245
1250
|
counters.filesSkipped++;
|
|
1246
1251
|
return;
|
|
@@ -1293,6 +1298,8 @@ async function executeUploads(
|
|
|
1293
1298
|
"up",
|
|
1294
1299
|
remoteMeta.etag,
|
|
1295
1300
|
lstat.mtimeMs,
|
|
1301
|
+
undefined,
|
|
1302
|
+
item.kind,
|
|
1296
1303
|
);
|
|
1297
1304
|
run.emit({ type: "reconciled", path: relativePath, direction: "push" });
|
|
1298
1305
|
counters.filesSkipped++;
|
|
@@ -1350,7 +1357,17 @@ async function executeUploads(
|
|
|
1350
1357
|
? await uploadSymlink(run.ctx, item.target, relativePath, run.options.author, pc)
|
|
1351
1358
|
: await uploadFile(run.ctx, absolutePath, relativePath, run.options.author, pc);
|
|
1352
1359
|
|
|
1353
|
-
updateEntry(
|
|
1360
|
+
updateEntry(
|
|
1361
|
+
run.journal,
|
|
1362
|
+
relativePath,
|
|
1363
|
+
localHash,
|
|
1364
|
+
size,
|
|
1365
|
+
"up",
|
|
1366
|
+
etag,
|
|
1367
|
+
mtimeMs,
|
|
1368
|
+
undefined,
|
|
1369
|
+
item.kind,
|
|
1370
|
+
);
|
|
1354
1371
|
if (run.message) {
|
|
1355
1372
|
run.journal.files[relativePath] = {
|
|
1356
1373
|
...run.journal.files[relativePath],
|
|
@@ -1501,14 +1518,39 @@ async function executeDeletes(
|
|
|
1501
1518
|
counters: ShareCounters,
|
|
1502
1519
|
filesRefusedStalePaths: string[],
|
|
1503
1520
|
): Promise<void> {
|
|
1504
|
-
const
|
|
1521
|
+
const deleteItems = [
|
|
1522
|
+
...deletePlan.toDelete.map((item) => ({ ...item, decommission: false })),
|
|
1523
|
+
...decommissionPlan.map((key) => ({ key, intentVersion: null, decommission: true })),
|
|
1524
|
+
];
|
|
1525
|
+
const deleteKeys = deleteItems.map((item) => item.key);
|
|
1505
1526
|
await primeObjectTransport(run.ctx, "delete", deleteKeys);
|
|
1506
|
-
for (const
|
|
1527
|
+
for (const item of deleteItems) {
|
|
1528
|
+
const { key: relativePath } = item;
|
|
1507
1529
|
if (run.vaultConfig && isExpiringSoon(run.ctx.expiresAt)) {
|
|
1508
1530
|
run.ctx = await refreshEntityContext(run.companyRef, run.vaultConfig);
|
|
1509
1531
|
}
|
|
1510
1532
|
try {
|
|
1511
1533
|
const entry = run.journal.files[relativePath];
|
|
1534
|
+
if (
|
|
1535
|
+
!item.decommission &&
|
|
1536
|
+
item.intentVersion !== null &&
|
|
1537
|
+
(!hasCurrentLocalDeleteIntent(entry) ||
|
|
1538
|
+
entry!.localDeleteIntent!.version !== item.intentVersion ||
|
|
1539
|
+
!isLocallyAbsent(path.join(run.syncRoot, relativePath)))
|
|
1540
|
+
) {
|
|
1541
|
+
counters.filesRefusedStale++;
|
|
1542
|
+
if (filesRefusedStalePaths.length < REFUSED_STALE_PATH_CAP) {
|
|
1543
|
+
filesRefusedStalePaths.push(relativePath);
|
|
1544
|
+
}
|
|
1545
|
+
run.emit({
|
|
1546
|
+
type: "delete-refused-stale-etag",
|
|
1547
|
+
path: relativePath,
|
|
1548
|
+
journalEtag: entry?.remoteEtag ?? "<missing-delete-intent>",
|
|
1549
|
+
remoteEtag: "<not-checked>",
|
|
1550
|
+
reason: "intent-changed",
|
|
1551
|
+
});
|
|
1552
|
+
continue;
|
|
1553
|
+
}
|
|
1512
1554
|
const size = entry?.size ?? 0;
|
|
1513
1555
|
await deleteRemoteFile(run.ctx, relativePath);
|
|
1514
1556
|
removeEntry(run.journal, relativePath);
|
|
@@ -2027,7 +2069,9 @@ type RefusedStaleReason =
|
|
|
2027
2069
|
| "stale-etag"
|
|
2028
2070
|
| "legacy-no-etag"
|
|
2029
2071
|
| "bulk-asymmetry"
|
|
2030
|
-
| "divergent-local"
|
|
2072
|
+
| "divergent-local"
|
|
2073
|
+
| "missing-delete-intent"
|
|
2074
|
+
| "intent-changed";
|
|
2031
2075
|
|
|
2032
2076
|
/**
|
|
2033
2077
|
* Bulk-asymmetry circuit-breaker — refuses to convert a suspiciously-large
|
|
@@ -2091,7 +2135,7 @@ function isBulkAsymmetryOverride(): boolean {
|
|
|
2091
2135
|
* uses. Emitted as `delete-refused-stale-etag` events.
|
|
2092
2136
|
*/
|
|
2093
2137
|
interface DeletePlan {
|
|
2094
|
-
toDelete: string
|
|
2138
|
+
toDelete: Array<{ key: string; intentVersion: 1 | null }>;
|
|
2095
2139
|
toTombstone: string[];
|
|
2096
2140
|
refusedStale: Array<{
|
|
2097
2141
|
key: string;
|
|
@@ -2113,6 +2157,34 @@ interface DeletePlan {
|
|
|
2113
2157
|
};
|
|
2114
2158
|
}
|
|
2115
2159
|
|
|
2160
|
+
function hasCurrentLocalDeleteIntent(
|
|
2161
|
+
entry: SyncJournal["files"][string] | undefined,
|
|
2162
|
+
): boolean {
|
|
2163
|
+
const intent = entry?.localDeleteIntent;
|
|
2164
|
+
return !!(
|
|
2165
|
+
intent &&
|
|
2166
|
+
intent.version === 1 &&
|
|
2167
|
+
entry.remoteEtag &&
|
|
2168
|
+
entry.kind &&
|
|
2169
|
+
intent.remoteEtag === entry.remoteEtag &&
|
|
2170
|
+
intent.localHash === entry.hash &&
|
|
2171
|
+
intent.localKind === entry.kind
|
|
2172
|
+
);
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
function isLocallyAbsent(localPath: string): boolean {
|
|
2176
|
+
try {
|
|
2177
|
+
fs.lstatSync(localPath);
|
|
2178
|
+
return false;
|
|
2179
|
+
} catch (err: unknown) {
|
|
2180
|
+
const code =
|
|
2181
|
+
err && typeof err === "object" && "code" in err
|
|
2182
|
+
? (err as { code?: string }).code
|
|
2183
|
+
: undefined;
|
|
2184
|
+
return code === "ENOENT";
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2116
2188
|
/**
|
|
2117
2189
|
* Concurrency cap for the per-file HEAD-O-meter (currency-gated). Sequential
|
|
2118
2190
|
* HEADs would add ~N×(50-200ms) to a sync — for the 261-mirror real-world
|
|
@@ -2216,7 +2288,7 @@ async function computeDeletePlan(
|
|
|
2216
2288
|
// all), or queue it for HEAD (currency-gated). Keeping this synchronous
|
|
2217
2289
|
// means the HEAD pass below sees a single, deduplicated candidate list
|
|
2218
2290
|
// and the journal-mutation buckets are already settled before any I/O.
|
|
2219
|
-
type HeadCandidate = { key: string; journalEtag: string };
|
|
2291
|
+
type HeadCandidate = { key: string; journalEtag: string; intentVersion: 1 };
|
|
2220
2292
|
const headCandidates: HeadCandidate[] = [];
|
|
2221
2293
|
// Litter drain bucket — kept separate from `plan.toDelete` so the
|
|
2222
2294
|
// bulk-asymmetry breaker (which moves toDelete + headCandidates into
|
|
@@ -2263,6 +2335,10 @@ async function computeDeletePlan(
|
|
|
2263
2335
|
}
|
|
2264
2336
|
if (presentLocally) continue;
|
|
2265
2337
|
|
|
2338
|
+
if (!companyScoped && isPersonalVaultDeletionExcluded(relativeKey)) {
|
|
2339
|
+
continue;
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2266
2342
|
// Vault-litter drain (6.0.2): conflict mirrors + rescue drift markers
|
|
2267
2343
|
// ALWAYS drain, bypassing `shouldSync` (which would skip them when the
|
|
2268
2344
|
// parent path is in personal-vault default exclusions like
|
|
@@ -2316,10 +2392,23 @@ async function computeDeletePlan(
|
|
|
2316
2392
|
continue;
|
|
2317
2393
|
}
|
|
2318
2394
|
|
|
2395
|
+
if (!hasCurrentLocalDeleteIntent(entry)) {
|
|
2396
|
+
plan.refusedStale.push({
|
|
2397
|
+
key: relativeKey,
|
|
2398
|
+
journalEtag: entry.remoteEtag ?? "<missing-delete-intent>",
|
|
2399
|
+
remoteEtag: "<not-checked>",
|
|
2400
|
+
reason: "missing-delete-intent",
|
|
2401
|
+
});
|
|
2402
|
+
continue;
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2319
2405
|
if (policy === "all") {
|
|
2320
2406
|
// policy:"all" is the explicit-opt-out emergency-reconcile mode; the
|
|
2321
2407
|
// bulk-asymmetry guard skips this branch (caller asserted intent).
|
|
2322
|
-
plan.toDelete.push(
|
|
2408
|
+
plan.toDelete.push({
|
|
2409
|
+
key: relativeKey,
|
|
2410
|
+
intentVersion: entry.localDeleteIntent!.version,
|
|
2411
|
+
});
|
|
2323
2412
|
continue;
|
|
2324
2413
|
}
|
|
2325
2414
|
bulkCandidatePicks++;
|
|
@@ -2332,7 +2421,10 @@ async function computeDeletePlan(
|
|
|
2332
2421
|
bulkCandidatePicks--;
|
|
2333
2422
|
continue;
|
|
2334
2423
|
}
|
|
2335
|
-
plan.toDelete.push(
|
|
2424
|
+
plan.toDelete.push({
|
|
2425
|
+
key: relativeKey,
|
|
2426
|
+
intentVersion: entry.localDeleteIntent!.version,
|
|
2427
|
+
});
|
|
2336
2428
|
continue;
|
|
2337
2429
|
}
|
|
2338
2430
|
// currency-gated: queue for HEAD unless the entry is legacy (no etag).
|
|
@@ -2346,7 +2438,11 @@ async function computeDeletePlan(
|
|
|
2346
2438
|
});
|
|
2347
2439
|
continue;
|
|
2348
2440
|
}
|
|
2349
|
-
headCandidates.push({
|
|
2441
|
+
headCandidates.push({
|
|
2442
|
+
key: relativeKey,
|
|
2443
|
+
journalEtag,
|
|
2444
|
+
intentVersion: entry.localDeleteIntent!.version,
|
|
2445
|
+
});
|
|
2350
2446
|
}
|
|
2351
2447
|
|
|
2352
2448
|
// Bulk-asymmetry circuit-breaker. See `BULK_ASYMMETRY_*` constants for
|
|
@@ -2375,7 +2471,7 @@ async function computeDeletePlan(
|
|
|
2375
2471
|
samplePaths.push(key);
|
|
2376
2472
|
}
|
|
2377
2473
|
};
|
|
2378
|
-
for (const
|
|
2474
|
+
for (const item of plan.toDelete) pushRefused(item.key);
|
|
2379
2475
|
for (const c of headCandidates) pushRefused(c.key);
|
|
2380
2476
|
plan.toDelete = [];
|
|
2381
2477
|
plan.bulkAsymmetry = {
|
|
@@ -2389,7 +2485,7 @@ async function computeDeletePlan(
|
|
|
2389
2485
|
// of user content; litter cleanup is orthogonal and should never be
|
|
2390
2486
|
// refused for the same reason new-litter producer-side exclusions
|
|
2391
2487
|
// shouldn't block it.
|
|
2392
|
-
plan.toDelete.push(...litterToDelete);
|
|
2488
|
+
plan.toDelete.push(...litterToDelete.map((key) => ({ key, intentVersion: null })));
|
|
2393
2489
|
return plan;
|
|
2394
2490
|
}
|
|
2395
2491
|
|
|
@@ -2436,7 +2532,10 @@ async function computeDeletePlan(
|
|
|
2436
2532
|
}
|
|
2437
2533
|
const currentEtag = normalizeEtag(remote.etag);
|
|
2438
2534
|
if (currentEtag === candidate.journalEtag) {
|
|
2439
|
-
plan.toDelete.push(
|
|
2535
|
+
plan.toDelete.push({
|
|
2536
|
+
key: candidate.key,
|
|
2537
|
+
intentVersion: candidate.intentVersion,
|
|
2538
|
+
});
|
|
2440
2539
|
} else {
|
|
2441
2540
|
plan.refusedStale.push({
|
|
2442
2541
|
key: candidate.key,
|
|
@@ -2453,7 +2552,7 @@ async function computeDeletePlan(
|
|
|
2453
2552
|
// delete loop tombstones each key identically (DeleteObject + remove from
|
|
2454
2553
|
// journal). Merged at the end so the bulk-asymmetry breaker above had its
|
|
2455
2554
|
// chance to NOT include litter in either the numerator or the sweep.
|
|
2456
|
-
plan.toDelete.push(...litterToDelete);
|
|
2555
|
+
plan.toDelete.push(...litterToDelete.map((key) => ({ key, intentVersion: null })));
|
|
2457
2556
|
|
|
2458
2557
|
return plan;
|
|
2459
2558
|
}
|