@indigoai-us/hq-cloud 6.0.0 → 6.0.2
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 +54 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +117 -3
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +174 -13
- package/dist/cli/share.test.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/share.test.ts +222 -14
- package/src/cli/share.ts +124 -3
package/src/cli/share.test.ts
CHANGED
|
@@ -1091,6 +1091,140 @@ describe("share", () => {
|
|
|
1091
1091
|
expect(journal.files["dangling-link.md"]).toBeDefined();
|
|
1092
1092
|
});
|
|
1093
1093
|
|
|
1094
|
+
it(
|
|
1095
|
+
"personal-vault override: `owned-only` is coerced to `currency-gated` so a direction:'down' " +
|
|
1096
|
+
"entry whose local file is gone DOES tombstone in the vault (the May-27 .drift-* leak class)",
|
|
1097
|
+
async () => {
|
|
1098
|
+
// The personal vault is a single-human-many-machines target. The
|
|
1099
|
+
// owned-only "I won't tombstone peer content" rule has no peer to
|
|
1100
|
+
// protect against, so it traps legitimate local deletes as permanent
|
|
1101
|
+
// vault litter — exactly what the May-27 `personal/.obsidian/*.drift-*`
|
|
1102
|
+
// pair did on this EC2 (journal direction "down", local rm, push
|
|
1103
|
+
// silently swallowed the delete). Coercing to currency-gated keeps the
|
|
1104
|
+
// only safety intent that still applies (HEAD-verify etag matches the
|
|
1105
|
+
// journal before tombstone) without the multi-user premise.
|
|
1106
|
+
const stateDirPersonal = fs.mkdtempSync(
|
|
1107
|
+
path.join(os.tmpdir(), "hq-state-prs-"),
|
|
1108
|
+
);
|
|
1109
|
+
process.env.HQ_STATE_DIR = stateDirPersonal;
|
|
1110
|
+
try {
|
|
1111
|
+
// Personal-vault syncRoot is `hqRoot` itself in personalMode, so the
|
|
1112
|
+
// missing file's absolute path resolves to <hqRoot>/<key>.
|
|
1113
|
+
// No on-disk file = local missing. Journal records a prior pull.
|
|
1114
|
+
const journalPath = path.join(
|
|
1115
|
+
stateDirPersonal,
|
|
1116
|
+
"sync-journal.__hq_personal_vault__.json",
|
|
1117
|
+
);
|
|
1118
|
+
fs.writeFileSync(
|
|
1119
|
+
journalPath,
|
|
1120
|
+
JSON.stringify({
|
|
1121
|
+
version: "1",
|
|
1122
|
+
lastSync: new Date().toISOString(),
|
|
1123
|
+
files: {
|
|
1124
|
+
"personal/scripts/run-project.sh.drift-1779863862-42519": {
|
|
1125
|
+
hash: "personal-vault-drift-hash",
|
|
1126
|
+
size: 1054,
|
|
1127
|
+
syncedAt: new Date(Date.now() - 86400000).toISOString(),
|
|
1128
|
+
direction: "down",
|
|
1129
|
+
remoteEtag: "personal-vault-drift-etag",
|
|
1130
|
+
},
|
|
1131
|
+
},
|
|
1132
|
+
}),
|
|
1133
|
+
);
|
|
1134
|
+
|
|
1135
|
+
// (Pre-6.0.2: currency-gated would have HEADed the candidate; the
|
|
1136
|
+
// mockResolvedValueOnce that lived here is now redundant because the
|
|
1137
|
+
// vault-litter drain bypasses HEAD entirely for `.drift-` markers.
|
|
1138
|
+
// Leaving the mock here would queue an unconsumed return value and
|
|
1139
|
+
// poison the next test's HEAD call — removed so the assertion above
|
|
1140
|
+
// verifies the litter path, not the etag path.)
|
|
1141
|
+
|
|
1142
|
+
const personalCtx = makeEntityContext({
|
|
1143
|
+
uid: "prs_01HASSAANTESTUSER",
|
|
1144
|
+
slug: "__hq_personal_vault__",
|
|
1145
|
+
bucketName: "hq-vault-prs-01HASSAANTESTUSER",
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
const result = await share({
|
|
1149
|
+
paths: [tmpDir],
|
|
1150
|
+
entityContext: personalCtx,
|
|
1151
|
+
hqRoot: tmpDir,
|
|
1152
|
+
personalMode: true,
|
|
1153
|
+
skipUnchanged: true,
|
|
1154
|
+
propagateDeletes: true,
|
|
1155
|
+
// Caller pinned owned-only. The override MUST take precedence on a
|
|
1156
|
+
// personal-vault entity — the whole point of the fix is that owned-
|
|
1157
|
+
// only's "don't tombstone peer-uploaded content" rule has no peer
|
|
1158
|
+
// to protect against here, so we coerce to currency-gated.
|
|
1159
|
+
propagateDeletePolicy: "owned-only",
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
expect(result.filesDeleted).toBe(1);
|
|
1163
|
+
expect(deleteRemoteFile).toHaveBeenCalledWith(
|
|
1164
|
+
expect.anything(),
|
|
1165
|
+
"personal/scripts/run-project.sh.drift-1779863862-42519",
|
|
1166
|
+
);
|
|
1167
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1168
|
+
expect(
|
|
1169
|
+
journal.files["personal/scripts/run-project.sh.drift-1779863862-42519"],
|
|
1170
|
+
).toBeUndefined();
|
|
1171
|
+
} finally {
|
|
1172
|
+
fs.rmSync(stateDirPersonal, { recursive: true, force: true });
|
|
1173
|
+
delete process.env.HQ_STATE_DIR;
|
|
1174
|
+
}
|
|
1175
|
+
},
|
|
1176
|
+
);
|
|
1177
|
+
|
|
1178
|
+
it(
|
|
1179
|
+
"personal-vault override does NOT apply to company vaults: `owned-only` on cmp_ entity still " +
|
|
1180
|
+
"refuses to tombstone a direction:'down' entry (multi-user curation intact)",
|
|
1181
|
+
async () => {
|
|
1182
|
+
// Sibling guard for the previous test: the override is scoped strictly
|
|
1183
|
+
// to `prs_` entities. Company vaults preserve owned-only's multi-user
|
|
1184
|
+
// semantics — a behind machine pulling Alice's upload, then rm'ing it
|
|
1185
|
+
// locally, must NOT tombstone Alice's file. Without this guard the
|
|
1186
|
+
// override could silently widen its blast radius the next time someone
|
|
1187
|
+
// refactors share()'s entity-typing.
|
|
1188
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1189
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1190
|
+
// No file on disk under companies/acme/peer-uploaded.md → local missing.
|
|
1191
|
+
|
|
1192
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1193
|
+
fs.writeFileSync(
|
|
1194
|
+
journalPath,
|
|
1195
|
+
JSON.stringify({
|
|
1196
|
+
version: "1",
|
|
1197
|
+
lastSync: new Date().toISOString(),
|
|
1198
|
+
files: {
|
|
1199
|
+
"peer-uploaded.md": {
|
|
1200
|
+
hash: "alice-hash",
|
|
1201
|
+
size: 100,
|
|
1202
|
+
syncedAt: new Date(Date.now() - 86400000).toISOString(),
|
|
1203
|
+
direction: "down",
|
|
1204
|
+
remoteEtag: "alice-etag",
|
|
1205
|
+
},
|
|
1206
|
+
},
|
|
1207
|
+
}),
|
|
1208
|
+
);
|
|
1209
|
+
|
|
1210
|
+
const result = await share({
|
|
1211
|
+
paths: [companyRoot],
|
|
1212
|
+
company: "acme",
|
|
1213
|
+
vaultConfig: mockConfig,
|
|
1214
|
+
hqRoot: tmpDir,
|
|
1215
|
+
skipUnchanged: true,
|
|
1216
|
+
propagateDeletes: true,
|
|
1217
|
+
// Company-vault entity (cmp_) → owned-only stays in effect.
|
|
1218
|
+
propagateDeletePolicy: "owned-only",
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
expect(result.filesDeleted).toBe(0);
|
|
1222
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
1223
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1224
|
+
expect(journal.files["peer-uploaded.md"]).toBeDefined();
|
|
1225
|
+
},
|
|
1226
|
+
);
|
|
1227
|
+
|
|
1094
1228
|
it("propagateDeletes: deletes journal-tracked files whose local copy is gone", async () => {
|
|
1095
1229
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1096
1230
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
@@ -1834,14 +1968,23 @@ describe("share", () => {
|
|
|
1834
1968
|
expect(uploadFile).not.toHaveBeenCalled();
|
|
1835
1969
|
});
|
|
1836
1970
|
|
|
1837
|
-
it("
|
|
1971
|
+
it("vault-litter drain (6.0.2): journaled `.conflict-*` mirror with local-missing IS swept by the delete plan", async () => {
|
|
1972
|
+
// Updated semantics (6.0.2): the existing-litter state — a conflict
|
|
1973
|
+
// mirror that leaked into the journal from a prior buggy upload and was
|
|
1974
|
+
// subsequently deleted locally — used to survive every sync because the
|
|
1975
|
+
// `isEphemeralPath` skip in computeDeletePlan was wired to drop it
|
|
1976
|
+
// before tombstoning. The deferred "dedicated reconcile command" the
|
|
1977
|
+
// doc-comment promised never materialized, and the litter accumulated
|
|
1978
|
+
// until users noticed it (e.g. the May-27 `personal/.obsidian/*.drift-*`
|
|
1979
|
+
// pair on the EC2 outpost). The fix: drain unconditionally via the
|
|
1980
|
+
// litter bucket — bypass shouldSync, ephemeral-skip, policy, and the
|
|
1981
|
+
// bulk-asymmetry breaker. By construction litter is not user content
|
|
1982
|
+
// (the EPHEMERAL_PATH_PATTERN / DRIFT_PATH_PATTERN regexes are precise
|
|
1983
|
+
// enough that a false-positive on a real user filename is vanishingly
|
|
1984
|
+
// unlikely), so the absence of those gates is the correct behavior.
|
|
1838
1985
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1839
1986
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1840
|
-
|
|
1841
|
-
// into the journal in a prior buggy version. Locally missing (user
|
|
1842
|
-
// already deleted it). The regular delete plan must NOT issue a
|
|
1843
|
-
// DeleteObject — that's the dedicated reconcile command's job, and
|
|
1844
|
-
// a sync should not accidentally race a user reviewing the mirror.
|
|
1987
|
+
|
|
1845
1988
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1846
1989
|
fs.writeFileSync(
|
|
1847
1990
|
journalPath,
|
|
@@ -1863,7 +2006,10 @@ describe("share", () => {
|
|
|
1863
2006
|
}),
|
|
1864
2007
|
);
|
|
1865
2008
|
|
|
1866
|
-
// HEAD needed for the non-mirror entry under currency-gated.
|
|
2009
|
+
// HEAD needed for the non-mirror entry under currency-gated. The litter
|
|
2010
|
+
// drain bypasses HEAD by design — the etag check encodes a "remote
|
|
2011
|
+
// hasn't drifted since I last synced" invariant that doesn't apply to
|
|
2012
|
+
// never-should-have-been-there litter.
|
|
1867
2013
|
vi.mocked(headRemoteFile).mockResolvedValue({
|
|
1868
2014
|
lastModified: new Date(),
|
|
1869
2015
|
etag: '"regular-etag"',
|
|
@@ -1879,19 +2025,81 @@ describe("share", () => {
|
|
|
1879
2025
|
propagateDeletes: true,
|
|
1880
2026
|
});
|
|
1881
2027
|
|
|
1882
|
-
|
|
1883
|
-
expect(
|
|
2028
|
+
// BOTH the regular file AND the conflict mirror tombstone in one pass.
|
|
2029
|
+
expect(result.filesDeleted).toBe(2);
|
|
2030
|
+
expect(deleteRemoteFile).toHaveBeenCalledTimes(2);
|
|
1884
2031
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "regular.md");
|
|
1885
|
-
expect(deleteRemoteFile).
|
|
2032
|
+
expect(deleteRemoteFile).toHaveBeenCalledWith(
|
|
1886
2033
|
expect.anything(),
|
|
1887
|
-
|
|
2034
|
+
"CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md",
|
|
1888
2035
|
);
|
|
1889
|
-
//
|
|
1890
|
-
// sweeps it once the user explicitly opts in.
|
|
2036
|
+
// Both journal entries are removed by the delete loop's removeEntry call.
|
|
1891
2037
|
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1892
2038
|
expect(
|
|
1893
2039
|
journal.files["CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md"],
|
|
1894
|
-
).
|
|
2040
|
+
).toBeUndefined();
|
|
2041
|
+
expect(journal.files["regular.md"]).toBeUndefined();
|
|
2042
|
+
});
|
|
2043
|
+
|
|
2044
|
+
it("vault-litter drain (6.0.2): `.drift-<unixts>-<pid>` rescue markers drain regardless of personal-vault exclusion path (the live obsidian case)", async () => {
|
|
2045
|
+
// The exact failure mode that motivated the fix: a rescue-overlay
|
|
2046
|
+
// `.drift-` marker sitting under a personal-vault default exclusion
|
|
2047
|
+
// (`.obsidian/**`) survives every sync because `shouldSync` rejects
|
|
2048
|
+
// the parent path before the delete plan ever considers the entry.
|
|
2049
|
+
// The litter bypass routes such keys around shouldSync directly into
|
|
2050
|
+
// `litterToDelete`.
|
|
2051
|
+
const stateDirPersonal = fs.mkdtempSync(
|
|
2052
|
+
path.join(os.tmpdir(), "hq-state-prs-drift-"),
|
|
2053
|
+
);
|
|
2054
|
+
process.env.HQ_STATE_DIR = stateDirPersonal;
|
|
2055
|
+
try {
|
|
2056
|
+
const journalPath = path.join(
|
|
2057
|
+
stateDirPersonal,
|
|
2058
|
+
"sync-journal.__hq_personal_vault__.json",
|
|
2059
|
+
);
|
|
2060
|
+
fs.writeFileSync(
|
|
2061
|
+
journalPath,
|
|
2062
|
+
JSON.stringify({
|
|
2063
|
+
version: "1",
|
|
2064
|
+
lastSync: new Date().toISOString(),
|
|
2065
|
+
files: {
|
|
2066
|
+
"personal/.obsidian/graph.json.drift-1779863862-42519": {
|
|
2067
|
+
hash: "h", size: 1054, syncedAt: new Date().toISOString(),
|
|
2068
|
+
direction: "down",
|
|
2069
|
+
remoteEtag: "obsidian-drift-etag",
|
|
2070
|
+
},
|
|
2071
|
+
},
|
|
2072
|
+
}),
|
|
2073
|
+
);
|
|
2074
|
+
|
|
2075
|
+
const personalCtx = makeEntityContext({
|
|
2076
|
+
uid: "prs_01HASSAANTESTUSER",
|
|
2077
|
+
slug: "__hq_personal_vault__",
|
|
2078
|
+
bucketName: "hq-vault-prs-01HASSAANTESTUSER",
|
|
2079
|
+
});
|
|
2080
|
+
|
|
2081
|
+
const result = await share({
|
|
2082
|
+
paths: [tmpDir],
|
|
2083
|
+
entityContext: personalCtx,
|
|
2084
|
+
hqRoot: tmpDir,
|
|
2085
|
+
personalMode: true,
|
|
2086
|
+
skipUnchanged: true,
|
|
2087
|
+
propagateDeletes: true,
|
|
2088
|
+
});
|
|
2089
|
+
|
|
2090
|
+
expect(result.filesDeleted).toBe(1);
|
|
2091
|
+
expect(deleteRemoteFile).toHaveBeenCalledWith(
|
|
2092
|
+
expect.anything(),
|
|
2093
|
+
"personal/.obsidian/graph.json.drift-1779863862-42519",
|
|
2094
|
+
);
|
|
2095
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2096
|
+
expect(
|
|
2097
|
+
journal.files["personal/.obsidian/graph.json.drift-1779863862-42519"],
|
|
2098
|
+
).toBeUndefined();
|
|
2099
|
+
} finally {
|
|
2100
|
+
fs.rmSync(stateDirPersonal, { recursive: true, force: true });
|
|
2101
|
+
delete process.env.HQ_STATE_DIR;
|
|
2102
|
+
}
|
|
1895
2103
|
});
|
|
1896
2104
|
|
|
1897
2105
|
// ── personalMode ───────────────────────────────────────────────────────────
|
package/src/cli/share.ts
CHANGED
|
@@ -100,6 +100,64 @@ export const EPHEMERAL_PATH_PATTERN =
|
|
|
100
100
|
* matches anywhere in the string, which is fine: the
|
|
101
101
|
* `.conflict-<ISO>-<hash>.` token is unambiguous.
|
|
102
102
|
*/
|
|
103
|
+
/**
|
|
104
|
+
* Rescue-overlay drift marker pattern. Written by `replace-rescue.sh`
|
|
105
|
+
* (`rescue_one` / `conflict_one`) when its rescue target already exists:
|
|
106
|
+
* `<orig>.drift-<unix-ts>-<pid>` — a collision suffix so the previous override
|
|
107
|
+
* is never silently overwritten. Distinct from `EPHEMERAL_PATH_PATTERN` —
|
|
108
|
+
* different producer (the rescue script vs the sync conflict-mirror path),
|
|
109
|
+
* different filename grammar (decimal timestamp + decimal pid vs ISO timestamp
|
|
110
|
+
* + hex machine hash). Like conflict mirrors, drift markers should never live
|
|
111
|
+
* in the vault; if a buggy past run uploaded one, the delete plan must be
|
|
112
|
+
* able to drain it regardless of where it sits.
|
|
113
|
+
*/
|
|
114
|
+
export const DRIFT_PATH_PATTERN = /\.drift-\d+-\d+$/;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* True iff the key is local-only ephemeral vault litter — a sync conflict
|
|
118
|
+
* mirror (`.conflict-<ISO>-<machine>[.ext]`) OR a rescue drift marker
|
|
119
|
+
* (`.drift-<unixts>-<pid>`).
|
|
120
|
+
*
|
|
121
|
+
* Used by `computeDeletePlan` to UNCONDITIONALLY drain existing vault litter,
|
|
122
|
+
* bypassing every "skip" gate that would otherwise trap it:
|
|
123
|
+
*
|
|
124
|
+
* 1. `shouldSync` — the personal-vault default exclusions (introduced in
|
|
125
|
+
* 5.25) reject paths like `**.obsidian/**`, `**.env`, `**output/**`,
|
|
126
|
+
* `**node_modules/**`, etc. from BOTH the upload walk AND the delete
|
|
127
|
+
* plan. That's correct for fresh content (don't upload), but it also
|
|
128
|
+
* strands any litter already in the vault at those paths — `<orig>.drift`
|
|
129
|
+
* / `<orig>.conflict` files inside an excluded parent get re-pulled
|
|
130
|
+
* every sync and never tombstoned. The live obsidian case here:
|
|
131
|
+
* `personal/.obsidian/graph.json.drift-1779863862-42519` survived every
|
|
132
|
+
* sync for two weeks because `.obsidian` is excluded.
|
|
133
|
+
* 2. `isEphemeralPath` — the existing skip in `computeDeletePlan` was
|
|
134
|
+
* designed to prevent a FRESH local `.conflict-*` mirror (written by the
|
|
135
|
+
* pull leg's "keep" branch as a side-by-side comparison file) from being
|
|
136
|
+
* miscounted as a delete candidate before the user has resolved the
|
|
137
|
+
* conflict. That intent stands, but it accidentally protects EXISTING
|
|
138
|
+
* cloud litter too. The fix: when the local file is already gone AND the
|
|
139
|
+
* key matches the litter pattern, drain it; the "fresh mirror, hasn't
|
|
140
|
+
* been resolved yet" case is impossible because that mirror would still
|
|
141
|
+
* be on disk.
|
|
142
|
+
* 3. The policy gate — `owned-only`'s direction filter and
|
|
143
|
+
* `currency-gated`'s etag check both encode user-content invariants
|
|
144
|
+
* that don't apply to litter (a `.drift-…-PID` file has no meaningful
|
|
145
|
+
* "ownership" or "freshness"; it's a stale local-overlay collision
|
|
146
|
+
* marker, by construction).
|
|
147
|
+
* 4. The bulk-asymmetry circuit breaker — litter cleanup is intentional
|
|
148
|
+
* ratchet-drain, not a "corrupt local mirror, refuse mass-delete"
|
|
149
|
+
* signal. Litter is queued via a separate bucket the breaker never
|
|
150
|
+
* sweeps.
|
|
151
|
+
*
|
|
152
|
+
* Together: a vault key matching either pattern always tombstones on the
|
|
153
|
+
* next push leg, regardless of personal-vault exclusions, ephemeral skip,
|
|
154
|
+
* policy, or breaker. Producer-side exclusions (upload walker + rescue
|
|
155
|
+
* script) close the ratchet on new litter; this drains the legacy buildup.
|
|
156
|
+
*/
|
|
157
|
+
export function isVaultLitterArtifact(p: string): boolean {
|
|
158
|
+
return EPHEMERAL_PATH_PATTERN.test(p) || DRIFT_PATH_PATTERN.test(p);
|
|
159
|
+
}
|
|
160
|
+
|
|
103
161
|
export function isEphemeralPath(p: string): boolean {
|
|
104
162
|
return EPHEMERAL_PATH_PATTERN.test(p);
|
|
105
163
|
}
|
|
@@ -538,7 +596,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
538
596
|
// `propagateDeletePolicy: "currency-gated"` (explicit) or
|
|
539
597
|
// `HQ_SYNC_DELETE_POLICY=currency-gated` (env, honored by sync-runner).
|
|
540
598
|
// The default flip to `"currency-gated"` is scheduled for 5.25.0.
|
|
541
|
-
|
|
599
|
+
let propagateDeletePolicy: "currency-gated" | "owned-only" | "all" =
|
|
542
600
|
options.propagateDeletePolicy ?? "owned-only";
|
|
543
601
|
const emit = options.onEvent ?? defaultConsoleLogger;
|
|
544
602
|
|
|
@@ -581,6 +639,28 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
581
639
|
let ctx: EntityContext = entityContext
|
|
582
640
|
? entityContext
|
|
583
641
|
: await resolveEntityContext(companyRef, vaultConfig!);
|
|
642
|
+
|
|
643
|
+
// Personal-vault policy correction (6.0.1). The `owned-only` rule encodes a
|
|
644
|
+
// multi-user curation premise — "don't tombstone peer-uploaded content even
|
|
645
|
+
// if my journal says I pulled it" — which is meaningful when several humans
|
|
646
|
+
// share a company bucket (a behind machine's first sync must not erase
|
|
647
|
+
// recent uploads from peers). On a personal vault that premise collapses:
|
|
648
|
+
// every file is the same human's content, just routed through different
|
|
649
|
+
// machines, and `direction: "down"` only means "uploaded from my laptop,
|
|
650
|
+
// pulled by my EC2" — it never means "uploaded by someone else." With
|
|
651
|
+
// `owned-only` in effect, `rm <file>` followed by `hq sync` silently fails
|
|
652
|
+
// to propagate the delete, leaving permanent vault litter (the May-27
|
|
653
|
+
// `personal/.obsidian/*.drift-*` files were diagnosed exactly this way).
|
|
654
|
+
// The etag-based `currency-gated` policy already captures the only safety
|
|
655
|
+
// intent that survives the single-user case ("don't tombstone if remote
|
|
656
|
+
// drifted since I last synced"); coerce to it here so the policy is right
|
|
657
|
+
// regardless of which caller's default landed. Explicit `"all"` is
|
|
658
|
+
// preserved — it's the emergency-reconcile opt-out and the caller has
|
|
659
|
+
// already asserted intent.
|
|
660
|
+
if (ctx.uid.startsWith("prs_") && propagateDeletePolicy === "owned-only") {
|
|
661
|
+
propagateDeletePolicy = "currency-gated";
|
|
662
|
+
}
|
|
663
|
+
|
|
584
664
|
// Remote keys are company-relative; the on-disk scoping prefix is
|
|
585
665
|
// companies/{slug}/. Anything outside this folder gets skipped to avoid
|
|
586
666
|
// leaking cross-company state into the vault.
|
|
@@ -1777,12 +1857,21 @@ async function computeDeletePlan(
|
|
|
1777
1857
|
// and the journal-mutation buckets are already settled before any I/O.
|
|
1778
1858
|
type HeadCandidate = { key: string; journalEtag: string };
|
|
1779
1859
|
const headCandidates: HeadCandidate[] = [];
|
|
1860
|
+
// Litter drain bucket — kept separate from `plan.toDelete` so the
|
|
1861
|
+
// bulk-asymmetry breaker (which moves toDelete + headCandidates into
|
|
1862
|
+
// `refusedStale` when it trips) can't sweep these out. See
|
|
1863
|
+
// `isVaultLitterArtifact` for the patterns and rationale: by construction
|
|
1864
|
+
// these aren't user content losses, so a high ratio of litter must not
|
|
1865
|
+
// refuse the drain — that's the whole point of the bypass. Merged into
|
|
1866
|
+
// `plan.toDelete` after the breaker check.
|
|
1867
|
+
const litterToDelete: string[] = [];
|
|
1780
1868
|
// Bulk-asymmetry tracking: count every in-scope journal entry (denominator)
|
|
1781
1869
|
// and every entry that would have been a delete-candidate before the guard
|
|
1782
1870
|
// (numerator). Numerator = headCandidates + owned-only/all toDelete picks +
|
|
1783
1871
|
// legacy-no-etag refusals. We do NOT count "ENOENT but ignore-filtered" or
|
|
1784
1872
|
// "ENOENT but ephemeral" — those drop out of the plan entirely on their own
|
|
1785
|
-
// and don't reflect mirror-loss intent
|
|
1873
|
+
// and don't reflect mirror-loss intent — and litter drains (see above),
|
|
1874
|
+
// which are intentional ratchet-cleanup, not mass-delete intent.
|
|
1786
1875
|
let inScopeJournalEntries = 0;
|
|
1787
1876
|
let bulkCandidatePicks = 0;
|
|
1788
1877
|
|
|
@@ -1812,9 +1901,28 @@ async function computeDeletePlan(
|
|
|
1812
1901
|
}
|
|
1813
1902
|
}
|
|
1814
1903
|
if (presentLocally) continue;
|
|
1904
|
+
|
|
1905
|
+
// Vault-litter drain (6.0.2): conflict mirrors + rescue drift markers
|
|
1906
|
+
// ALWAYS drain, bypassing `shouldSync` (which would skip them when the
|
|
1907
|
+
// parent path is in personal-vault default exclusions like
|
|
1908
|
+
// `.obsidian/**`), the `isEphemeralPath` skip (which protects FRESH
|
|
1909
|
+
// local conflict mirrors but accidentally also protects existing vault
|
|
1910
|
+
// litter), the policy gate (no meaningful ownership/freshness for
|
|
1911
|
+
// litter), and the bulk-asymmetry breaker (intentional ratchet-drain,
|
|
1912
|
+
// not corrupt-mirror mass-delete intent). See `isVaultLitterArtifact`
|
|
1913
|
+
// for the full rationale and patterns. Queued via the separate
|
|
1914
|
+
// `litterToDelete` bucket so the breaker can't sweep these out.
|
|
1915
|
+
if (isVaultLitterArtifact(relativeKey)) {
|
|
1916
|
+
litterToDelete.push(relativeKey);
|
|
1917
|
+
continue;
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1815
1920
|
if (!shouldSync(localPath, false) && !shouldSync(localPath, true)) continue;
|
|
1816
1921
|
// Ephemeral artifacts (conflict mirrors) never propagate-delete via the
|
|
1817
|
-
// normal path — see EPHEMERAL_PATH_PATTERN doc.
|
|
1922
|
+
// normal path — see EPHEMERAL_PATH_PATTERN doc. NOTE: this is a no-op
|
|
1923
|
+
// post-litter-drain above — any key matching EPHEMERAL_PATH_PATTERN was
|
|
1924
|
+
// already routed to `litterToDelete`. Retained as defense-in-depth +
|
|
1925
|
+
// documentation of the policy-side intent.
|
|
1818
1926
|
if (isEphemeralPath(relativeKey)) continue;
|
|
1819
1927
|
|
|
1820
1928
|
if (policy === "all") {
|
|
@@ -1885,6 +1993,12 @@ async function computeDeletePlan(
|
|
|
1885
1993
|
ratio: bulkCandidatePicks / inScopeJournalEntries,
|
|
1886
1994
|
samplePaths,
|
|
1887
1995
|
};
|
|
1996
|
+
// Litter still drains even when the breaker trips — see `litterToDelete`
|
|
1997
|
+
// declaration. The breaker protects against corrupt-mirror mass-deletes
|
|
1998
|
+
// of user content; litter cleanup is orthogonal and should never be
|
|
1999
|
+
// refused for the same reason new-litter producer-side exclusions
|
|
2000
|
+
// shouldn't block it.
|
|
2001
|
+
plan.toDelete.push(...litterToDelete);
|
|
1888
2002
|
return plan;
|
|
1889
2003
|
}
|
|
1890
2004
|
|
|
@@ -1922,6 +2036,13 @@ async function computeDeletePlan(
|
|
|
1922
2036
|
}
|
|
1923
2037
|
}
|
|
1924
2038
|
|
|
2039
|
+
// Litter drains alongside the normal candidates — bypasses every gate but
|
|
2040
|
+
// settles into the same plan.toDelete bucket so the share() executor's
|
|
2041
|
+
// delete loop tombstones each key identically (DeleteObject + remove from
|
|
2042
|
+
// journal). Merged at the end so the bulk-asymmetry breaker above had its
|
|
2043
|
+
// chance to NOT include litter in either the numerator or the sweep.
|
|
2044
|
+
plan.toDelete.push(...litterToDelete);
|
|
2045
|
+
|
|
1925
2046
|
return plan;
|
|
1926
2047
|
}
|
|
1927
2048
|
|