@indigoai-us/hq-cloud 6.14.17 → 6.14.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +35 -0
- package/dist/active-company.d.ts +43 -0
- package/dist/active-company.d.ts.map +1 -0
- package/dist/active-company.js +132 -0
- package/dist/active-company.js.map +1 -0
- package/dist/active-company.test.d.ts +2 -0
- package/dist/active-company.test.d.ts.map +1 -0
- package/dist/active-company.test.js +149 -0
- package/dist/active-company.test.js.map +1 -0
- package/dist/bin/sync-runner-company.d.ts +7 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +21 -1
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-planning.d.ts +27 -1
- package/dist/bin/sync-runner-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +33 -4
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner-planning.test.d.ts +2 -0
- package/dist/bin/sync-runner-planning.test.d.ts.map +1 -0
- package/dist/bin/sync-runner-planning.test.js +115 -0
- package/dist/bin/sync-runner-planning.test.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts +10 -2
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +201 -25
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-routes.js +14 -2
- package/dist/bin/sync-runner-watch-routes.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +39 -9
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +114 -20
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +852 -8
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/rescue-core.js +27 -1
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-drop-dir-symlink.test.d.ts +2 -0
- package/dist/cli/rescue-drop-dir-symlink.test.d.ts.map +1 -0
- package/dist/cli/rescue-drop-dir-symlink.test.js +206 -0
- package/dist/cli/rescue-drop-dir-symlink.test.js.map +1 -0
- package/dist/cli/share.d.ts +15 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +100 -55
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +119 -6
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +19 -25
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +121 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/journal.d.ts +3 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +13 -2
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +26 -1
- package/dist/journal.test.js.map +1 -1
- package/dist/manifest-reconcile.d.ts +118 -5
- package/dist/manifest-reconcile.d.ts.map +1 -1
- package/dist/manifest-reconcile.js +319 -62
- package/dist/manifest-reconcile.js.map +1 -1
- package/dist/manifest-reconcile.test.js +824 -2
- package/dist/manifest-reconcile.test.js.map +1 -1
- package/dist/personal-vault.d.ts +12 -0
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +20 -0
- package/dist/personal-vault.js.map +1 -1
- package/dist/qmd-reindex.d.ts +88 -11
- package/dist/qmd-reindex.d.ts.map +1 -1
- package/dist/qmd-reindex.js +354 -65
- package/dist/qmd-reindex.js.map +1 -1
- package/dist/qmd-reindex.test.d.ts +4 -0
- package/dist/qmd-reindex.test.d.ts.map +1 -1
- package/dist/qmd-reindex.test.js +285 -3
- package/dist/qmd-reindex.test.js.map +1 -1
- package/dist/s3.d.ts +13 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +34 -1
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +60 -1
- package/dist/s3.test.js.map +1 -1
- package/dist/telemetry.d.ts +22 -0
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +79 -0
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +117 -1
- package/dist/telemetry.test.js.map +1 -1
- package/dist/watcher.d.ts +26 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +75 -12
- package/dist/watcher.js.map +1 -1
- package/package.json +1 -1
- package/pnpm-workspace.yaml +1 -1
- package/src/active-company.test.ts +188 -0
- package/src/active-company.ts +168 -0
- package/src/bin/sync-runner-company.ts +29 -1
- package/src/bin/sync-runner-planning.test.ts +131 -0
- package/src/bin/sync-runner-planning.ts +60 -7
- package/src/bin/sync-runner-watch-loop.ts +322 -32
- package/src/bin/sync-runner-watch-routes.ts +14 -1
- package/src/bin/sync-runner.test.ts +1021 -16
- package/src/bin/sync-runner.ts +179 -28
- package/src/cli/rescue-core.ts +26 -1
- package/src/cli/rescue-drop-dir-symlink.test.ts +224 -0
- package/src/cli/share.test.ts +150 -6
- package/src/cli/share.ts +143 -48
- package/src/cli/sync.test.ts +150 -1
- package/src/cli/sync.ts +25 -29
- package/src/journal.test.ts +45 -0
- package/src/journal.ts +18 -1
- package/src/manifest-reconcile.test.ts +1019 -3
- package/src/manifest-reconcile.ts +424 -66
- package/src/personal-vault.ts +23 -0
- package/src/qmd-reindex.test.ts +324 -4
- package/src/qmd-reindex.ts +414 -76
- package/src/s3.test.ts +79 -0
- package/src/s3.ts +48 -1
- package/src/telemetry.test.ts +128 -0
- package/src/telemetry.ts +80 -0
- package/src/watcher.ts +111 -14
- package/test/e2e/watcher-real-chokidar.test.ts +62 -2
- package/test/e2e/watcher-recursive-backend.test.ts +67 -1
- package/test/joiner-manifest-reconcile.integration.test.ts +283 -0
package/src/cli/share.test.ts
CHANGED
|
@@ -2008,6 +2008,11 @@ describe("share", () => {
|
|
|
2008
2008
|
});
|
|
2009
2009
|
|
|
2010
2010
|
expect(result.filesDeleted).toBe(1);
|
|
2011
|
+
expect(result.pathResults).toContainEqual({
|
|
2012
|
+
path: "knowledge",
|
|
2013
|
+
status: "accepted",
|
|
2014
|
+
operation: "delete",
|
|
2015
|
+
});
|
|
2011
2016
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "knowledge");
|
|
2012
2017
|
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2013
2018
|
expect(journal.files["knowledge"]).toBeUndefined();
|
|
@@ -2198,6 +2203,44 @@ describe("share", () => {
|
|
|
2198
2203
|
);
|
|
2199
2204
|
});
|
|
2200
2205
|
|
|
2206
|
+
it("personalMode excludes generated core mirror symlinks but keeps canonical core files", async () => {
|
|
2207
|
+
const packagePolicy = path.join(
|
|
2208
|
+
tmpDir,
|
|
2209
|
+
"core",
|
|
2210
|
+
"packages",
|
|
2211
|
+
"hq-pack-example",
|
|
2212
|
+
"policies",
|
|
2213
|
+
"generated.md",
|
|
2214
|
+
);
|
|
2215
|
+
const generatedMirror = path.join(tmpDir, "core", "policies", "generated.md");
|
|
2216
|
+
const canonicalCoreFile = path.join(tmpDir, "core", "policies", "shipped.md");
|
|
2217
|
+
fs.mkdirSync(path.dirname(packagePolicy), { recursive: true });
|
|
2218
|
+
fs.mkdirSync(path.dirname(generatedMirror), { recursive: true });
|
|
2219
|
+
fs.writeFileSync(packagePolicy, "generated package policy\n");
|
|
2220
|
+
fs.symlinkSync(packagePolicy, generatedMirror);
|
|
2221
|
+
fs.writeFileSync(canonicalCoreFile, "shipped core policy\n");
|
|
2222
|
+
|
|
2223
|
+
const result = await share({
|
|
2224
|
+
paths: [path.join(tmpDir, "core")],
|
|
2225
|
+
entityContext: makeEntityContext({
|
|
2226
|
+
uid: "prs_01PERSONALTEST",
|
|
2227
|
+
slug: "__hq_personal_vault__",
|
|
2228
|
+
bucketName: "hq-vault-prs-01PERSONALTEST",
|
|
2229
|
+
}),
|
|
2230
|
+
hqRoot: tmpDir,
|
|
2231
|
+
personalMode: true,
|
|
2232
|
+
journalSlug: "personal-generated-core-mirror",
|
|
2233
|
+
});
|
|
2234
|
+
|
|
2235
|
+
expect(result.filesExcludedByPolicy).toBe(1);
|
|
2236
|
+
expect(result.filesUploaded).toBe(2);
|
|
2237
|
+
expect(vi.mocked(uploadSymlink)).not.toHaveBeenCalled();
|
|
2238
|
+
expect(vi.mocked(uploadFile).mock.calls.map((call) => call[2]).sort()).toEqual([
|
|
2239
|
+
"core/packages/hq-pack-example/policies/generated.md",
|
|
2240
|
+
"core/policies/shipped.md",
|
|
2241
|
+
]);
|
|
2242
|
+
});
|
|
2243
|
+
|
|
2201
2244
|
it(
|
|
2202
2245
|
"personal-vault override does NOT apply to company vaults: `owned-only` on cmp_ entity still " +
|
|
2203
2246
|
"refuses to tombstone a direction:'down' entry (multi-user curation intact)",
|
|
@@ -3033,7 +3076,7 @@ describe("share", () => {
|
|
|
3033
3076
|
});
|
|
3034
3077
|
});
|
|
3035
3078
|
|
|
3036
|
-
it("
|
|
3079
|
+
it("daemon-stopped deletion has no watcher intent and is safely refused", async () => {
|
|
3037
3080
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3038
3081
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3039
3082
|
// Journal entry from before remoteEtag tracking — no etag to compare
|
|
@@ -3078,6 +3121,12 @@ describe("share", () => {
|
|
|
3078
3121
|
reason: "missing-delete-intent",
|
|
3079
3122
|
});
|
|
3080
3123
|
expect(result.filesRefusedStale).toBe(1);
|
|
3124
|
+
expect(result.pathResults).toContainEqual({
|
|
3125
|
+
path: "legacy-no-etag.md",
|
|
3126
|
+
status: "refused",
|
|
3127
|
+
operation: "delete",
|
|
3128
|
+
reason: "missing-delete-intent",
|
|
3129
|
+
});
|
|
3081
3130
|
});
|
|
3082
3131
|
|
|
3083
3132
|
it("currency-gated: real-world /update-hq scenario — direction:'down' delete propagates when current", async () => {
|
|
@@ -3351,6 +3400,58 @@ describe("share", () => {
|
|
|
3351
3400
|
// passing in the allowed top-level directories — share() trusts its
|
|
3352
3401
|
// `paths` input.
|
|
3353
3402
|
|
|
3403
|
+
it("plans an intent-backed personal delete from an explicit missing scope root", async () => {
|
|
3404
|
+
const key = "deleted-top-level.md";
|
|
3405
|
+
const journalSlug = "personal-scoped-delete";
|
|
3406
|
+
const journalPath = path.join(
|
|
3407
|
+
stateDir,
|
|
3408
|
+
`sync-journal.${journalSlug}.json`,
|
|
3409
|
+
);
|
|
3410
|
+
fs.writeFileSync(
|
|
3411
|
+
journalPath,
|
|
3412
|
+
JSON.stringify({
|
|
3413
|
+
version: "2",
|
|
3414
|
+
lastSync: new Date().toISOString(),
|
|
3415
|
+
pulls: [],
|
|
3416
|
+
files: {
|
|
3417
|
+
[key]: {
|
|
3418
|
+
hash: "captured-hash",
|
|
3419
|
+
size: 8,
|
|
3420
|
+
syncedAt: new Date().toISOString(),
|
|
3421
|
+
direction: "up",
|
|
3422
|
+
remoteEtag: "captured-etag",
|
|
3423
|
+
kind: "file",
|
|
3424
|
+
localDeleteIntent: deleteIntent(
|
|
3425
|
+
"captured-etag",
|
|
3426
|
+
"captured-hash",
|
|
3427
|
+
),
|
|
3428
|
+
},
|
|
3429
|
+
},
|
|
3430
|
+
}),
|
|
3431
|
+
);
|
|
3432
|
+
|
|
3433
|
+
const result = await share({
|
|
3434
|
+
// computePersonalVaultPaths() has no path to contribute after this
|
|
3435
|
+
// top-level file is gone. The watcher-captured root is the sole scope.
|
|
3436
|
+
paths: [],
|
|
3437
|
+
deleteScopeRoots: [key],
|
|
3438
|
+
company: "acme",
|
|
3439
|
+
vaultConfig: mockConfig,
|
|
3440
|
+
hqRoot: tmpDir,
|
|
3441
|
+
personalMode: true,
|
|
3442
|
+
journalSlug,
|
|
3443
|
+
skipUnchanged: true,
|
|
3444
|
+
propagateDeletes: true,
|
|
3445
|
+
propagateDeletePolicy: "owned-only",
|
|
3446
|
+
});
|
|
3447
|
+
|
|
3448
|
+
expect(result.filesDeleted).toBe(1);
|
|
3449
|
+
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), key);
|
|
3450
|
+
expect(
|
|
3451
|
+
JSON.parse(fs.readFileSync(journalPath, "utf8")).files[key],
|
|
3452
|
+
).toBeUndefined();
|
|
3453
|
+
});
|
|
3454
|
+
|
|
3354
3455
|
// ── Decommission prefixes ──────────────────────────────────────────────────
|
|
3355
3456
|
//
|
|
3356
3457
|
// `decommissionPrefixes` is the "company was promoted to its own team
|
|
@@ -4059,10 +4160,12 @@ describe("share", () => {
|
|
|
4059
4160
|
total,
|
|
4060
4161
|
missing,
|
|
4061
4162
|
direction = "up" as "up" | "down",
|
|
4163
|
+
withIntents = true,
|
|
4062
4164
|
}: {
|
|
4063
4165
|
total: number;
|
|
4064
4166
|
missing: number;
|
|
4065
4167
|
direction?: "up" | "down";
|
|
4168
|
+
withIntents?: boolean;
|
|
4066
4169
|
}): { companyRoot: string; journalPath: string } {
|
|
4067
4170
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4068
4171
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
@@ -4075,7 +4178,7 @@ describe("share", () => {
|
|
|
4075
4178
|
direction: "up" | "down";
|
|
4076
4179
|
remoteEtag: string;
|
|
4077
4180
|
kind: "file";
|
|
4078
|
-
localDeleteIntent
|
|
4181
|
+
localDeleteIntent?: ReturnType<typeof deleteIntent>;
|
|
4079
4182
|
}
|
|
4080
4183
|
> = {};
|
|
4081
4184
|
for (let i = 0; i < total; i++) {
|
|
@@ -4087,7 +4190,9 @@ describe("share", () => {
|
|
|
4087
4190
|
direction,
|
|
4088
4191
|
remoteEtag: `etag-${i}`,
|
|
4089
4192
|
kind: "file",
|
|
4090
|
-
|
|
4193
|
+
...(withIntents
|
|
4194
|
+
? { localDeleteIntent: deleteIntent(`etag-${i}`, "h") }
|
|
4195
|
+
: {}),
|
|
4091
4196
|
};
|
|
4092
4197
|
if (i >= missing) {
|
|
4093
4198
|
fs.writeFileSync(path.join(companyRoot, key), "hi");
|
|
@@ -4110,7 +4215,11 @@ describe("share", () => {
|
|
|
4110
4215
|
});
|
|
4111
4216
|
|
|
4112
4217
|
it("trips at the 11/100 boundary (11% > 10%, abs >= 10): refuses every delete and emits bulk-asymmetry event", async () => {
|
|
4113
|
-
const { companyRoot, journalPath } = setupJournal({
|
|
4218
|
+
const { companyRoot, journalPath } = setupJournal({
|
|
4219
|
+
total: 100,
|
|
4220
|
+
missing: 11,
|
|
4221
|
+
withIntents: false,
|
|
4222
|
+
});
|
|
4114
4223
|
// currency-gated: remote HEAD would match every journal etag — guard
|
|
4115
4224
|
// must trip BEFORE any HEAD round-trip occurs.
|
|
4116
4225
|
vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => ({
|
|
@@ -4166,6 +4275,33 @@ describe("share", () => {
|
|
|
4166
4275
|
expect(perKeyRefusals.length).toBe(11);
|
|
4167
4276
|
});
|
|
4168
4277
|
|
|
4278
|
+
it("intent-backed 11/100 subtree disappearance bypasses the breaker and deletes every covered path", async () => {
|
|
4279
|
+
const { companyRoot } = setupJournal({ total: 100, missing: 11 });
|
|
4280
|
+
vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => ({
|
|
4281
|
+
etag: `etag-${parseInt(key.replace(/[^0-9]/g, ""), 10)}`,
|
|
4282
|
+
lastModified: new Date(),
|
|
4283
|
+
size: 5,
|
|
4284
|
+
}));
|
|
4285
|
+
const events: Array<{ type: string }> = [];
|
|
4286
|
+
|
|
4287
|
+
const result = await share({
|
|
4288
|
+
paths: [companyRoot],
|
|
4289
|
+
company: "acme",
|
|
4290
|
+
vaultConfig: mockConfig,
|
|
4291
|
+
hqRoot: tmpDir,
|
|
4292
|
+
skipUnchanged: true,
|
|
4293
|
+
propagateDeletes: true,
|
|
4294
|
+
propagateDeletePolicy: "currency-gated",
|
|
4295
|
+
onEvent: (e) => events.push(e as { type: string }),
|
|
4296
|
+
});
|
|
4297
|
+
|
|
4298
|
+
expect(result.filesDeleted).toBe(11);
|
|
4299
|
+
expect(deleteRemoteFile).toHaveBeenCalledTimes(11);
|
|
4300
|
+
expect(
|
|
4301
|
+
events.find((e) => e.type === "delete-refused-bulk-asymmetry"),
|
|
4302
|
+
).toBeUndefined();
|
|
4303
|
+
});
|
|
4304
|
+
|
|
4169
4305
|
it("does NOT trip at 9/100 (below ratio): all candidates delete normally", async () => {
|
|
4170
4306
|
const { companyRoot } = setupJournal({ total: 100, missing: 9 });
|
|
4171
4307
|
vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => ({
|
|
@@ -4232,7 +4368,11 @@ describe("share", () => {
|
|
|
4232
4368
|
});
|
|
4233
4369
|
|
|
4234
4370
|
it("fully-empty mirror (100/100) trips the guard — the canonical mass-delete failure mode", async () => {
|
|
4235
|
-
const { companyRoot, journalPath } = setupJournal({
|
|
4371
|
+
const { companyRoot, journalPath } = setupJournal({
|
|
4372
|
+
total: 50,
|
|
4373
|
+
missing: 50,
|
|
4374
|
+
withIntents: false,
|
|
4375
|
+
});
|
|
4236
4376
|
vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => ({
|
|
4237
4377
|
etag: `etag-${parseInt(key.replace(/[^0-9]/g, ""), 10)}`,
|
|
4238
4378
|
lastModified: new Date(),
|
|
@@ -4288,7 +4428,11 @@ describe("share", () => {
|
|
|
4288
4428
|
});
|
|
4289
4429
|
|
|
4290
4430
|
it("owned-only policy also goes through the guard: 11/100 missing direction:'up' entries refused", async () => {
|
|
4291
|
-
const { companyRoot, journalPath } = setupJournal({
|
|
4431
|
+
const { companyRoot, journalPath } = setupJournal({
|
|
4432
|
+
total: 100,
|
|
4433
|
+
missing: 11,
|
|
4434
|
+
withIntents: false,
|
|
4435
|
+
});
|
|
4292
4436
|
|
|
4293
4437
|
const events: Array<{ type: string }> = [];
|
|
4294
4438
|
const result = await share({
|
package/src/cli/share.ts
CHANGED
|
@@ -41,7 +41,10 @@ import {
|
|
|
41
41
|
wrapFilterWithPersonalVaultDefaults,
|
|
42
42
|
type PersonalVaultExclusion,
|
|
43
43
|
} from "../personal-vault-exclusions.js";
|
|
44
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
isGeneratedCoreMirrorKey,
|
|
46
|
+
isPersonalVaultDeletionExcluded,
|
|
47
|
+
} from "../personal-vault.js";
|
|
45
48
|
import { resolveConflict } from "./conflict.js";
|
|
46
49
|
import type { ConflictStrategy } from "./conflict.js";
|
|
47
50
|
import type { SyncProgressEvent } from "./sync.js";
|
|
@@ -590,6 +593,12 @@ export interface ShareOptions {
|
|
|
590
593
|
* next push to erase it.
|
|
591
594
|
*/
|
|
592
595
|
propagateDeletePolicy?: "currency-gated" | "owned-only" | "all";
|
|
596
|
+
/**
|
|
597
|
+
* Explicit vault-relative delete roots captured by a live watcher before a
|
|
598
|
+
* scoped push. These roots do not need to exist locally; delete execution
|
|
599
|
+
* still requires a current version-bound intent (or server tombstone).
|
|
600
|
+
*/
|
|
601
|
+
deleteScopeRoots?: string[];
|
|
593
602
|
/**
|
|
594
603
|
* Hq-root-relative key prefixes whose journal entries should be
|
|
595
604
|
* unconditionally decommissioned from the remote bucket and journal,
|
|
@@ -753,9 +762,27 @@ export interface ShareResult {
|
|
|
753
762
|
* way to runner/UI consumers.
|
|
754
763
|
*/
|
|
755
764
|
conflictPaths: string[];
|
|
765
|
+
/** Uncapped per-path delete outcomes used by scoped watcher publication. */
|
|
766
|
+
pathResults?: SharePathResult[];
|
|
756
767
|
aborted: boolean;
|
|
757
768
|
}
|
|
758
769
|
|
|
770
|
+
export type ShareDeleteRefusalReason =
|
|
771
|
+
| "stale-etag"
|
|
772
|
+
| "legacy-no-etag"
|
|
773
|
+
| "bulk-asymmetry"
|
|
774
|
+
| "divergent-local"
|
|
775
|
+
| "missing-delete-intent"
|
|
776
|
+
| "intent-changed"
|
|
777
|
+
| "transfer-error";
|
|
778
|
+
|
|
779
|
+
export interface SharePathResult {
|
|
780
|
+
path: string;
|
|
781
|
+
status: "accepted" | "refused";
|
|
782
|
+
operation: "delete" | "tombstone";
|
|
783
|
+
reason?: ShareDeleteRefusalReason;
|
|
784
|
+
}
|
|
785
|
+
|
|
759
786
|
/**
|
|
760
787
|
* A conditional-write fence rejection — the SDK's 412 (`name:
|
|
761
788
|
* "PreconditionFailed"`) or the presigned transport's mirror of it. Means
|
|
@@ -838,6 +865,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
838
865
|
const counters = createShareCounters();
|
|
839
866
|
const filesRefusedStalePaths: string[] = [];
|
|
840
867
|
const conflictPaths: string[] = [];
|
|
868
|
+
const pathResults: SharePathResult[] = [];
|
|
841
869
|
|
|
842
870
|
const plans = await buildSharePlans(run);
|
|
843
871
|
const uploadResult = await executeUploads(
|
|
@@ -852,6 +880,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
852
880
|
counters,
|
|
853
881
|
filesRefusedStalePaths,
|
|
854
882
|
uploadResult.abortFlightConflictPaths,
|
|
883
|
+
pathResults,
|
|
855
884
|
true,
|
|
856
885
|
);
|
|
857
886
|
}
|
|
@@ -862,11 +891,19 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
862
891
|
plans.decommissionPlan,
|
|
863
892
|
counters,
|
|
864
893
|
filesRefusedStalePaths,
|
|
894
|
+
pathResults,
|
|
865
895
|
);
|
|
866
896
|
finalizeShareJournal(run);
|
|
867
897
|
throwUploadWorkerErrors(uploadResult.workerErrors);
|
|
868
898
|
|
|
869
|
-
return buildShareResult(
|
|
899
|
+
return buildShareResult(
|
|
900
|
+
run,
|
|
901
|
+
counters,
|
|
902
|
+
filesRefusedStalePaths,
|
|
903
|
+
conflictPaths,
|
|
904
|
+
pathResults,
|
|
905
|
+
false,
|
|
906
|
+
);
|
|
870
907
|
}
|
|
871
908
|
|
|
872
909
|
type DeletePolicy = NonNullable<ShareOptions["propagateDeletePolicy"]>;
|
|
@@ -1070,7 +1107,27 @@ async function buildSharePlans(run: PushRunContext): Promise<SharePlans> {
|
|
|
1070
1107
|
// `companies/{slug}/…` keys, so this only applies in company mode.
|
|
1071
1108
|
const filesToShare =
|
|
1072
1109
|
run.options.personalMode === true
|
|
1073
|
-
? collected
|
|
1110
|
+
? collected.filter((entry) => {
|
|
1111
|
+
// `core/<type>` symlinks are generated projections of canonical
|
|
1112
|
+
// personal/package content. Reindex or package wiring owns their
|
|
1113
|
+
// lifecycle, so uploading them creates remote objects that a later
|
|
1114
|
+
// pull can only materialize temporarily before reindex removes them.
|
|
1115
|
+
// Keep ordinary core files eligible: only a symlink is derived.
|
|
1116
|
+
const generatedCoreMirror =
|
|
1117
|
+
entry.kind === "symlink" && isGeneratedCoreMirrorKey(entry.relativePath)
|
|
1118
|
+
if (!generatedCoreMirror) return true;
|
|
1119
|
+
|
|
1120
|
+
// This is a type-aware companion to the path-only personal-vault
|
|
1121
|
+
// default filters. `collectFiles` is the first seam that knows the
|
|
1122
|
+
// entry is a symlink, so record it here for the same summary/result
|
|
1123
|
+
// accounting used by the static exclusions.
|
|
1124
|
+
if (!run.excludedSet.has(entry.relativePath)) {
|
|
1125
|
+
run.excludedSet.add(entry.relativePath);
|
|
1126
|
+
run.excludedById["generated-core-mirror"] =
|
|
1127
|
+
(run.excludedById["generated-core-mirror"] ?? 0) + 1;
|
|
1128
|
+
}
|
|
1129
|
+
return false;
|
|
1130
|
+
})
|
|
1074
1131
|
: collected.filter((entry) => {
|
|
1075
1132
|
if (!entry.relativePath.startsWith("companies/")) return true;
|
|
1076
1133
|
run.emit({
|
|
@@ -1085,7 +1142,12 @@ async function buildSharePlans(run: PushRunContext): Promise<SharePlans> {
|
|
|
1085
1142
|
run.skipUnchanged === true,
|
|
1086
1143
|
);
|
|
1087
1144
|
const deleteScopeRoots = run.propagateDeletes === true
|
|
1088
|
-
? resolveDeleteScopeRoots(
|
|
1145
|
+
? resolveDeleteScopeRoots(
|
|
1146
|
+
run.paths,
|
|
1147
|
+
run.hqRoot,
|
|
1148
|
+
run.syncRoot,
|
|
1149
|
+
run.options.deleteScopeRoots,
|
|
1150
|
+
)
|
|
1089
1151
|
: [];
|
|
1090
1152
|
const deletePlan: DeletePlan = run.propagateDeletes === true
|
|
1091
1153
|
? await computeDeletePlan(
|
|
@@ -1522,6 +1584,7 @@ async function executeDeletes(
|
|
|
1522
1584
|
decommissionPlan: string[],
|
|
1523
1585
|
counters: ShareCounters,
|
|
1524
1586
|
filesRefusedStalePaths: string[],
|
|
1587
|
+
pathResults: SharePathResult[],
|
|
1525
1588
|
): Promise<void> {
|
|
1526
1589
|
const deleteItems = [
|
|
1527
1590
|
...deletePlan.toDelete.map((item) => ({ ...item, decommission: false })),
|
|
@@ -1543,6 +1606,9 @@ async function executeDeletes(
|
|
|
1543
1606
|
entry!.localDeleteIntent!.version !== item.intentVersion ||
|
|
1544
1607
|
!isLocallyAbsent(path.join(run.syncRoot, relativePath)))
|
|
1545
1608
|
) {
|
|
1609
|
+
if (entry?.localDeleteIntent) {
|
|
1610
|
+
delete entry.localDeleteIntent;
|
|
1611
|
+
}
|
|
1546
1612
|
counters.filesRefusedStale++;
|
|
1547
1613
|
if (filesRefusedStalePaths.length < REFUSED_STALE_PATH_CAP) {
|
|
1548
1614
|
filesRefusedStalePaths.push(relativePath);
|
|
@@ -1554,6 +1620,12 @@ async function executeDeletes(
|
|
|
1554
1620
|
remoteEtag: "<not-checked>",
|
|
1555
1621
|
reason: "intent-changed",
|
|
1556
1622
|
});
|
|
1623
|
+
pathResults.push({
|
|
1624
|
+
path: relativePath,
|
|
1625
|
+
status: "refused",
|
|
1626
|
+
operation: "delete",
|
|
1627
|
+
reason: "intent-changed",
|
|
1628
|
+
});
|
|
1557
1629
|
continue;
|
|
1558
1630
|
}
|
|
1559
1631
|
const size = entry?.size ?? 0;
|
|
@@ -1566,6 +1638,11 @@ async function executeDeletes(
|
|
|
1566
1638
|
bytes: size,
|
|
1567
1639
|
deleted: true,
|
|
1568
1640
|
});
|
|
1641
|
+
pathResults.push({
|
|
1642
|
+
path: relativePath,
|
|
1643
|
+
status: "accepted",
|
|
1644
|
+
operation: "delete",
|
|
1645
|
+
});
|
|
1569
1646
|
} catch (err) {
|
|
1570
1647
|
if (err instanceof VaultAuthError) throw err;
|
|
1571
1648
|
run.emit({
|
|
@@ -1573,6 +1650,12 @@ async function executeDeletes(
|
|
|
1573
1650
|
path: relativePath,
|
|
1574
1651
|
message: err instanceof Error ? err.message : String(err),
|
|
1575
1652
|
});
|
|
1653
|
+
pathResults.push({
|
|
1654
|
+
path: relativePath,
|
|
1655
|
+
status: "refused",
|
|
1656
|
+
operation: "delete",
|
|
1657
|
+
reason: "transfer-error",
|
|
1658
|
+
});
|
|
1576
1659
|
}
|
|
1577
1660
|
}
|
|
1578
1661
|
for (const relativePath of deletePlan.toTombstone) {
|
|
@@ -1585,6 +1668,11 @@ async function executeDeletes(
|
|
|
1585
1668
|
deleted: true,
|
|
1586
1669
|
message: "tombstone (remote already 404)",
|
|
1587
1670
|
});
|
|
1671
|
+
pathResults.push({
|
|
1672
|
+
path: relativePath,
|
|
1673
|
+
status: "accepted",
|
|
1674
|
+
operation: "tombstone",
|
|
1675
|
+
});
|
|
1588
1676
|
}
|
|
1589
1677
|
const decommissionedSet =
|
|
1590
1678
|
decommissionPlan.length > 0 ? new Set(decommissionPlan) : null;
|
|
@@ -1601,6 +1689,12 @@ async function executeDeletes(
|
|
|
1601
1689
|
remoteEtag: refused.remoteEtag,
|
|
1602
1690
|
reason: refused.reason,
|
|
1603
1691
|
});
|
|
1692
|
+
pathResults.push({
|
|
1693
|
+
path: refused.key,
|
|
1694
|
+
status: "refused",
|
|
1695
|
+
operation: "delete",
|
|
1696
|
+
reason: refused.reason,
|
|
1697
|
+
});
|
|
1604
1698
|
}
|
|
1605
1699
|
}
|
|
1606
1700
|
|
|
@@ -1669,6 +1763,7 @@ function buildShareResult(
|
|
|
1669
1763
|
counters: ShareCounters,
|
|
1670
1764
|
filesRefusedStalePaths: string[],
|
|
1671
1765
|
conflictPaths: string[],
|
|
1766
|
+
pathResults: SharePathResult[],
|
|
1672
1767
|
aborted: boolean,
|
|
1673
1768
|
): ShareResult {
|
|
1674
1769
|
return {
|
|
@@ -1684,6 +1779,7 @@ function buildShareResult(
|
|
|
1684
1779
|
filesExcludedByScope: run.scopeExcludedSet.size,
|
|
1685
1780
|
filesExcludedByIgnore: run.ignoreExcludedSet.size,
|
|
1686
1781
|
conflictPaths,
|
|
1782
|
+
pathResults,
|
|
1687
1783
|
aborted,
|
|
1688
1784
|
};
|
|
1689
1785
|
}
|
|
@@ -2043,8 +2139,26 @@ function resolveDeleteScopeRoots(
|
|
|
2043
2139
|
paths: string[],
|
|
2044
2140
|
hqRoot: string,
|
|
2045
2141
|
syncRoot: string,
|
|
2142
|
+
explicitRoots: readonly string[] = [],
|
|
2046
2143
|
): string[] {
|
|
2047
2144
|
const prefixes = new Set<string>();
|
|
2145
|
+
for (const root of explicitRoots) {
|
|
2146
|
+
const normalized = root
|
|
2147
|
+
.split("\\")
|
|
2148
|
+
.join("/")
|
|
2149
|
+
.replace(/^\.\/+/, "")
|
|
2150
|
+
.replace(/\/+$/, "");
|
|
2151
|
+
if (
|
|
2152
|
+
normalized === "" ||
|
|
2153
|
+
normalized === "." ||
|
|
2154
|
+
path.posix.isAbsolute(normalized) ||
|
|
2155
|
+
normalized === ".." ||
|
|
2156
|
+
normalized.startsWith("../")
|
|
2157
|
+
) {
|
|
2158
|
+
continue;
|
|
2159
|
+
}
|
|
2160
|
+
prefixes.add(normalized);
|
|
2161
|
+
}
|
|
2048
2162
|
for (const p of paths) {
|
|
2049
2163
|
const absolutePath = path.isAbsolute(p) ? p : path.resolve(hqRoot, p);
|
|
2050
2164
|
if (!fs.existsSync(absolutePath)) continue;
|
|
@@ -2296,23 +2410,19 @@ async function computeDeletePlan(
|
|
|
2296
2410
|
// and the journal-mutation buckets are already settled before any I/O.
|
|
2297
2411
|
type HeadCandidate = { key: string; journalEtag: string; intentVersion: 1 };
|
|
2298
2412
|
const headCandidates: HeadCandidate[] = [];
|
|
2299
|
-
// Litter drain bucket — kept separate from
|
|
2300
|
-
// bulk-asymmetry
|
|
2301
|
-
// `refusedStale` when it trips) can't sweep these out. See
|
|
2413
|
+
// Litter drain bucket — kept separate from normal delete candidates so
|
|
2414
|
+
// the bulk-asymmetry accounting cannot sweep these in. See
|
|
2302
2415
|
// `isVaultLitterArtifact` for the patterns and rationale: by construction
|
|
2303
2416
|
// these aren't user content losses, so a high ratio of litter must not
|
|
2304
2417
|
// refuse the drain — that's the whole point of the bypass. Merged into
|
|
2305
2418
|
// `plan.toDelete` after the breaker check.
|
|
2306
2419
|
const litterToDelete: string[] = [];
|
|
2307
|
-
//
|
|
2308
|
-
//
|
|
2309
|
-
//
|
|
2310
|
-
// legacy-no-etag refusals. We do NOT count "ENOENT but ignore-filtered" or
|
|
2311
|
-
// "ENOENT but ephemeral" — those drop out of the plan entirely on their own
|
|
2312
|
-
// and don't reflect mirror-loss intent — and litter drains (see above),
|
|
2313
|
-
// which are intentional ratchet-cleanup, not mass-delete intent.
|
|
2420
|
+
// Current watcher intents are affirmative evidence of an observed delete,
|
|
2421
|
+
// so only intent-less disappearances contribute to the mirror-loss breaker.
|
|
2422
|
+
// An unmounted/wiped tree produces no intents and still trips it.
|
|
2314
2423
|
let inScopeJournalEntries = 0;
|
|
2315
|
-
let
|
|
2424
|
+
let intentlessDisappearances = 0;
|
|
2425
|
+
const intentlessKeys = new Set<string>();
|
|
2316
2426
|
|
|
2317
2427
|
for (const [relativeKey, entry] of Object.entries(journal.files)) {
|
|
2318
2428
|
const inScope = scopeRoots.some(
|
|
@@ -2399,6 +2509,13 @@ async function computeDeletePlan(
|
|
|
2399
2509
|
}
|
|
2400
2510
|
|
|
2401
2511
|
if (!hasCurrentLocalDeleteIntent(entry)) {
|
|
2512
|
+
if (
|
|
2513
|
+
policy === "currency-gated" ||
|
|
2514
|
+
(policy === "owned-only" && entry.direction === "up")
|
|
2515
|
+
) {
|
|
2516
|
+
intentlessDisappearances++;
|
|
2517
|
+
intentlessKeys.add(relativeKey);
|
|
2518
|
+
}
|
|
2402
2519
|
plan.refusedStale.push({
|
|
2403
2520
|
key: relativeKey,
|
|
2404
2521
|
journalEtag: entry.remoteEtag ?? "<missing-delete-intent>",
|
|
@@ -2417,14 +2534,8 @@ async function computeDeletePlan(
|
|
|
2417
2534
|
});
|
|
2418
2535
|
continue;
|
|
2419
2536
|
}
|
|
2420
|
-
bulkCandidatePicks++;
|
|
2421
2537
|
if (policy === "owned-only") {
|
|
2422
2538
|
if (entry.direction !== "up") {
|
|
2423
|
-
// Not a delete candidate under owned-only, but it WAS missing
|
|
2424
|
-
// locally. Don't count it for the bulk guard — direction:'down'
|
|
2425
|
-
// entries that vanish locally are silently ignored by this policy
|
|
2426
|
-
// anyway, so they don't represent intent to mass-delete.
|
|
2427
|
-
bulkCandidatePicks--;
|
|
2428
2539
|
continue;
|
|
2429
2540
|
}
|
|
2430
2541
|
plan.toDelete.push({
|
|
@@ -2457,42 +2568,26 @@ async function computeDeletePlan(
|
|
|
2457
2568
|
if (
|
|
2458
2569
|
policy !== "all" &&
|
|
2459
2570
|
!isBulkAsymmetryOverride() &&
|
|
2460
|
-
|
|
2571
|
+
intentlessDisappearances >= BULK_ASYMMETRY_MIN_ABS &&
|
|
2461
2572
|
inScopeJournalEntries > 0 &&
|
|
2462
|
-
|
|
2573
|
+
intentlessDisappearances / inScopeJournalEntries >= BULK_ASYMMETRY_RATIO
|
|
2463
2574
|
) {
|
|
2464
|
-
// Move every staged candidate (both already-bucketed toDelete from
|
|
2465
|
-
// owned-only and queued headCandidates from currency-gated) into
|
|
2466
|
-
// refusedStale with reason "bulk-asymmetry". Journal is not mutated;
|
|
2467
|
-
// no DeleteObject is issued.
|
|
2468
2575
|
const samplePaths: string[] = [];
|
|
2469
|
-
const
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
reason: "bulk-asymmetry",
|
|
2475
|
-
});
|
|
2576
|
+
for (const refused of plan.refusedStale) {
|
|
2577
|
+
if (!intentlessKeys.has(refused.key)) continue;
|
|
2578
|
+
refused.journalEtag = "<bulk-asymmetry>";
|
|
2579
|
+
refused.remoteEtag = "<not-checked>";
|
|
2580
|
+
refused.reason = "bulk-asymmetry";
|
|
2476
2581
|
if (samplePaths.length < BULK_ASYMMETRY_SAMPLE_CAP) {
|
|
2477
|
-
samplePaths.push(key);
|
|
2582
|
+
samplePaths.push(refused.key);
|
|
2478
2583
|
}
|
|
2479
|
-
}
|
|
2480
|
-
for (const item of plan.toDelete) pushRefused(item.key);
|
|
2481
|
-
for (const c of headCandidates) pushRefused(c.key);
|
|
2482
|
-
plan.toDelete = [];
|
|
2584
|
+
}
|
|
2483
2585
|
plan.bulkAsymmetry = {
|
|
2484
|
-
candidates:
|
|
2586
|
+
candidates: intentlessDisappearances,
|
|
2485
2587
|
inScope: inScopeJournalEntries,
|
|
2486
|
-
ratio:
|
|
2588
|
+
ratio: intentlessDisappearances / inScopeJournalEntries,
|
|
2487
2589
|
samplePaths,
|
|
2488
2590
|
};
|
|
2489
|
-
// Litter still drains even when the breaker trips — see `litterToDelete`
|
|
2490
|
-
// declaration. The breaker protects against corrupt-mirror mass-deletes
|
|
2491
|
-
// of user content; litter cleanup is orthogonal and should never be
|
|
2492
|
-
// refused for the same reason new-litter producer-side exclusions
|
|
2493
|
-
// shouldn't block it.
|
|
2494
|
-
plan.toDelete.push(...litterToDelete.map((key) => ({ key, intentVersion: null })));
|
|
2495
|
-
return plan;
|
|
2496
2591
|
}
|
|
2497
2592
|
|
|
2498
2593
|
// Stage 2: bounded-parallel HEAD pass. Promise.all over chunks of size
|