@indigoai-us/hq-cloud 6.0.1 → 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 +96 -2
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +76 -21
- package/dist/cli/share.test.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/share.test.ts +94 -22
- package/src/cli/share.ts +101 -2
package/src/cli/share.test.ts
CHANGED
|
@@ -1132,14 +1132,12 @@ describe("share", () => {
|
|
|
1132
1132
|
}),
|
|
1133
1133
|
);
|
|
1134
1134
|
|
|
1135
|
-
//
|
|
1136
|
-
//
|
|
1137
|
-
//
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
size: 1054,
|
|
1142
|
-
});
|
|
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.)
|
|
1143
1141
|
|
|
1144
1142
|
const personalCtx = makeEntityContext({
|
|
1145
1143
|
uid: "prs_01HASSAANTESTUSER",
|
|
@@ -1970,14 +1968,23 @@ describe("share", () => {
|
|
|
1970
1968
|
expect(uploadFile).not.toHaveBeenCalled();
|
|
1971
1969
|
});
|
|
1972
1970
|
|
|
1973
|
-
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.
|
|
1974
1985
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1975
1986
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1976
|
-
|
|
1977
|
-
// into the journal in a prior buggy version. Locally missing (user
|
|
1978
|
-
// already deleted it). The regular delete plan must NOT issue a
|
|
1979
|
-
// DeleteObject — that's the dedicated reconcile command's job, and
|
|
1980
|
-
// a sync should not accidentally race a user reviewing the mirror.
|
|
1987
|
+
|
|
1981
1988
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1982
1989
|
fs.writeFileSync(
|
|
1983
1990
|
journalPath,
|
|
@@ -1999,7 +2006,10 @@ describe("share", () => {
|
|
|
1999
2006
|
}),
|
|
2000
2007
|
);
|
|
2001
2008
|
|
|
2002
|
-
// 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.
|
|
2003
2013
|
vi.mocked(headRemoteFile).mockResolvedValue({
|
|
2004
2014
|
lastModified: new Date(),
|
|
2005
2015
|
etag: '"regular-etag"',
|
|
@@ -2015,19 +2025,81 @@ describe("share", () => {
|
|
|
2015
2025
|
propagateDeletes: true,
|
|
2016
2026
|
});
|
|
2017
2027
|
|
|
2018
|
-
|
|
2019
|
-
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);
|
|
2020
2031
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "regular.md");
|
|
2021
|
-
expect(deleteRemoteFile).
|
|
2032
|
+
expect(deleteRemoteFile).toHaveBeenCalledWith(
|
|
2022
2033
|
expect.anything(),
|
|
2023
|
-
|
|
2034
|
+
"CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md",
|
|
2024
2035
|
);
|
|
2025
|
-
//
|
|
2026
|
-
// sweeps it once the user explicitly opts in.
|
|
2036
|
+
// Both journal entries are removed by the delete loop's removeEntry call.
|
|
2027
2037
|
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2028
2038
|
expect(
|
|
2029
2039
|
journal.files["CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md"],
|
|
2030
|
-
).
|
|
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
|
+
}
|
|
2031
2103
|
});
|
|
2032
2104
|
|
|
2033
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
|
}
|
|
@@ -1799,12 +1857,21 @@ async function computeDeletePlan(
|
|
|
1799
1857
|
// and the journal-mutation buckets are already settled before any I/O.
|
|
1800
1858
|
type HeadCandidate = { key: string; journalEtag: string };
|
|
1801
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[] = [];
|
|
1802
1868
|
// Bulk-asymmetry tracking: count every in-scope journal entry (denominator)
|
|
1803
1869
|
// and every entry that would have been a delete-candidate before the guard
|
|
1804
1870
|
// (numerator). Numerator = headCandidates + owned-only/all toDelete picks +
|
|
1805
1871
|
// legacy-no-etag refusals. We do NOT count "ENOENT but ignore-filtered" or
|
|
1806
1872
|
// "ENOENT but ephemeral" — those drop out of the plan entirely on their own
|
|
1807
|
-
// 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.
|
|
1808
1875
|
let inScopeJournalEntries = 0;
|
|
1809
1876
|
let bulkCandidatePicks = 0;
|
|
1810
1877
|
|
|
@@ -1834,9 +1901,28 @@ async function computeDeletePlan(
|
|
|
1834
1901
|
}
|
|
1835
1902
|
}
|
|
1836
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
|
+
|
|
1837
1920
|
if (!shouldSync(localPath, false) && !shouldSync(localPath, true)) continue;
|
|
1838
1921
|
// Ephemeral artifacts (conflict mirrors) never propagate-delete via the
|
|
1839
|
-
// 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.
|
|
1840
1926
|
if (isEphemeralPath(relativeKey)) continue;
|
|
1841
1927
|
|
|
1842
1928
|
if (policy === "all") {
|
|
@@ -1907,6 +1993,12 @@ async function computeDeletePlan(
|
|
|
1907
1993
|
ratio: bulkCandidatePicks / inScopeJournalEntries,
|
|
1908
1994
|
samplePaths,
|
|
1909
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);
|
|
1910
2002
|
return plan;
|
|
1911
2003
|
}
|
|
1912
2004
|
|
|
@@ -1944,6 +2036,13 @@ async function computeDeletePlan(
|
|
|
1944
2036
|
}
|
|
1945
2037
|
}
|
|
1946
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
|
+
|
|
1947
2046
|
return plan;
|
|
1948
2047
|
}
|
|
1949
2048
|
|