@indigoai-us/hq-cloud 6.14.47 → 6.14.48
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 +11 -3
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +182 -19
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +530 -28
- 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 +16 -3
- package/dist/cli/sync.js.map +1 -1
- package/dist/journal.d.ts +56 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +49 -1
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +37 -2
- package/dist/journal.test.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/share.test.ts +598 -28
- package/src/cli/share.ts +197 -31
- package/src/cli/sync.ts +21 -11
- package/src/journal.test.ts +44 -1
- package/src/journal.ts +69 -4
package/src/cli/share.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
hashSymlinkTarget,
|
|
33
33
|
updateEntry,
|
|
34
34
|
removeEntry,
|
|
35
|
+
isTombstone,
|
|
35
36
|
normalizeEtag,
|
|
36
37
|
PERSONAL_VAULT_JOURNAL_SLUG,
|
|
37
38
|
migratePersonalVaultJournal,
|
|
@@ -300,6 +301,7 @@ export const _testing = {
|
|
|
300
301
|
collectFiles,
|
|
301
302
|
resolveNamedPath,
|
|
302
303
|
isWithinLexicalOrReal,
|
|
304
|
+
defaultConsoleLogger,
|
|
303
305
|
};
|
|
304
306
|
|
|
305
307
|
/**
|
|
@@ -646,8 +648,10 @@ export interface ShareOptions {
|
|
|
646
648
|
* belong in THIS bucket regardless of local state" and uses the same
|
|
647
649
|
* DeleteObject + journal-removal path as `propagateDeletes`.
|
|
648
650
|
*
|
|
649
|
-
* Honors `propagateDeletePolicy
|
|
650
|
-
*
|
|
651
|
+
* Honors `propagateDeletePolicy`. Under `"owned-only"` (this function's
|
|
652
|
+
* own fallback when the caller passes nothing; every live caller passes
|
|
653
|
+
* `resolveDeletePolicy()`, which defaults to `"currency-gated"`) only
|
|
654
|
+
* journal entries with `direction === "up"` are decommissioned, so a
|
|
651
655
|
* misconfigured caller never erases content pulled from elsewhere.
|
|
652
656
|
*
|
|
653
657
|
* Independent of `propagateDeletes`: callers can opt into decommission
|
|
@@ -893,6 +897,7 @@ export type ShareDeleteRefusalReason =
|
|
|
893
897
|
| "divergent-local"
|
|
894
898
|
| "missing-delete-intent"
|
|
895
899
|
| "intent-changed"
|
|
900
|
+
| "recreated-locally"
|
|
896
901
|
| "transfer-error";
|
|
897
902
|
|
|
898
903
|
export interface SharePathResult {
|
|
@@ -1093,7 +1098,7 @@ const REFUSED_STALE_PATH_CAP = 50;
|
|
|
1093
1098
|
|
|
1094
1099
|
async function createPushRunContext(options: ShareOptions): Promise<PushRunContext> {
|
|
1095
1100
|
const { paths, company, message, onConflict, vaultConfig, entityContext, hqRoot, skipUnchanged, propagateDeletes } = options;
|
|
1096
|
-
|
|
1101
|
+
const propagateDeletePolicy: DeletePolicy =
|
|
1097
1102
|
options.propagateDeletePolicy ?? "owned-only";
|
|
1098
1103
|
const baseEmit = options.onEvent ?? defaultConsoleLogger;
|
|
1099
1104
|
|
|
@@ -1123,9 +1128,30 @@ async function createPushRunContext(options: ShareOptions): Promise<PushRunConte
|
|
|
1123
1128
|
? entityContext
|
|
1124
1129
|
: await resolveEntityContext(companyRef, vaultConfig!);
|
|
1125
1130
|
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1131
|
+
// NOTE (removed 6.14.48): a `prs_`-scoped coercion used to silently rewrite
|
|
1132
|
+
// `owned-only` -> `currency-gated` here (366721b). It was correct for the
|
|
1133
|
+
// problem it was written against and is now actively harmful, for three
|
|
1134
|
+
// reasons that only became true later:
|
|
1135
|
+
//
|
|
1136
|
+
// 1. Its ONLY reachable effect in production was to override an EXPLICIT
|
|
1137
|
+
// `owned-only`. Every live caller passes a policy
|
|
1138
|
+
// (`sync-runner-company.ts` -> `resolveDeletePolicy()`), and that
|
|
1139
|
+
// resolver already defaults to `currency-gated`. So the coercion never
|
|
1140
|
+
// fired on a default run — it fired exactly when an operator had set
|
|
1141
|
+
// `HQ_SYNC_DELETE_POLICY=owned-only`, i.e. the documented rollback.
|
|
1142
|
+
// 2. Once delete authorization moved to ETag currency (no watcher-minted
|
|
1143
|
+
// intent required), that override stopped being cosmetic: an operator
|
|
1144
|
+
// reaching for the rollback because deletes were misbehaving would keep
|
|
1145
|
+
// getting intent-less etag-only deletes on their personal vault. A
|
|
1146
|
+
// rollback knob that silently exempts a vault is not a rollback knob.
|
|
1147
|
+
// 3. Its motivating bug — the May-27 `personal/.obsidian/*.drift-*` leak —
|
|
1148
|
+
// is fixed twice over by mechanisms that postdate it: the vault-litter
|
|
1149
|
+
// drain (6.0.2) drains `.drift-*` markers ahead of every policy gate,
|
|
1150
|
+
// and etag-gated deletes propagate an intent-less `direction:"down"`
|
|
1151
|
+
// removal under the default policy without needing any coercion.
|
|
1152
|
+
//
|
|
1153
|
+
// So `owned-only` now means owned-only on every vault kind. It is the strict
|
|
1154
|
+
// rollback path and must stay strictly stricter than the default everywhere.
|
|
1129
1155
|
|
|
1130
1156
|
// Mirror push progress into the shared cross-process snapshot (see sync.ts).
|
|
1131
1157
|
// Record the friendly company slug, or null for the personal vault so the
|
|
@@ -1553,10 +1579,12 @@ async function executeUploads(
|
|
|
1553
1579
|
localHash,
|
|
1554
1580
|
lstat.size,
|
|
1555
1581
|
"up",
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1582
|
+
absolutePath,
|
|
1583
|
+
{
|
|
1584
|
+
remoteEtag: remoteMeta.etag,
|
|
1585
|
+
mtimeMs: lstat.mtimeMs,
|
|
1586
|
+
kind: item.kind,
|
|
1587
|
+
},
|
|
1560
1588
|
);
|
|
1561
1589
|
run.emit({ type: "reconciled", path: relativePath, direction: "push" });
|
|
1562
1590
|
counters.filesSkipped++;
|
|
@@ -1638,10 +1666,8 @@ async function executeUploads(
|
|
|
1638
1666
|
localHash,
|
|
1639
1667
|
size,
|
|
1640
1668
|
"up",
|
|
1641
|
-
|
|
1642
|
-
mtimeMs,
|
|
1643
|
-
undefined,
|
|
1644
|
-
item.kind,
|
|
1669
|
+
absolutePath,
|
|
1670
|
+
{ remoteEtag: etag, mtimeMs, kind: item.kind },
|
|
1645
1671
|
);
|
|
1646
1672
|
if (run.message) {
|
|
1647
1673
|
run.journal.files[relativePath] = {
|
|
@@ -1807,21 +1833,34 @@ async function executeDeletes(
|
|
|
1807
1833
|
}
|
|
1808
1834
|
try {
|
|
1809
1835
|
const entry = run.journal.files[relativePath];
|
|
1810
|
-
|
|
1836
|
+
// Last-second absence re-check. Applies to EVERY propagated delete,
|
|
1837
|
+
// intent-backed or etag-only: a pull leg or the user can recreate the
|
|
1838
|
+
// file between planning and applying, and deleting it remotely then
|
|
1839
|
+
// destroys a file that exists locally right now. This used to sit inside
|
|
1840
|
+
// the intent-version check, which meant it was skipped for any item
|
|
1841
|
+
// carrying `intentVersion: null` — harmless while that only covered
|
|
1842
|
+
// litter and decommission, but not once etag-only candidates travel that
|
|
1843
|
+
// way too.
|
|
1844
|
+
//
|
|
1845
|
+
// Codec boundary: journal keys are canonical; the probe must use the
|
|
1846
|
+
// ENCODED local path or a win32 colon key always looks absent (a raw `:`
|
|
1847
|
+
// name can never exist on disk) and the remote delete proceeds while the
|
|
1848
|
+
// encoded local file is still present.
|
|
1849
|
+
const recreatedLocally =
|
|
1850
|
+
!item.decommission &&
|
|
1851
|
+
!isLocallyAbsent(localPathForVaultKey(run.syncRoot, relativePath));
|
|
1852
|
+
const intentChanged =
|
|
1811
1853
|
!item.decommission &&
|
|
1812
1854
|
item.intentVersion !== null &&
|
|
1813
1855
|
(!hasCurrentLocalDeleteIntent(entry) ||
|
|
1814
|
-
entry!.localDeleteIntent!.version !== item.intentVersion
|
|
1815
|
-
|
|
1816
|
-
// absence re-check must probe the ENCODED local path or a win32
|
|
1817
|
-
// colon key always looks absent (a raw `:` name can never exist
|
|
1818
|
-
// on disk) and the remote delete proceeds while the encoded local
|
|
1819
|
-
// file is still present.
|
|
1820
|
-
!isLocallyAbsent(localPathForVaultKey(run.syncRoot, relativePath)))
|
|
1821
|
-
) {
|
|
1856
|
+
entry!.localDeleteIntent!.version !== item.intentVersion);
|
|
1857
|
+
if (recreatedLocally || intentChanged) {
|
|
1822
1858
|
if (entry?.localDeleteIntent) {
|
|
1823
1859
|
delete entry.localDeleteIntent;
|
|
1824
1860
|
}
|
|
1861
|
+
const refusalReason = recreatedLocally
|
|
1862
|
+
? ("recreated-locally" as const)
|
|
1863
|
+
: ("intent-changed" as const);
|
|
1825
1864
|
counters.filesRefusedStale++;
|
|
1826
1865
|
if (filesRefusedStalePaths.length < REFUSED_STALE_PATH_CAP) {
|
|
1827
1866
|
filesRefusedStalePaths.push(relativePath);
|
|
@@ -1831,13 +1870,13 @@ async function executeDeletes(
|
|
|
1831
1870
|
path: relativePath,
|
|
1832
1871
|
journalEtag: entry?.remoteEtag ?? "<missing-delete-intent>",
|
|
1833
1872
|
remoteEtag: "<not-checked>",
|
|
1834
|
-
reason:
|
|
1873
|
+
reason: refusalReason,
|
|
1835
1874
|
});
|
|
1836
1875
|
pathResults.push({
|
|
1837
1876
|
path: relativePath,
|
|
1838
1877
|
status: "refused",
|
|
1839
1878
|
operation: "delete",
|
|
1840
|
-
reason:
|
|
1879
|
+
reason: refusalReason,
|
|
1841
1880
|
});
|
|
1842
1881
|
continue;
|
|
1843
1882
|
}
|
|
@@ -2183,6 +2222,32 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
|
2183
2222
|
// Per-key lines stay compact so a 100-candidate refusal doesn't bury
|
|
2184
2223
|
// the summary banner.
|
|
2185
2224
|
console.error(` ⚠ bulk-asymmetry refusal, kept on remote: ${event.path}`);
|
|
2225
|
+
} else if (event.reason === "missing-delete-intent") {
|
|
2226
|
+
// NEITHER etag is meaningful here: no HEAD was issued, so `remoteEtag`
|
|
2227
|
+
// is the `<not-checked>` sentinel, and `journalEtag` is the entry's
|
|
2228
|
+
// real recorded etag. Printing them under the `stale-etag` wording
|
|
2229
|
+
// (which this branch used to fall through to) reads as an etag
|
|
2230
|
+
// conflict and has repeatedly sent operators chasing a currency bug
|
|
2231
|
+
// that does not exist — the actual cause is that no watcher observed
|
|
2232
|
+
// the removal, so no delete authorization was ever recorded.
|
|
2233
|
+
console.error(
|
|
2234
|
+
` ⚠ no-delete-authorization, kept on remote: ${event.path} ` +
|
|
2235
|
+
`(the sync watcher did not observe this removal, so the delete was never authorized)`,
|
|
2236
|
+
);
|
|
2237
|
+
} else if (event.reason === "recreated-locally") {
|
|
2238
|
+
console.error(
|
|
2239
|
+
` ⚠ recreated-locally, kept on remote: ${event.path} ` +
|
|
2240
|
+
`(the file exists locally again, so the delete was abandoned)`,
|
|
2241
|
+
);
|
|
2242
|
+
} else if (event.reason === "divergent-local") {
|
|
2243
|
+
// Same trap as above — `remoteEtag` is `<not-checked>`. The entry's
|
|
2244
|
+
// recorded etag was stamped only to silence a re-fired conflict; the
|
|
2245
|
+
// local copy never matched it, so propagating the delete would destroy
|
|
2246
|
+
// divergent remote work.
|
|
2247
|
+
console.error(
|
|
2248
|
+
` ⚠ divergent-local, kept on remote: ${event.path} ` +
|
|
2249
|
+
`(the local copy never matched the recorded remote version; pull to reconcile first)`,
|
|
2250
|
+
);
|
|
2186
2251
|
} else {
|
|
2187
2252
|
console.error(
|
|
2188
2253
|
` ⚠ stale-etag, kept on remote: ${event.path} (journal=${event.journalEtag}, remote=${event.remoteEtag})`,
|
|
@@ -2911,7 +2976,8 @@ type RefusedStaleReason =
|
|
|
2911
2976
|
| "bulk-asymmetry"
|
|
2912
2977
|
| "divergent-local"
|
|
2913
2978
|
| "missing-delete-intent"
|
|
2914
|
-
| "intent-changed"
|
|
2979
|
+
| "intent-changed"
|
|
2980
|
+
| "recreated-locally";
|
|
2915
2981
|
|
|
2916
2982
|
/**
|
|
2917
2983
|
* Bulk-asymmetry circuit-breaker — refuses to convert a suspiciously-large
|
|
@@ -3134,7 +3200,7 @@ async function computeDeletePlan(
|
|
|
3134
3200
|
// all), or queue it for HEAD (currency-gated). Keeping this synchronous
|
|
3135
3201
|
// means the HEAD pass below sees a single, deduplicated candidate list
|
|
3136
3202
|
// and the journal-mutation buckets are already settled before any I/O.
|
|
3137
|
-
type HeadCandidate = { key: string; journalEtag: string; intentVersion: 1 };
|
|
3203
|
+
type HeadCandidate = { key: string; journalEtag: string; intentVersion: 1 | null };
|
|
3138
3204
|
const headCandidates: HeadCandidate[] = [];
|
|
3139
3205
|
// Litter drain bucket — kept separate from `plan.toDelete` so the
|
|
3140
3206
|
// bulk-asymmetry breaker (which moves toDelete + headCandidates into
|
|
@@ -3177,6 +3243,41 @@ async function computeDeletePlan(
|
|
|
3177
3243
|
relativeKey.startsWith(`${root}/`),
|
|
3178
3244
|
);
|
|
3179
3245
|
if (!inScope) continue;
|
|
3246
|
+
|
|
3247
|
+
// A tombstoned entry has ALREADY had its disposition decided — by a scope
|
|
3248
|
+
// shrink, `hq sync narrow --apply`, a manual prune, or a pull-side
|
|
3249
|
+
// intentional-delete record. It must never be re-evaluated as a fresh
|
|
3250
|
+
// delete candidate.
|
|
3251
|
+
//
|
|
3252
|
+
// This matters most for `all` -> `shared`: `tombstoneEntry` deliberately
|
|
3253
|
+
// KEEPS `hash`, `direction` and `remoteEtag` (so a recovery flow can see
|
|
3254
|
+
// what was there), and the shrink removes the file locally. Every one of
|
|
3255
|
+
// those entries therefore presents as locally-absent, intent-less and
|
|
3256
|
+
// etag-current — which, now that authorization is etag-only, is an
|
|
3257
|
+
// instruction to delete another member's files out of the shared company
|
|
3258
|
+
// vault, for the crime of having narrowed one's own sync scope.
|
|
3259
|
+
//
|
|
3260
|
+
// Until now the ONLY thing standing in the way was `shouldSync` happening
|
|
3261
|
+
// to carry a `prefixSet` (see `createPushRunContext`); any run that
|
|
3262
|
+
// resolved no prefix set — a degraded scope lookup, a caller that does not
|
|
3263
|
+
// set one — would have walked straight through. That is far too load-
|
|
3264
|
+
// bearing for an implicit dependency, so the invariant is stated here
|
|
3265
|
+
// directly.
|
|
3266
|
+
//
|
|
3267
|
+
// `local-delete` is the ONE reason that must pass: the pull leg stamps it
|
|
3268
|
+
// as a deliberate producer hand-off ("the user removed this; push leg,
|
|
3269
|
+
// finish the job"), and skipping it would strand exactly the deletes this
|
|
3270
|
+
// change exists to enable. Every other reason — scope_shrink,
|
|
3271
|
+
// narrow_apply, manual, or an absent/unrecognised one — means "stop
|
|
3272
|
+
// tracking, do NOT touch the remote", so the test is written fail-closed:
|
|
3273
|
+
// only the explicit hand-off is let through.
|
|
3274
|
+
//
|
|
3275
|
+
// Skipped BEFORE `inScopeJournalEntries++` so these inflate neither the
|
|
3276
|
+
// numerator nor the denominator of the bulk-asymmetry ratio. Excluding
|
|
3277
|
+
// them from the denominator can only make the breaker MORE likely to trip,
|
|
3278
|
+
// which is the safe direction.
|
|
3279
|
+
if (isTombstone(entry) && entry.removedReason !== "local-delete") continue;
|
|
3280
|
+
|
|
3180
3281
|
inScopeJournalEntries++;
|
|
3181
3282
|
const localPath = localPathForVaultKey(syncRoot, relativeKey);
|
|
3182
3283
|
|
|
@@ -3256,13 +3357,78 @@ async function computeDeletePlan(
|
|
|
3256
3357
|
}
|
|
3257
3358
|
|
|
3258
3359
|
if (!hasCurrentLocalDeleteIntent(entry)) {
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3360
|
+
// A delete intent is minted ONLY by a live watcher, at the instant it
|
|
3361
|
+
// observes the unlink. Three of the four ways HQ syncs never construct a
|
|
3362
|
+
// watcher at all — outpost/agent boxes run a one-shot runner, `hq sync
|
|
3363
|
+
// now` is one-shot, and the desktop app drops `--event-push` (and with it
|
|
3364
|
+
// the whole TreeWatcher) whenever Instant-sync is off. On those surfaces
|
|
3365
|
+
// an intent can never exist, so requiring one made deletes structurally
|
|
3366
|
+
// impossible rather than merely gated. Files deleted while no watcher was
|
|
3367
|
+
// running also stranded permanently: the file is already gone, so no
|
|
3368
|
+
// watcher can ever retroactively witness it.
|
|
3369
|
+
//
|
|
3370
|
+
// `currency-gated` therefore authorizes on ETag currency alone. The
|
|
3371
|
+
// per-file question it answers is unchanged — "is the remote object still
|
|
3372
|
+
// exactly what this journal recorded, i.e. has no peer touched it since"
|
|
3373
|
+
// — and a mismatch still refuses (`stale-etag`) so a peer's newer work is
|
|
3374
|
+
// never destroyed.
|
|
3375
|
+
//
|
|
3376
|
+
// The whole-set question ("does my local mirror look catastrophically
|
|
3377
|
+
// empty") is NOT answered by the ETag, and must not be conflated with it:
|
|
3378
|
+
// in the steady state nobody has overwritten anything, so a moved hqRoot,
|
|
3379
|
+
// unmounted volume, fresh clone or partial restore presents mass-ENOENT
|
|
3380
|
+
// with perfectly matching ETags. That is exactly how the 2026-05-25
|
|
3381
|
+
// indigo vault mass-delete destroyed 559 objects. The bulk-asymmetry
|
|
3382
|
+
// breaker below is the guard for that class and is deliberately left
|
|
3383
|
+
// intact — these candidates are counted into `bulkCandidatePicks` on the
|
|
3384
|
+
// SAME line as intent-backed ones, so relaxing the per-file gate cannot
|
|
3385
|
+
// weaken the whole-set one. See policy `sync-delete-bulk-asymmetry-guard`
|
|
3386
|
+
// (hard) and workspace/reports/indigo-vault-mass-delete-debug.md.
|
|
3387
|
+
//
|
|
3388
|
+
// `owned-only` keeps requiring an intent: it is the documented rollback
|
|
3389
|
+
// knob, and an operator who selects it is asking for the stricter path.
|
|
3390
|
+
if (policy === "owned-only") {
|
|
3391
|
+
// Still counted into the breaker exactly as before: an owned-only run
|
|
3392
|
+
// whose mirror has gone missing must trip the whole-set guard even
|
|
3393
|
+
// though each individual key is refused for want of an intent.
|
|
3394
|
+
if (entry.direction === "up") {
|
|
3395
|
+
bulkCandidatePicks++;
|
|
3396
|
+
intentlessKeys.add(relativeKey);
|
|
3397
|
+
}
|
|
3398
|
+
plan.refusedStale.push({
|
|
3399
|
+
key: relativeKey,
|
|
3400
|
+
journalEtag: entry.remoteEtag ?? "<missing-delete-intent>",
|
|
3401
|
+
remoteEtag: "<not-checked>",
|
|
3402
|
+
reason: "missing-delete-intent",
|
|
3403
|
+
});
|
|
3404
|
+
continue;
|
|
3405
|
+
}
|
|
3406
|
+
if (policy === "currency-gated") {
|
|
3263
3407
|
bulkCandidatePicks++;
|
|
3264
3408
|
intentlessKeys.add(relativeKey);
|
|
3409
|
+
// No etag on record ⇒ nothing to compare ⇒ no authorization. Refused
|
|
3410
|
+
// for the same reason an intent-backed legacy entry is (below).
|
|
3411
|
+
if (!entry.remoteEtag) {
|
|
3412
|
+
plan.refusedStale.push({
|
|
3413
|
+
key: relativeKey,
|
|
3414
|
+
journalEtag: "<legacy-no-etag>",
|
|
3415
|
+
remoteEtag: "<unknown>",
|
|
3416
|
+
reason: "legacy-no-etag",
|
|
3417
|
+
});
|
|
3418
|
+
continue;
|
|
3419
|
+
}
|
|
3420
|
+
headCandidates.push({
|
|
3421
|
+
key: relativeKey,
|
|
3422
|
+
journalEtag: entry.remoteEtag,
|
|
3423
|
+
intentVersion: null,
|
|
3424
|
+
});
|
|
3425
|
+
continue;
|
|
3265
3426
|
}
|
|
3427
|
+
// policy === "all" is deliberately NOT relaxed here. It already skips the
|
|
3428
|
+
// bulk-asymmetry breaker, so letting it skip the intent gate too would
|
|
3429
|
+
// make it a wholly unguarded mass delete — a strictly larger blast radius
|
|
3430
|
+
// than this change is scoped to. It keeps refusing intent-less entries
|
|
3431
|
+
// exactly as before.
|
|
3266
3432
|
plan.refusedStale.push({
|
|
3267
3433
|
key: relativeKey,
|
|
3268
3434
|
journalEtag: entry.remoteEtag ?? "<missing-delete-intent>",
|
package/src/cli/sync.ts
CHANGED
|
@@ -238,7 +238,8 @@ export type SyncProgressEvent =
|
|
|
238
238
|
| "bulk-asymmetry"
|
|
239
239
|
| "divergent-local"
|
|
240
240
|
| "missing-delete-intent"
|
|
241
|
-
| "intent-changed"
|
|
241
|
+
| "intent-changed"
|
|
242
|
+
| "recreated-locally";
|
|
242
243
|
}
|
|
243
244
|
| {
|
|
244
245
|
/**
|
|
@@ -1471,9 +1472,12 @@ async function executeConflictItem(
|
|
|
1471
1472
|
item.localHash,
|
|
1472
1473
|
item.localSize,
|
|
1473
1474
|
"down",
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1475
|
+
localPath,
|
|
1476
|
+
{
|
|
1477
|
+
remoteEtag: remoteFile.etag,
|
|
1478
|
+
mtimeMs: item.localMtime.getTime(),
|
|
1479
|
+
kind: item.localSnapshot.kind === "symlink" ? "symlink" : "file",
|
|
1480
|
+
},
|
|
1477
1481
|
);
|
|
1478
1482
|
run.emit({ type: "reconciled", path: remoteFile.key, direction: "pull" });
|
|
1479
1483
|
counters.filesSkipped++;
|
|
@@ -1561,9 +1565,12 @@ async function executeConflictItem(
|
|
|
1561
1565
|
item.localHash,
|
|
1562
1566
|
item.localSize,
|
|
1563
1567
|
"down",
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1568
|
+
localPath,
|
|
1569
|
+
{
|
|
1570
|
+
remoteEtag: remoteFile.etag,
|
|
1571
|
+
mtimeMs: item.localMtime.getTime(),
|
|
1572
|
+
kind: item.localSnapshot.kind === "symlink" ? "symlink" : "file",
|
|
1573
|
+
},
|
|
1567
1574
|
);
|
|
1568
1575
|
// Journal-honesty: we recorded the remote etag so this conflict can't
|
|
1569
1576
|
// re-fire (#137), but the KEPT local copy diverges from that remote — it
|
|
@@ -1694,10 +1701,13 @@ async function downloadOne(
|
|
|
1694
1701
|
hash,
|
|
1695
1702
|
size,
|
|
1696
1703
|
"down",
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1704
|
+
localPath,
|
|
1705
|
+
{
|
|
1706
|
+
remoteEtag: remoteFile.etag,
|
|
1707
|
+
mtimeMs: localLstat.mtimeMs,
|
|
1708
|
+
...(createdBySub !== undefined ? { createdBySub } : {}),
|
|
1709
|
+
kind: isLocalSymlink ? "symlink" : "file",
|
|
1710
|
+
},
|
|
1701
1711
|
);
|
|
1702
1712
|
|
|
1703
1713
|
const priorEntry = getEntry(run.journal, remoteFile.key);
|
package/src/journal.test.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
readJournal,
|
|
17
17
|
writeJournal,
|
|
18
18
|
updateEntry,
|
|
19
|
+
PrematureJournalEntryError,
|
|
19
20
|
migrateToV2,
|
|
20
21
|
normalizeJournalKeys,
|
|
21
22
|
appendPullRecord,
|
|
@@ -208,12 +209,54 @@ describe("journal", () => {
|
|
|
208
209
|
describe("updateEntry", () => {
|
|
209
210
|
it("stamps lastSync and the per-file syncedAt", () => {
|
|
210
211
|
const j: SyncJournal = { version: "1", lastSync: "", files: {} };
|
|
211
|
-
|
|
212
|
+
const abs = path.join(stateDir, "foo.md");
|
|
213
|
+
fs.writeFileSync(abs, "x");
|
|
214
|
+
updateEntry(j, "foo.md", "hash", 10, "up", abs);
|
|
212
215
|
expect(j.files["foo.md"]?.hash).toBe("hash");
|
|
213
216
|
expect(j.files["foo.md"]?.direction).toBe("up");
|
|
214
217
|
expect(j.lastSync).not.toBe("");
|
|
215
218
|
expect(j.files["foo.md"]?.syncedAt).not.toBe("");
|
|
216
219
|
});
|
|
220
|
+
|
|
221
|
+
// Load-bearing once delete propagation authorizes on ETag currency alone:
|
|
222
|
+
// an entry whose file was never written is indistinguishable from "the
|
|
223
|
+
// user deleted this", and the next push would issue a remote DeleteObject
|
|
224
|
+
// for what was only premature bookkeeping.
|
|
225
|
+
it("refuses to journal a path that is not on disk", () => {
|
|
226
|
+
const j: SyncJournal = { version: "1", lastSync: "", files: {} };
|
|
227
|
+
const missing = path.join(stateDir, "never-written.md");
|
|
228
|
+
expect(() => updateEntry(j, "never-written.md", "hash", 10, "down", missing)).toThrow(
|
|
229
|
+
PrematureJournalEntryError,
|
|
230
|
+
);
|
|
231
|
+
expect(j.files).not.toHaveProperty("never-written.md");
|
|
232
|
+
expect(j.lastSync).toBe("");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("accepts a dangling symlink — the link itself is the synced object", () => {
|
|
236
|
+
const j: SyncJournal = { version: "1", lastSync: "", files: {} };
|
|
237
|
+
const link = path.join(stateDir, "dangling-link");
|
|
238
|
+
fs.symlinkSync(path.join(stateDir, "no-such-target"), link);
|
|
239
|
+
expect(() =>
|
|
240
|
+
updateEntry(j, "dangling-link", "hash", 0, "down", link, { kind: "symlink" }),
|
|
241
|
+
).not.toThrow();
|
|
242
|
+
expect(j.files["dangling-link"]?.kind).toBe("symlink");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Regression: `kind` and `createdBySub` were adjacent trailing optionals
|
|
246
|
+
// and two call sites passed kind into the createdBySub slot, leaving
|
|
247
|
+
// entry.kind unset. entry.kind gates delete-intent minting, so those
|
|
248
|
+
// entries could never be deleted. Named options make it unrepresentable.
|
|
249
|
+
it("records kind and createdBySub into their own fields", () => {
|
|
250
|
+
const j: SyncJournal = { version: "1", lastSync: "", files: {} };
|
|
251
|
+
const abs = path.join(stateDir, "typed.md");
|
|
252
|
+
fs.writeFileSync(abs, "x");
|
|
253
|
+
updateEntry(j, "typed.md", "hash", 1, "down", abs, {
|
|
254
|
+
kind: "file",
|
|
255
|
+
createdBySub: "sub-123",
|
|
256
|
+
});
|
|
257
|
+
expect(j.files["typed.md"]?.kind).toBe("file");
|
|
258
|
+
expect(j.files["typed.md"]?.createdBySub).toBe("sub-123");
|
|
259
|
+
});
|
|
217
260
|
});
|
|
218
261
|
|
|
219
262
|
// ── Journal v2 (US-005): pulls, tombstones, GC, migration ─────────────────
|
package/src/journal.ts
CHANGED
|
@@ -416,17 +416,82 @@ export function hashSymlinkTarget(target: string): string {
|
|
|
416
416
|
* via `hq files get` are pulled — `direction:"down"` — but ride the pin union
|
|
417
417
|
* in the caller's inclusion prefixSet, so they are likewise never pruned.)
|
|
418
418
|
*/
|
|
419
|
+
/**
|
|
420
|
+
* Thrown when a caller tries to journal a path that is not on disk.
|
|
421
|
+
*
|
|
422
|
+
* Deliberately loud rather than a silent skip: a caller reaching this has a
|
|
423
|
+
* real ordering bug, and swallowing it would reintroduce exactly the silent
|
|
424
|
+
* drift this guard exists to prevent.
|
|
425
|
+
*/
|
|
426
|
+
export class PrematureJournalEntryError extends Error {
|
|
427
|
+
constructor(readonly relativePath: string, readonly absolutePath: string) {
|
|
428
|
+
super(
|
|
429
|
+
`refusing to journal '${relativePath}': no file at '${absolutePath}'. ` +
|
|
430
|
+
`A journal entry must be written only AFTER its file is on disk.`,
|
|
431
|
+
);
|
|
432
|
+
this.name = "PrematureJournalEntryError";
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Optional fields of a journal entry.
|
|
438
|
+
*
|
|
439
|
+
* These are an OBJECT rather than trailing positional parameters because they
|
|
440
|
+
* were four adjacent optionals, two of them `string`, and callers drifted:
|
|
441
|
+
* two conflict-resolution sites in `cli/sync.ts` passed their `"file" |
|
|
442
|
+
* "symlink"` value into the `createdBySub` slot, so `entry.kind` was silently
|
|
443
|
+
* never set on any conflict-resolved entry. That is not cosmetic — `entry.kind`
|
|
444
|
+
* gates delete-intent minting (`markLocalDeleteIntent` requires it to match,
|
|
445
|
+
* and the watcher skips any entry without one), so a file that had ever been
|
|
446
|
+
* through conflict resolution could never be deleted, even with a live watcher.
|
|
447
|
+
* Named fields make that class of mistake unrepresentable.
|
|
448
|
+
*/
|
|
449
|
+
export interface UpdateEntryOptions {
|
|
450
|
+
remoteEtag?: string;
|
|
451
|
+
mtimeMs?: number;
|
|
452
|
+
/** Object's `created-by-sub` S3 metadata. Download path only. */
|
|
453
|
+
createdBySub?: string;
|
|
454
|
+
kind?: "file" | "symlink";
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Record a journal entry for a file that is CONFIRMED present on disk.
|
|
459
|
+
*
|
|
460
|
+
* `verifyAbsolutePath` is required, and the entry is written only after an
|
|
461
|
+
* `lstat` proves the file exists. This is a load-bearing invariant, not a
|
|
462
|
+
* defensive nicety: a journal entry says "this key was synced and the local
|
|
463
|
+
* copy is at this hash". Once delete propagation authorizes on ETag currency
|
|
464
|
+
* alone (no watcher-minted intent), an entry whose file was never actually
|
|
465
|
+
* written becomes indistinguishable from "the user deleted this file" — and
|
|
466
|
+
* the next push issues a remote DeleteObject for a file that was only ever
|
|
467
|
+
* premature bookkeeping.
|
|
468
|
+
*
|
|
469
|
+
* Every existing caller already had a `fs.lstatSync` in hand and passed values
|
|
470
|
+
* derived from it, so this makes an existing convention structural. The point
|
|
471
|
+
* is that a FUTURE caller cannot get it wrong: with the check inside this
|
|
472
|
+
* function there is no longer a way to add a call site that records an entry
|
|
473
|
+
* for a file that is not there.
|
|
474
|
+
*
|
|
475
|
+
* `lstat` (not `stat`) so a dangling symlink still counts as present — the
|
|
476
|
+
* link itself is the synced object, and its target may legitimately be absent.
|
|
477
|
+
*
|
|
478
|
+
* @throws PrematureJournalEntryError when nothing exists at `verifyAbsolutePath`.
|
|
479
|
+
*/
|
|
419
480
|
export function updateEntry(
|
|
420
481
|
journal: SyncJournal,
|
|
421
482
|
relativePath: string,
|
|
422
483
|
hash: string,
|
|
423
484
|
size: number,
|
|
424
485
|
direction: "up" | "down",
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
createdBySub?: string,
|
|
428
|
-
kind?: "file" | "symlink",
|
|
486
|
+
verifyAbsolutePath: string,
|
|
487
|
+
opts: UpdateEntryOptions = {},
|
|
429
488
|
): void {
|
|
489
|
+
try {
|
|
490
|
+
fs.lstatSync(verifyAbsolutePath);
|
|
491
|
+
} catch {
|
|
492
|
+
throw new PrematureJournalEntryError(relativePath, verifyAbsolutePath);
|
|
493
|
+
}
|
|
494
|
+
const { remoteEtag, mtimeMs, createdBySub, kind } = opts;
|
|
430
495
|
const entry: JournalEntry = {
|
|
431
496
|
hash,
|
|
432
497
|
size,
|