@indigoai-us/hq-cloud 5.30.0 → 5.32.0
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/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +69 -2
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +292 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +26 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +105 -8
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +210 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +37 -2
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +28 -5
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +137 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/describe-error.d.ts +21 -0
- package/dist/lib/describe-error.d.ts.map +1 -0
- package/dist/lib/describe-error.js +53 -0
- package/dist/lib/describe-error.js.map +1 -0
- package/dist/lib/describe-error.test.d.ts +2 -0
- package/dist/lib/describe-error.test.d.ts.map +1 -0
- package/dist/lib/describe-error.test.js +89 -0
- package/dist/lib/describe-error.test.js.map +1 -0
- package/dist/personal-vault.d.ts +63 -7
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +112 -8
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.d.ts +14 -0
- package/dist/personal-vault.test.d.ts.map +1 -0
- package/dist/personal-vault.test.js +191 -0
- package/dist/personal-vault.test.js.map +1 -0
- package/dist/s3.d.ts +12 -1
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +11 -1
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +24 -0
- package/dist/s3.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +364 -0
- package/src/bin/sync-runner.ts +73 -2
- package/src/cli/share.test.ts +243 -0
- package/src/cli/share.ts +142 -8
- package/src/cli/sync.test.ts +152 -0
- package/src/cli/sync.ts +68 -5
- package/src/index.ts +3 -0
- package/src/lib/describe-error.test.ts +100 -0
- package/src/lib/describe-error.ts +58 -0
- package/src/personal-vault.test.ts +231 -0
- package/src/personal-vault.ts +134 -8
- package/src/s3.test.ts +30 -0
- package/src/s3.ts +12 -2
package/src/cli/share.test.ts
CHANGED
|
@@ -1799,6 +1799,249 @@ describe("share", () => {
|
|
|
1799
1799
|
// exclusion list itself is enforced by the runner (sync-runner.ts) by only
|
|
1800
1800
|
// passing in the allowed top-level directories — share() trusts its
|
|
1801
1801
|
// `paths` input.
|
|
1802
|
+
|
|
1803
|
+
// ── Decommission prefixes ──────────────────────────────────────────────────
|
|
1804
|
+
//
|
|
1805
|
+
// `decommissionPrefixes` is the "company was promoted to its own team
|
|
1806
|
+
// bucket, sweep its orphaned keys from this bucket" escape hatch. Unlike
|
|
1807
|
+
// `propagateDeletes`, it does NOT require the local file to be missing —
|
|
1808
|
+
// the caller asserts these keys no longer belong here regardless of
|
|
1809
|
+
// local state. Honors the same `propagateDeletePolicy` safety property.
|
|
1810
|
+
|
|
1811
|
+
it("decommissionPrefixes: deletes journal entries under the prefix even when the local file is still present", async () => {
|
|
1812
|
+
// Personal-bucket journal recording a key under companies/foo/. The
|
|
1813
|
+
// local file is intentionally LEFT on disk to prove decommission
|
|
1814
|
+
// doesn't depend on local-absence — the team bucket is now the
|
|
1815
|
+
// canonical home but on-disk content is unchanged.
|
|
1816
|
+
fs.mkdirSync(path.join(tmpDir, "companies", "foo"), { recursive: true });
|
|
1817
|
+
fs.writeFileSync(path.join(tmpDir, "companies", "foo", "notes.md"), "still here");
|
|
1818
|
+
fs.mkdirSync(path.join(tmpDir, "knowledge"), { recursive: true });
|
|
1819
|
+
fs.writeFileSync(path.join(tmpDir, "knowledge", "x.md"), "knowledge");
|
|
1820
|
+
|
|
1821
|
+
const personalJournalPath = path.join(stateDir, "sync-journal.personal.json");
|
|
1822
|
+
fs.writeFileSync(
|
|
1823
|
+
personalJournalPath,
|
|
1824
|
+
JSON.stringify({
|
|
1825
|
+
version: "1",
|
|
1826
|
+
lastSync: new Date().toISOString(),
|
|
1827
|
+
files: {
|
|
1828
|
+
"companies/foo/notes.md": {
|
|
1829
|
+
hash: "h", size: 11, syncedAt: new Date().toISOString(),
|
|
1830
|
+
direction: "up",
|
|
1831
|
+
},
|
|
1832
|
+
// Sibling out of decommission scope; must survive untouched.
|
|
1833
|
+
"knowledge/x.md": {
|
|
1834
|
+
hash: "k", size: 9, syncedAt: new Date().toISOString(),
|
|
1835
|
+
direction: "up",
|
|
1836
|
+
},
|
|
1837
|
+
},
|
|
1838
|
+
}),
|
|
1839
|
+
);
|
|
1840
|
+
|
|
1841
|
+
const result = await share({
|
|
1842
|
+
paths: [path.join(tmpDir, "knowledge")],
|
|
1843
|
+
company: "acme",
|
|
1844
|
+
vaultConfig: mockConfig,
|
|
1845
|
+
hqRoot: tmpDir,
|
|
1846
|
+
personalMode: true,
|
|
1847
|
+
journalSlug: "personal",
|
|
1848
|
+
skipUnchanged: true,
|
|
1849
|
+
// Pin owned-only — this test asserts the direction-of-origin gate
|
|
1850
|
+
// applies symmetrically to decommission. Once the default flips to
|
|
1851
|
+
// `currency-gated` (scheduled for 5.25+), the implicit-default form
|
|
1852
|
+
// would force a HEAD call we don't want to mock here.
|
|
1853
|
+
propagateDeletePolicy: "owned-only",
|
|
1854
|
+
decommissionPrefixes: ["companies/foo"],
|
|
1855
|
+
});
|
|
1856
|
+
|
|
1857
|
+
expect(result.filesDeleted).toBe(1);
|
|
1858
|
+
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "companies/foo/notes.md");
|
|
1859
|
+
expect(deleteRemoteFile).not.toHaveBeenCalledWith(expect.anything(), "knowledge/x.md");
|
|
1860
|
+
// Local file remains on disk — decommission is a remote-only sweep.
|
|
1861
|
+
expect(fs.existsSync(path.join(tmpDir, "companies", "foo", "notes.md"))).toBe(true);
|
|
1862
|
+
// Journal entry pruned in lockstep with the remote delete.
|
|
1863
|
+
const journalAfter = JSON.parse(fs.readFileSync(personalJournalPath, "utf-8"));
|
|
1864
|
+
expect(journalAfter.files["companies/foo/notes.md"]).toBeUndefined();
|
|
1865
|
+
expect(journalAfter.files["knowledge/x.md"]).toBeDefined();
|
|
1866
|
+
});
|
|
1867
|
+
|
|
1868
|
+
it("decommissionPrefixes: under owned-only policy, skips direction:'down' entries", async () => {
|
|
1869
|
+
// The orphan was pulled from another machine, never uploaded by this
|
|
1870
|
+
// one. owned-only refuses to remotely erase another machine's upload
|
|
1871
|
+
// even if the prefix list claims it. Mirrors propagateDeletes's
|
|
1872
|
+
// owned-only contract — see test "propagateDeletes: under owned-only".
|
|
1873
|
+
fs.mkdirSync(path.join(tmpDir, "companies", "foo"), { recursive: true });
|
|
1874
|
+
const personalJournalPath = path.join(stateDir, "sync-journal.personal.json");
|
|
1875
|
+
fs.writeFileSync(
|
|
1876
|
+
personalJournalPath,
|
|
1877
|
+
JSON.stringify({
|
|
1878
|
+
version: "1",
|
|
1879
|
+
lastSync: new Date().toISOString(),
|
|
1880
|
+
files: {
|
|
1881
|
+
"companies/foo/from-peer.md": {
|
|
1882
|
+
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
1883
|
+
direction: "down",
|
|
1884
|
+
},
|
|
1885
|
+
},
|
|
1886
|
+
}),
|
|
1887
|
+
);
|
|
1888
|
+
|
|
1889
|
+
const result = await share({
|
|
1890
|
+
paths: [],
|
|
1891
|
+
company: "acme",
|
|
1892
|
+
vaultConfig: mockConfig,
|
|
1893
|
+
hqRoot: tmpDir,
|
|
1894
|
+
personalMode: true,
|
|
1895
|
+
journalSlug: "personal",
|
|
1896
|
+
skipUnchanged: true,
|
|
1897
|
+
propagateDeletePolicy: "owned-only",
|
|
1898
|
+
decommissionPrefixes: ["companies/foo"],
|
|
1899
|
+
});
|
|
1900
|
+
|
|
1901
|
+
expect(result.filesDeleted).toBe(0);
|
|
1902
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
1903
|
+
// Journal entry preserved — the pulled-from-peer record stays so a
|
|
1904
|
+
// future pull can still reconcile it correctly.
|
|
1905
|
+
const journalAfter = JSON.parse(fs.readFileSync(personalJournalPath, "utf-8"));
|
|
1906
|
+
expect(journalAfter.files["companies/foo/from-peer.md"]).toBeDefined();
|
|
1907
|
+
});
|
|
1908
|
+
|
|
1909
|
+
it("decommissionPrefixes: dedupes with propagateDeletes plan — a key in both still emits one DeleteObject", async () => {
|
|
1910
|
+
// If the local file is ALSO missing under the prefix, the same key
|
|
1911
|
+
// would be eligible via both propagateDeletes (local gone) and
|
|
1912
|
+
// decommissionPrefixes (prefix match). The dedup guarantee is that
|
|
1913
|
+
// we delete once, not twice.
|
|
1914
|
+
fs.mkdirSync(path.join(tmpDir, "companies", "foo"), { recursive: true });
|
|
1915
|
+
// Note: NO file at companies/foo/notes.md — local-absence is what
|
|
1916
|
+
// makes propagateDeletes plan it; the prefix is what makes
|
|
1917
|
+
// decommission plan it. The Set-based dedup must collapse them.
|
|
1918
|
+
const personalJournalPath = path.join(stateDir, "sync-journal.personal.json");
|
|
1919
|
+
fs.writeFileSync(
|
|
1920
|
+
personalJournalPath,
|
|
1921
|
+
JSON.stringify({
|
|
1922
|
+
version: "1",
|
|
1923
|
+
lastSync: new Date().toISOString(),
|
|
1924
|
+
files: {
|
|
1925
|
+
"companies/foo/notes.md": {
|
|
1926
|
+
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
1927
|
+
direction: "up",
|
|
1928
|
+
},
|
|
1929
|
+
},
|
|
1930
|
+
}),
|
|
1931
|
+
);
|
|
1932
|
+
|
|
1933
|
+
const result = await share({
|
|
1934
|
+
paths: [path.join(tmpDir, "companies", "foo")],
|
|
1935
|
+
company: "acme",
|
|
1936
|
+
vaultConfig: mockConfig,
|
|
1937
|
+
hqRoot: tmpDir,
|
|
1938
|
+
personalMode: true,
|
|
1939
|
+
journalSlug: "personal",
|
|
1940
|
+
skipUnchanged: true,
|
|
1941
|
+
propagateDeletes: true,
|
|
1942
|
+
// Use "all" here so propagateDeletes plans the (already-missing)
|
|
1943
|
+
// key regardless of its direction — the dedup assertion is what
|
|
1944
|
+
// this test exists to pin.
|
|
1945
|
+
propagateDeletePolicy: "all",
|
|
1946
|
+
decommissionPrefixes: ["companies/foo"],
|
|
1947
|
+
});
|
|
1948
|
+
|
|
1949
|
+
expect(result.filesDeleted).toBe(1);
|
|
1950
|
+
expect(deleteRemoteFile).toHaveBeenCalledTimes(1);
|
|
1951
|
+
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "companies/foo/notes.md");
|
|
1952
|
+
});
|
|
1953
|
+
|
|
1954
|
+
it("decommissionPrefixes: under currency-gated policy, skips direction:'down' entries (same as owned-only)", async () => {
|
|
1955
|
+
// currency-gated gates `propagateDeletes` on per-file HEAD currency
|
|
1956
|
+
// proof. Decommission is unconditional by design — no HEAD check
|
|
1957
|
+
// adds value — but we still want the direction-of-origin safety net
|
|
1958
|
+
// so a peer-written entry isn't blasted away on the peer's behalf.
|
|
1959
|
+
// Asserts `computeDecommissionPlan` collapses currency-gated to
|
|
1960
|
+
// owned-only semantics (the requireOwned branch).
|
|
1961
|
+
fs.mkdirSync(path.join(tmpDir, "companies", "foo"), { recursive: true });
|
|
1962
|
+
const personalJournalPath = path.join(stateDir, "sync-journal.personal.json");
|
|
1963
|
+
fs.writeFileSync(
|
|
1964
|
+
personalJournalPath,
|
|
1965
|
+
JSON.stringify({
|
|
1966
|
+
version: "1",
|
|
1967
|
+
lastSync: new Date().toISOString(),
|
|
1968
|
+
files: {
|
|
1969
|
+
"companies/foo/from-peer.md": {
|
|
1970
|
+
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
1971
|
+
direction: "down",
|
|
1972
|
+
remoteEtag: "abc",
|
|
1973
|
+
},
|
|
1974
|
+
},
|
|
1975
|
+
}),
|
|
1976
|
+
);
|
|
1977
|
+
|
|
1978
|
+
const result = await share({
|
|
1979
|
+
paths: [],
|
|
1980
|
+
company: "acme",
|
|
1981
|
+
vaultConfig: mockConfig,
|
|
1982
|
+
hqRoot: tmpDir,
|
|
1983
|
+
personalMode: true,
|
|
1984
|
+
journalSlug: "personal",
|
|
1985
|
+
skipUnchanged: true,
|
|
1986
|
+
propagateDeletePolicy: "currency-gated",
|
|
1987
|
+
decommissionPrefixes: ["companies/foo"],
|
|
1988
|
+
});
|
|
1989
|
+
|
|
1990
|
+
expect(result.filesDeleted).toBe(0);
|
|
1991
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
1992
|
+
});
|
|
1993
|
+
|
|
1994
|
+
it("decommissionPrefixes: yields to toTombstone (key already 404 on remote) — single 'deleted' event, no double-count", async () => {
|
|
1995
|
+
// Regression for an audit finding: a key in BOTH decommissionPlan
|
|
1996
|
+
// AND deletePlan.toTombstone (because the remote was already 404 at
|
|
1997
|
+
// HEAD time) used to double-emit a "deleted" event (one from the
|
|
1998
|
+
// DeleteObject loop, one from the tombstone loop) and increment
|
|
1999
|
+
// both filesDeleted and filesTombstoned. Fix: decommissionPlan
|
|
2000
|
+
// dedups against toTombstone — the tombstone loop handles it
|
|
2001
|
+
// (journal removal, no S3 call), decommission skips it.
|
|
2002
|
+
fs.mkdirSync(path.join(tmpDir, "companies", "foo"), { recursive: true });
|
|
2003
|
+
// No local file → propagateDeletes would normally plan it. With
|
|
2004
|
+
// headRemoteFile mocked to return null (the default in beforeEach),
|
|
2005
|
+
// currency-gated classifies as toTombstone (remote already 404).
|
|
2006
|
+
const personalJournalPath = path.join(stateDir, "sync-journal.personal.json");
|
|
2007
|
+
fs.writeFileSync(
|
|
2008
|
+
personalJournalPath,
|
|
2009
|
+
JSON.stringify({
|
|
2010
|
+
version: "1",
|
|
2011
|
+
lastSync: new Date().toISOString(),
|
|
2012
|
+
files: {
|
|
2013
|
+
"companies/foo/gone.md": {
|
|
2014
|
+
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
2015
|
+
direction: "up",
|
|
2016
|
+
remoteEtag: "abc",
|
|
2017
|
+
},
|
|
2018
|
+
},
|
|
2019
|
+
}),
|
|
2020
|
+
);
|
|
2021
|
+
|
|
2022
|
+
const result = await share({
|
|
2023
|
+
paths: [path.join(tmpDir, "companies", "foo")],
|
|
2024
|
+
company: "acme",
|
|
2025
|
+
vaultConfig: mockConfig,
|
|
2026
|
+
hqRoot: tmpDir,
|
|
2027
|
+
personalMode: true,
|
|
2028
|
+
journalSlug: "personal",
|
|
2029
|
+
skipUnchanged: true,
|
|
2030
|
+
propagateDeletes: true,
|
|
2031
|
+
propagateDeletePolicy: "currency-gated",
|
|
2032
|
+
decommissionPrefixes: ["companies/foo"],
|
|
2033
|
+
});
|
|
2034
|
+
|
|
2035
|
+
// No DeleteObject — the tombstone bucket handled it (remote is 404).
|
|
2036
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
2037
|
+
// Exactly one journal-removal counted (tombstone), not two.
|
|
2038
|
+
expect(result.filesTombstoned).toBe(1);
|
|
2039
|
+
expect(result.filesDeleted).toBe(0);
|
|
2040
|
+
// Journal entry is gone.
|
|
2041
|
+
const journalAfter = JSON.parse(fs.readFileSync(personalJournalPath, "utf-8"));
|
|
2042
|
+
expect(journalAfter.files["companies/foo/gone.md"]).toBeUndefined();
|
|
2043
|
+
});
|
|
2044
|
+
|
|
1802
2045
|
describe("personalMode", () => {
|
|
1803
2046
|
it("personalMode=true keys files hq-root-relative, not companies/{slug}/-relative", async () => {
|
|
1804
2047
|
fs.mkdirSync(path.join(tmpDir, ".claude", "skills"), { recursive: true });
|
package/src/cli/share.ts
CHANGED
|
@@ -331,6 +331,32 @@ export interface ShareOptions {
|
|
|
331
331
|
* next push to erase it.
|
|
332
332
|
*/
|
|
333
333
|
propagateDeletePolicy?: "currency-gated" | "owned-only" | "all";
|
|
334
|
+
/**
|
|
335
|
+
* Hq-root-relative key prefixes whose journal entries should be
|
|
336
|
+
* unconditionally decommissioned from the remote bucket and journal,
|
|
337
|
+
* independent of whether the local file is present. Each prefix matches
|
|
338
|
+
* its exact-key form (e.g. "companies/foo") AND any descendant key
|
|
339
|
+
* (e.g. "companies/foo/knowledge/notes.md") — same prefix semantics as
|
|
340
|
+
* `propagateDeletes`'s scope roots.
|
|
341
|
+
*
|
|
342
|
+
* Use case: a company that previously synced to the operator's personal
|
|
343
|
+
* bucket has been promoted to its own team bucket (`/designate-team`).
|
|
344
|
+
* Its keys at `companies/{slug}/...` in the personal bucket are now
|
|
345
|
+
* orphans — the on-disk files still exist (the team bucket is the new
|
|
346
|
+
* canonical home), so the standard `propagateDeletes` gate ("local file
|
|
347
|
+
* missing") never fires. This option asserts "these objects no longer
|
|
348
|
+
* belong in THIS bucket regardless of local state" and uses the same
|
|
349
|
+
* DeleteObject + journal-removal path as `propagateDeletes`.
|
|
350
|
+
*
|
|
351
|
+
* Honors `propagateDeletePolicy` — `"owned-only"` (default) only
|
|
352
|
+
* decommissions journal entries with `direction === "up"`, so a
|
|
353
|
+
* misconfigured caller never erases content pulled from elsewhere.
|
|
354
|
+
*
|
|
355
|
+
* Independent of `propagateDeletes`: callers can opt into decommission
|
|
356
|
+
* without enabling general delete propagation. In practice the runner
|
|
357
|
+
* sets both for the personal slot.
|
|
358
|
+
*/
|
|
359
|
+
decommissionPrefixes?: string[];
|
|
334
360
|
/**
|
|
335
361
|
* Identity stamped onto each uploaded object's S3 user metadata
|
|
336
362
|
* (`created-by`, `created-by-sub`, `created-at`). The hq-console vault UI
|
|
@@ -548,6 +574,43 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
548
574
|
)
|
|
549
575
|
: { toDelete: [], toTombstone: [], refusedStale: [] };
|
|
550
576
|
|
|
577
|
+
// Decommission plan: journal entries under explicit prefixes the caller
|
|
578
|
+
// has asserted no longer belong in this bucket (typically a promoted
|
|
579
|
+
// company's `companies/{slug}/` keys in the personal bucket). Independent
|
|
580
|
+
// of `propagateDeletes` and DOES NOT require the local file to be missing
|
|
581
|
+
// — the caller is making a stronger claim than "local says delete this".
|
|
582
|
+
// Honors the same owned-only safety policy so a misconfigured caller
|
|
583
|
+
// can never erase content the journal records as pulled from elsewhere.
|
|
584
|
+
// Dedupes against `deletePlan` so a key in both plans is only processed
|
|
585
|
+
// once (DeleteObject is idempotent on S3 but the journal-write would
|
|
586
|
+
// race a no-op pass through the loop body).
|
|
587
|
+
const decommissionPlan =
|
|
588
|
+
(options.decommissionPrefixes ?? []).length > 0
|
|
589
|
+
? computeDecommissionPlan(
|
|
590
|
+
journal,
|
|
591
|
+
options.decommissionPrefixes ?? [],
|
|
592
|
+
propagateDeletePolicy,
|
|
593
|
+
// Dedup against `toDelete` (decommission and propagate-delete
|
|
594
|
+
// would both issue DeleteObject — single call wins) and against
|
|
595
|
+
// `toTombstone` (the remote is already 404; the tombstone loop
|
|
596
|
+
// drops the journal entry without a network call — decommission
|
|
597
|
+
// yields, both for efficiency and to avoid emitting two
|
|
598
|
+
// "deleted" events for the same key).
|
|
599
|
+
//
|
|
600
|
+
// We do NOT dedup against `refusedStale`. A key whose remote
|
|
601
|
+
// ETag drifted (peer wrote a newer version) but which decommission
|
|
602
|
+
// claims should still be removed — the caller has asserted this
|
|
603
|
+
// key doesn't belong in this bucket regardless of peer activity.
|
|
604
|
+
// Under owned-only (default) `computeDecommissionPlan`'s
|
|
605
|
+
// direction:'up' filter already excludes peer-written entries;
|
|
606
|
+
// under policy:'all' the caller has opted out of that safety
|
|
607
|
+
// anyway. The refusedStale loop below filters out keys we're
|
|
608
|
+
// about to decommission to avoid emitting a spurious "kept on
|
|
609
|
+
// remote" event for content we're deleting.
|
|
610
|
+
new Set([...deletePlan.toDelete, ...deletePlan.toTombstone]),
|
|
611
|
+
)
|
|
612
|
+
: [];
|
|
613
|
+
|
|
551
614
|
emit({
|
|
552
615
|
type: "plan",
|
|
553
616
|
// share() is push-only; pull counts are sourced from sync()'s plan event.
|
|
@@ -562,8 +625,11 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
562
625
|
// Reported count is the deletes we're actually going to issue — does NOT
|
|
563
626
|
// include tombstones (no S3 call) or refused-stale (no journal change).
|
|
564
627
|
// Refusals surface as their own event stream so consumers that care can
|
|
565
|
-
// render a "kept on remote: N" line separately.
|
|
566
|
-
|
|
628
|
+
// render a "kept on remote: N" line separately. `decommissionPlan` adds
|
|
629
|
+
// to this count because every decommission entry IS an issued
|
|
630
|
+
// DeleteObject (different intent than propagate-deletes, same network
|
|
631
|
+
// effect).
|
|
632
|
+
filesToDelete: deletePlan.toDelete.length + decommissionPlan.length,
|
|
567
633
|
});
|
|
568
634
|
|
|
569
635
|
// Stage 2: execute. Skip items pre-classified as no-ops, then for each
|
|
@@ -703,11 +769,16 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
703
769
|
|
|
704
770
|
// Stage 3: propagate deletes. Three buckets, three actions:
|
|
705
771
|
//
|
|
706
|
-
// 1. `toDelete`
|
|
707
|
-
//
|
|
708
|
-
//
|
|
709
|
-
//
|
|
710
|
-
//
|
|
772
|
+
// 1. `toDelete` (PLUS `decommissionPlan` concatenated in) — write a
|
|
773
|
+
// delete-marker (versioning is enabled on the bucket so the delete
|
|
774
|
+
// is soft and prior versions remain recoverable) and remove the
|
|
775
|
+
// journal entry so the next sync sees the key as truly gone on
|
|
776
|
+
// this machine. A failed DeleteObject leaves both the journal
|
|
777
|
+
// entry and remote object intact — the next run retries.
|
|
778
|
+
// Decommission keys join this bucket because their effect is
|
|
779
|
+
// identical (DeleteObject + journal removal); the difference is
|
|
780
|
+
// intent only, and the dedupe at plan-computation time ensures we
|
|
781
|
+
// don't double-issue for a key both plans matched.
|
|
711
782
|
//
|
|
712
783
|
// 2. `toTombstone` — the remote was 404 at HEAD time (cleaned up out
|
|
713
784
|
// of band, e.g. someone hand-deleted via console). No DeleteObject
|
|
@@ -722,7 +793,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
722
793
|
// leg of `sync now` re-pulls naturally via the existing
|
|
723
794
|
// `hasRemoteChanged` path. Emit a dedicated event so UIs can
|
|
724
795
|
// surface the refusal without inferring it from absence.
|
|
725
|
-
for (const relativePath of deletePlan.toDelete) {
|
|
796
|
+
for (const relativePath of [...deletePlan.toDelete, ...decommissionPlan]) {
|
|
726
797
|
if (vaultConfig && isExpiringSoon(ctx.expiresAt)) {
|
|
727
798
|
ctx = await refreshEntityContext(companyRef, vaultConfig);
|
|
728
799
|
}
|
|
@@ -757,7 +828,15 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
757
828
|
message: "tombstone (remote already 404)",
|
|
758
829
|
});
|
|
759
830
|
}
|
|
831
|
+
// Decommission overrides refusedStale: a key whose peer drifted but
|
|
832
|
+
// which the caller has claimed via `decommissionPrefixes` is processed
|
|
833
|
+
// by the DeleteObject loop above; skip the refusal event here so the
|
|
834
|
+
// event stream doesn't simultaneously claim "we deleted it" and "we
|
|
835
|
+
// kept it on remote" for the same key.
|
|
836
|
+
const decommissionedSet =
|
|
837
|
+
decommissionPlan.length > 0 ? new Set(decommissionPlan) : null;
|
|
760
838
|
for (const refused of deletePlan.refusedStale) {
|
|
839
|
+
if (decommissionedSet && decommissionedSet.has(refused.key)) continue;
|
|
761
840
|
filesRefusedStale++;
|
|
762
841
|
emit({
|
|
763
842
|
type: "delete-refused-stale-etag",
|
|
@@ -1359,3 +1438,58 @@ async function computeDeletePlan(
|
|
|
1359
1438
|
|
|
1360
1439
|
return plan;
|
|
1361
1440
|
}
|
|
1441
|
+
|
|
1442
|
+
/**
|
|
1443
|
+
* Walk every journal key that matches one of the supplied `prefixes` and
|
|
1444
|
+
* return the keys eligible for unconditional remote `DeleteObject`. Unlike
|
|
1445
|
+
* `computeDeletePlan` this DOES NOT check whether the local file is missing
|
|
1446
|
+
* — the caller has asserted that these keys no longer belong in this
|
|
1447
|
+
* bucket regardless of local state (typically a company that was promoted
|
|
1448
|
+
* from personal-bucket fallback to its own team bucket).
|
|
1449
|
+
*
|
|
1450
|
+
* An entry is in the plan only when ALL of the following hold:
|
|
1451
|
+
*
|
|
1452
|
+
* 1. Its key matches (or sits beneath) one of the `prefixes`. The match
|
|
1453
|
+
* is exact OR `key.startsWith(prefix + "/")` — same semantics as
|
|
1454
|
+
* `computeDeletePlan`'s scope-root check.
|
|
1455
|
+
* 2. When `policy === "owned-only"`: the journal entry's `direction`
|
|
1456
|
+
* is `"up"` (this machine previously uploaded the file). Mirrors
|
|
1457
|
+
* `computeDeletePlan`'s safety property — a misconfigured prefix
|
|
1458
|
+
* list can never erase content pulled from elsewhere.
|
|
1459
|
+
* 3. The key is NOT already in `alreadyPlanned` (the standard delete
|
|
1460
|
+
* plan), so a single DeleteObject + journal-update pass processes
|
|
1461
|
+
* each key once.
|
|
1462
|
+
*
|
|
1463
|
+
* Empty `prefixes` ⇒ empty plan (caller didn't opt in).
|
|
1464
|
+
*/
|
|
1465
|
+
function computeDecommissionPlan(
|
|
1466
|
+
journal: SyncJournal,
|
|
1467
|
+
prefixes: string[],
|
|
1468
|
+
policy: "currency-gated" | "owned-only" | "all",
|
|
1469
|
+
alreadyPlanned: ReadonlySet<string>,
|
|
1470
|
+
): string[] {
|
|
1471
|
+
if (prefixes.length === 0) return [];
|
|
1472
|
+
// `currency-gated` exists to gate `propagateDeletes` on per-file proof of
|
|
1473
|
+
// currency (HEAD compare). Decommission is unconditional by design — we
|
|
1474
|
+
// don't need a HEAD check to know whether the key belongs here, the
|
|
1475
|
+
// caller has asserted it doesn't. So for the direction-of-origin safety
|
|
1476
|
+
// gate we collapse `currency-gated` to the `owned-only` behavior:
|
|
1477
|
+
// peer-written entries (direction:'down') are skipped. That's the
|
|
1478
|
+
// conservative choice — a peer who wrote into this bucket should
|
|
1479
|
+
// reconcile their own entry, not have us blast it away on their behalf.
|
|
1480
|
+
const requireOwned = policy === "owned-only" || policy === "currency-gated";
|
|
1481
|
+
const out: string[] = [];
|
|
1482
|
+
for (const [relativeKey, entry] of Object.entries(journal.files)) {
|
|
1483
|
+
if (alreadyPlanned.has(relativeKey)) continue;
|
|
1484
|
+
const matches = prefixes.some(
|
|
1485
|
+
(prefix) =>
|
|
1486
|
+
prefix === "" ||
|
|
1487
|
+
relativeKey === prefix ||
|
|
1488
|
+
relativeKey.startsWith(`${prefix}/`),
|
|
1489
|
+
);
|
|
1490
|
+
if (!matches) continue;
|
|
1491
|
+
if (requireOwned && entry.direction !== "up") continue;
|
|
1492
|
+
out.push(relativeKey);
|
|
1493
|
+
}
|
|
1494
|
+
return out;
|
|
1495
|
+
}
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -26,6 +26,7 @@ vi.mock("../s3.js", async () => {
|
|
|
26
26
|
const dir = innerPath.dirname(localPath);
|
|
27
27
|
if (!innerFs.existsSync(dir)) innerFs.mkdirSync(dir, { recursive: true });
|
|
28
28
|
innerFs.writeFileSync(localPath, "mock file content");
|
|
29
|
+
return { metadata: {} };
|
|
29
30
|
}),
|
|
30
31
|
listRemoteFiles: innerVi.fn().mockResolvedValue(remoteFiles),
|
|
31
32
|
deleteRemoteFile: innerVi.fn().mockResolvedValue(undefined),
|
|
@@ -325,6 +326,97 @@ describe("sync", () => {
|
|
|
325
326
|
expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "docs", "readme.md"))).toBe(false);
|
|
326
327
|
});
|
|
327
328
|
|
|
329
|
+
it("personalMode + includeLocalCompanies: downloads companies/{cloud-false-slug}/... keys when slug NOT in teamSyncedSlugs", async () => {
|
|
330
|
+
// The symmetric flip for the cloud:false → personal-bucket fallback.
|
|
331
|
+
// Machine A pushed `companies/free-co/notes.md` to the personal bucket
|
|
332
|
+
// (because the operator set `HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1`
|
|
333
|
+
// AND `company.yaml` declares `cloud: false`). Machine B subscribes:
|
|
334
|
+
// pull must allow these keys through the personalMode filter, otherwise
|
|
335
|
+
// the feature is push-only and the destination machine never sees them.
|
|
336
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
337
|
+
{ key: "companies/free-co/notes.md", size: 50, lastModified: new Date(), etag: '"abc"' },
|
|
338
|
+
{ key: "docs/readme.md", size: 30, lastModified: new Date(), etag: '"def"' },
|
|
339
|
+
]);
|
|
340
|
+
|
|
341
|
+
const result = await sync({
|
|
342
|
+
company: "acme",
|
|
343
|
+
vaultConfig: mockConfig,
|
|
344
|
+
hqRoot: tmpDir,
|
|
345
|
+
personalMode: true,
|
|
346
|
+
includeLocalCompanies: true,
|
|
347
|
+
teamSyncedSlugs: new Set(), // empty → no slug is "orphan"
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
expect(result.filesDownloaded).toBe(2);
|
|
351
|
+
expect(result.filesSkipped).toBe(0);
|
|
352
|
+
expect(fs.existsSync(path.join(tmpDir, "companies", "free-co", "notes.md"))).toBe(true);
|
|
353
|
+
expect(fs.existsSync(path.join(tmpDir, "docs", "readme.md"))).toBe(true);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("personalMode + includeLocalCompanies: drops companies/{team-synced-slug}/... keys as orphans (symmetric to push-side decommission)", async () => {
|
|
357
|
+
// The orphan-cleanup half. Once a company has been promoted from
|
|
358
|
+
// cloud:false fallback to its own team bucket, the operator gains
|
|
359
|
+
// an active Membership for it (slug enters teamSyncedSlugs). Any
|
|
360
|
+
// leftover `companies/{that-slug}/...` keys in the personal bucket
|
|
361
|
+
// are stale residue — downloading them would clash with the team-
|
|
362
|
+
// bucket pull at the same disk path. Filter drops them silently.
|
|
363
|
+
// Push-side `decommissionPrefixes` eventually removes them from the
|
|
364
|
+
// bucket; this filter keeps the local tree clean in the meantime
|
|
365
|
+
// (especially important for pull-only mode where decommission never
|
|
366
|
+
// runs).
|
|
367
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
368
|
+
// Orphan: this slug is now team-synced — drop the key.
|
|
369
|
+
{ key: "companies/acme/file.md", size: 50, lastModified: new Date(), etag: '"old"' },
|
|
370
|
+
// Legitimate cloud:false content: download.
|
|
371
|
+
{ key: "companies/free-co/notes.md", size: 30, lastModified: new Date(), etag: '"new"' },
|
|
372
|
+
]);
|
|
373
|
+
|
|
374
|
+
const result = await sync({
|
|
375
|
+
company: "acme",
|
|
376
|
+
vaultConfig: mockConfig,
|
|
377
|
+
hqRoot: tmpDir,
|
|
378
|
+
personalMode: true,
|
|
379
|
+
includeLocalCompanies: true,
|
|
380
|
+
teamSyncedSlugs: new Set(["acme"]),
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
expect(result.filesDownloaded).toBe(1);
|
|
384
|
+
expect(result.filesSkipped).toBe(1);
|
|
385
|
+
// acme orphan must NOT land — it would clash with the team-bucket pull.
|
|
386
|
+
expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "file.md"))).toBe(false);
|
|
387
|
+
// free-co legitimate content lands at the hq-root-relative path.
|
|
388
|
+
expect(fs.existsSync(path.join(tmpDir, "companies", "free-co", "notes.md"))).toBe(true);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("personalMode WITHOUT includeLocalCompanies: legacy behavior preserved — ALL companies/... keys dropped", async () => {
|
|
392
|
+
// Regression for the legacy contract. Pre-5.20 (and any operator who
|
|
393
|
+
// hasn't opted into HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1), the
|
|
394
|
+
// personal bucket should never contain `companies/...` keys. If they
|
|
395
|
+
// do exist (stale data, manual S3 console intervention, an old bug),
|
|
396
|
+
// the pull-side filter drops them as a safety net. This test pins
|
|
397
|
+
// that contract under the new option shape: includeLocalCompanies
|
|
398
|
+
// omitted → behaves identically to the original filter, even if
|
|
399
|
+
// teamSyncedSlugs is supplied.
|
|
400
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
401
|
+
{ key: "companies/anything/file.md", size: 50, lastModified: new Date(), etag: '"x"' },
|
|
402
|
+
{ key: "docs/readme.md", size: 30, lastModified: new Date(), etag: '"y"' },
|
|
403
|
+
]);
|
|
404
|
+
|
|
405
|
+
const result = await sync({
|
|
406
|
+
company: "acme",
|
|
407
|
+
vaultConfig: mockConfig,
|
|
408
|
+
hqRoot: tmpDir,
|
|
409
|
+
personalMode: true,
|
|
410
|
+
// includeLocalCompanies omitted → defaults to false
|
|
411
|
+
teamSyncedSlugs: new Set(["unrelated"]),
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
expect(result.filesDownloaded).toBe(1);
|
|
415
|
+
expect(result.filesSkipped).toBe(1);
|
|
416
|
+
expect(fs.existsSync(path.join(tmpDir, "companies", "anything", "file.md"))).toBe(false);
|
|
417
|
+
expect(fs.existsSync(path.join(tmpDir, "docs", "readme.md"))).toBe(true);
|
|
418
|
+
});
|
|
419
|
+
|
|
328
420
|
it("overwrites local on --on-conflict overwrite", async () => {
|
|
329
421
|
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
330
422
|
fs.mkdirSync(companyDocs, { recursive: true });
|
|
@@ -703,6 +795,64 @@ describe("sync", () => {
|
|
|
703
795
|
expect(newFilesIdx).toBeGreaterThan(lastProgressIdx);
|
|
704
796
|
});
|
|
705
797
|
|
|
798
|
+
it("stamps progress.author from the downloaded object's created-by metadata", async () => {
|
|
799
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
800
|
+
{ key: "docs/authored.md", size: 10, lastModified: new Date(), etag: '"e1"' },
|
|
801
|
+
]);
|
|
802
|
+
vi.mocked(s3Module.downloadFile).mockImplementationOnce(
|
|
803
|
+
async (_ctx: unknown, _key: string, localPath: string) => {
|
|
804
|
+
const dir = path.dirname(localPath);
|
|
805
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
806
|
+
fs.writeFileSync(localPath, "authored content");
|
|
807
|
+
return { metadata: { "created-by": "alice@example.com" } };
|
|
808
|
+
},
|
|
809
|
+
);
|
|
810
|
+
|
|
811
|
+
const progressEvents: Array<{ type: string; path: string; author?: string | null }> = [];
|
|
812
|
+
await sync({
|
|
813
|
+
company: "acme",
|
|
814
|
+
vaultConfig: mockConfig,
|
|
815
|
+
hqRoot: tmpDir,
|
|
816
|
+
onEvent: (e) => {
|
|
817
|
+
if (e.type === "progress") progressEvents.push(e);
|
|
818
|
+
},
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
expect(progressEvents).toHaveLength(1);
|
|
822
|
+
expect(progressEvents[0]).toMatchObject({
|
|
823
|
+
type: "progress",
|
|
824
|
+
path: "docs/authored.md",
|
|
825
|
+
author: "alice@example.com",
|
|
826
|
+
});
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
it("omits progress.author when the downloaded object has no created-by metadata", async () => {
|
|
830
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
831
|
+
{ key: "docs/anon.md", size: 10, lastModified: new Date(), etag: '"e2"' },
|
|
832
|
+
]);
|
|
833
|
+
vi.mocked(s3Module.downloadFile).mockImplementationOnce(
|
|
834
|
+
async (_ctx: unknown, _key: string, localPath: string) => {
|
|
835
|
+
const dir = path.dirname(localPath);
|
|
836
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
837
|
+
fs.writeFileSync(localPath, "anon content");
|
|
838
|
+
return { metadata: {} };
|
|
839
|
+
},
|
|
840
|
+
);
|
|
841
|
+
|
|
842
|
+
const progressEvents: Array<{ type: string; path: string; author?: string | null }> = [];
|
|
843
|
+
await sync({
|
|
844
|
+
company: "acme",
|
|
845
|
+
vaultConfig: mockConfig,
|
|
846
|
+
hqRoot: tmpDir,
|
|
847
|
+
onEvent: (e) => {
|
|
848
|
+
if (e.type === "progress") progressEvents.push(e);
|
|
849
|
+
},
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
expect(progressEvents).toHaveLength(1);
|
|
853
|
+
expect(progressEvents[0].author ?? null).toBeNull();
|
|
854
|
+
});
|
|
855
|
+
|
|
706
856
|
it("plan event counts a 3-way conflict separately from downloads", async () => {
|
|
707
857
|
// Local edit + journal-tracked + remote ETag drifted → conflict.
|
|
708
858
|
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
@@ -775,6 +925,7 @@ describe("sync", () => {
|
|
|
775
925
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
776
926
|
// Mirror real downloadFile's symlink branch.
|
|
777
927
|
fs.symlinkSync(linkTarget, localPath);
|
|
928
|
+
return { metadata: {} };
|
|
778
929
|
},
|
|
779
930
|
);
|
|
780
931
|
|
|
@@ -876,6 +1027,7 @@ describe("sync", () => {
|
|
|
876
1027
|
const dir = path.dirname(localPath);
|
|
877
1028
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
878
1029
|
fs.symlinkSync(linkTarget, localPath);
|
|
1030
|
+
return { metadata: {} };
|
|
879
1031
|
},
|
|
880
1032
|
);
|
|
881
1033
|
|