@indigoai-us/hq-cloud 6.12.4 → 6.13.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.test.js +35 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +26 -0
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +100 -0
- 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 +100 -3
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +246 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/journal.d.ts +1 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js.map +1 -1
- package/dist/lib/cloud-authoritative.d.ts +45 -0
- package/dist/lib/cloud-authoritative.d.ts.map +1 -0
- package/dist/lib/cloud-authoritative.js +58 -0
- package/dist/lib/cloud-authoritative.js.map +1 -0
- package/dist/lib/cloud-authoritative.test.d.ts +2 -0
- package/dist/lib/cloud-authoritative.test.d.ts.map +1 -0
- package/dist/lib/cloud-authoritative.test.js +42 -0
- package/dist/lib/cloud-authoritative.test.js.map +1 -0
- package/dist/types.d.ts +11 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +41 -0
- package/src/cli/share.test.ts +119 -0
- package/src/cli/share.ts +32 -1
- package/src/cli/sync.test.ts +308 -0
- package/src/cli/sync.ts +111 -3
- package/src/journal.ts +1 -1
- package/src/lib/cloud-authoritative.test.ts +45 -0
- package/src/lib/cloud-authoritative.ts +59 -0
- package/src/types.ts +11 -1
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloud-authoritative vault paths — files the SERVER regenerates and owns, so
|
|
3
|
+
* on a sync conflict the cloud version always wins (pull-wins) and NO
|
|
4
|
+
* `.conflict-` mirror is minted.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists
|
|
7
|
+
* ---------------
|
|
8
|
+
* Some vault files are written/regenerated server-side on a schedule, not by
|
|
9
|
+
* the client:
|
|
10
|
+
* - `company-brief.md` — rewritten by the ontology gardener's brief-generator
|
|
11
|
+
* on every run.
|
|
12
|
+
* - `board.json` — rewritten by board automation.
|
|
13
|
+
* - `ontology/**`, `signals/**`, `sources/**` — the gardener / sources+signals
|
|
14
|
+
* pipeline are the sole authors; clients only consume.
|
|
15
|
+
*
|
|
16
|
+
* A client that also holds these locally diverges from the server copy on
|
|
17
|
+
* essentially every sync. Under `--on-conflict keep` (what outposts run) that
|
|
18
|
+
* produced two compounding bugs:
|
|
19
|
+
* 1. CONFLICT LOOP — each sync minted a fresh `<file>.conflict-<ts>-<machine>`
|
|
20
|
+
* mirror that was never cleaned up (e.g. ~50 `company-brief.md.conflict-*`
|
|
21
|
+
* piled up on an outpost over a few days).
|
|
22
|
+
* 2. SILENT DRIFT — the keep path stamped the journal with the REMOTE etag
|
|
23
|
+
* while leaving the divergent LOCAL content in place, so the journal
|
|
24
|
+
* reported "in sync" forever and the file never reconciled.
|
|
25
|
+
*
|
|
26
|
+
* Treating these paths as cloud-authoritative resolves both: the conflict path
|
|
27
|
+
* short-circuits to a normal overwrite-from-cloud (correct journal stamp, no
|
|
28
|
+
* mirror), because for a server-owned file the cloud copy is the truth.
|
|
29
|
+
*
|
|
30
|
+
* Extending
|
|
31
|
+
* ---------
|
|
32
|
+
* Add a pattern to {@link CLOUD_AUTHORITATIVE_PATTERNS}. Patterns are matched
|
|
33
|
+
* against the VAULT-RELATIVE path (a leading `companies/<slug>/` is stripped
|
|
34
|
+
* first), so both call sites — the pull leg (hqRoot-relative
|
|
35
|
+
* `companies/<slug>/…`) and the push leg (company-relative `…`) — work.
|
|
36
|
+
*/
|
|
37
|
+
/** Vault-relative patterns for server-owned, cloud-wins files. */
|
|
38
|
+
export declare const CLOUD_AUTHORITATIVE_PATTERNS: readonly RegExp[];
|
|
39
|
+
/**
|
|
40
|
+
* True iff `relPath` is a server-regenerated, cloud-authoritative vault file.
|
|
41
|
+
* Accepts either an hqRoot-relative path (`companies/<slug>/…`) or a
|
|
42
|
+
* vault-relative path (`…`); Windows separators are normalized.
|
|
43
|
+
*/
|
|
44
|
+
export declare function isCloudAuthoritative(relPath: string): boolean;
|
|
45
|
+
//# sourceMappingURL=cloud-authoritative.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cloud-authoritative.d.ts","sourceRoot":"","sources":["../../src/lib/cloud-authoritative.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAEH,kEAAkE;AAClE,eAAO,MAAM,4BAA4B,EAAE,SAAS,MAAM,EAMzD,CAAC;AAEF;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAO7D"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloud-authoritative vault paths — files the SERVER regenerates and owns, so
|
|
3
|
+
* on a sync conflict the cloud version always wins (pull-wins) and NO
|
|
4
|
+
* `.conflict-` mirror is minted.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists
|
|
7
|
+
* ---------------
|
|
8
|
+
* Some vault files are written/regenerated server-side on a schedule, not by
|
|
9
|
+
* the client:
|
|
10
|
+
* - `company-brief.md` — rewritten by the ontology gardener's brief-generator
|
|
11
|
+
* on every run.
|
|
12
|
+
* - `board.json` — rewritten by board automation.
|
|
13
|
+
* - `ontology/**`, `signals/**`, `sources/**` — the gardener / sources+signals
|
|
14
|
+
* pipeline are the sole authors; clients only consume.
|
|
15
|
+
*
|
|
16
|
+
* A client that also holds these locally diverges from the server copy on
|
|
17
|
+
* essentially every sync. Under `--on-conflict keep` (what outposts run) that
|
|
18
|
+
* produced two compounding bugs:
|
|
19
|
+
* 1. CONFLICT LOOP — each sync minted a fresh `<file>.conflict-<ts>-<machine>`
|
|
20
|
+
* mirror that was never cleaned up (e.g. ~50 `company-brief.md.conflict-*`
|
|
21
|
+
* piled up on an outpost over a few days).
|
|
22
|
+
* 2. SILENT DRIFT — the keep path stamped the journal with the REMOTE etag
|
|
23
|
+
* while leaving the divergent LOCAL content in place, so the journal
|
|
24
|
+
* reported "in sync" forever and the file never reconciled.
|
|
25
|
+
*
|
|
26
|
+
* Treating these paths as cloud-authoritative resolves both: the conflict path
|
|
27
|
+
* short-circuits to a normal overwrite-from-cloud (correct journal stamp, no
|
|
28
|
+
* mirror), because for a server-owned file the cloud copy is the truth.
|
|
29
|
+
*
|
|
30
|
+
* Extending
|
|
31
|
+
* ---------
|
|
32
|
+
* Add a pattern to {@link CLOUD_AUTHORITATIVE_PATTERNS}. Patterns are matched
|
|
33
|
+
* against the VAULT-RELATIVE path (a leading `companies/<slug>/` is stripped
|
|
34
|
+
* first), so both call sites — the pull leg (hqRoot-relative
|
|
35
|
+
* `companies/<slug>/…`) and the push leg (company-relative `…`) — work.
|
|
36
|
+
*/
|
|
37
|
+
/** Vault-relative patterns for server-owned, cloud-wins files. */
|
|
38
|
+
export const CLOUD_AUTHORITATIVE_PATTERNS = [
|
|
39
|
+
/^company-brief\.md$/,
|
|
40
|
+
/^board\.json$/,
|
|
41
|
+
/^ontology\//,
|
|
42
|
+
/^signals\//,
|
|
43
|
+
/^sources\//,
|
|
44
|
+
];
|
|
45
|
+
/**
|
|
46
|
+
* True iff `relPath` is a server-regenerated, cloud-authoritative vault file.
|
|
47
|
+
* Accepts either an hqRoot-relative path (`companies/<slug>/…`) or a
|
|
48
|
+
* vault-relative path (`…`); Windows separators are normalized.
|
|
49
|
+
*/
|
|
50
|
+
export function isCloudAuthoritative(relPath) {
|
|
51
|
+
const vaultRel = relPath
|
|
52
|
+
.split("\\")
|
|
53
|
+
.join("/")
|
|
54
|
+
.replace(/^\.?\//, "")
|
|
55
|
+
.replace(/^companies\/[^/]+\//, "");
|
|
56
|
+
return CLOUD_AUTHORITATIVE_PATTERNS.some((re) => re.test(vaultRel));
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=cloud-authoritative.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cloud-authoritative.js","sourceRoot":"","sources":["../../src/lib/cloud-authoritative.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAEH,kEAAkE;AAClE,MAAM,CAAC,MAAM,4BAA4B,GAAsB;IAC7D,qBAAqB;IACrB,eAAe;IACf,aAAa;IACb,YAAY;IACZ,YAAY;CACb,CAAC;AAEF;;;;GAIG;AACH,MAAM,UAAU,oBAAoB,CAAC,OAAe;IAClD,MAAM,QAAQ,GAAG,OAAO;SACrB,KAAK,CAAC,IAAI,CAAC;SACX,IAAI,CAAC,GAAG,CAAC;SACT,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC;SACrB,OAAO,CAAC,qBAAqB,EAAE,EAAE,CAAC,CAAC;IACtC,OAAO,4BAA4B,CAAC,IAAI,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC;AACtE,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cloud-authoritative.test.d.ts","sourceRoot":"","sources":["../../src/lib/cloud-authoritative.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { isCloudAuthoritative } from "./cloud-authoritative.js";
|
|
3
|
+
describe("isCloudAuthoritative", () => {
|
|
4
|
+
it("matches server-regenerated root files (both path forms)", () => {
|
|
5
|
+
for (const p of [
|
|
6
|
+
"companies/indigo/company-brief.md",
|
|
7
|
+
"company-brief.md",
|
|
8
|
+
"companies/acme/board.json",
|
|
9
|
+
"board.json",
|
|
10
|
+
]) {
|
|
11
|
+
expect(isCloudAuthoritative(p)).toBe(true);
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
it("matches the pipeline-authored dirs", () => {
|
|
15
|
+
for (const p of [
|
|
16
|
+
"companies/indigo/ontology/entities/concept/grok.md",
|
|
17
|
+
"ontology/entities/person/x.md",
|
|
18
|
+
"companies/indigo/signals/slack-dm/action_item/a.md",
|
|
19
|
+
"signals/decision/b.md",
|
|
20
|
+
"companies/indigo/sources/slack/T1-2.md",
|
|
21
|
+
"sources/meetings/m.md",
|
|
22
|
+
]) {
|
|
23
|
+
expect(isCloudAuthoritative(p)).toBe(true);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
it("does NOT match client-owned files", () => {
|
|
27
|
+
for (const p of [
|
|
28
|
+
"companies/indigo/skills/work-mesh/SKILL.md",
|
|
29
|
+
"companies/indigo/projects/company-agents/release/index.html",
|
|
30
|
+
"companies/indigo/workers/hq-notify/emails/notice.template.html",
|
|
31
|
+
"companies/indigo/knowledge/readme.md",
|
|
32
|
+
"company-brief.md.conflict-2026-06-18T00-00-00Z-501bc8.md", // a mirror, not the brief
|
|
33
|
+
"docs/handoff.md",
|
|
34
|
+
]) {
|
|
35
|
+
expect(isCloudAuthoritative(p)).toBe(false);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
it("normalizes windows separators", () => {
|
|
39
|
+
expect(isCloudAuthoritative("companies\\indigo\\ontology\\entities\\x.md")).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
//# sourceMappingURL=cloud-authoritative.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cloud-authoritative.test.js","sourceRoot":"","sources":["../../src/lib/cloud-authoritative.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAC;AAEhE,QAAQ,CAAC,sBAAsB,EAAE,GAAG,EAAE;IACpC,EAAE,CAAC,yDAAyD,EAAE,GAAG,EAAE;QACjE,KAAK,MAAM,CAAC,IAAI;YACd,mCAAmC;YACnC,kBAAkB;YAClB,2BAA2B;YAC3B,YAAY;SACb,EAAE,CAAC;YACF,MAAM,CAAC,oBAAoB,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC7C,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,oCAAoC,EAAE,GAAG,EAAE;QAC5C,KAAK,MAAM,CAAC,IAAI;YACd,oDAAoD;YACpD,+BAA+B;YAC/B,oDAAoD;YACpD,uBAAuB;YACvB,wCAAwC;YACxC,uBAAuB;SACxB,EAAE,CAAC;YACF,MAAM,CAAC,oBAAoB,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC7C,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,mCAAmC,EAAE,GAAG,EAAE;QAC3C,KAAK,MAAM,CAAC,IAAI;YACd,4CAA4C;YAC5C,6DAA6D;YAC7D,gEAAgE;YAChE,sCAAsC;YACtC,0DAA0D,EAAE,0BAA0B;YACtF,iBAAiB;SAClB,EAAE,CAAC;YACF,MAAM,CAAC,oBAAoB,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC9C,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,+BAA+B,EAAE,GAAG,EAAE;QACvC,MAAM,CAAC,oBAAoB,CAAC,6CAA6C,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACzF,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
|
package/dist/types.d.ts
CHANGED
|
@@ -68,7 +68,7 @@ export interface JournalEntry {
|
|
|
68
68
|
* the same paths as orphans, then garbage-collected.
|
|
69
69
|
*/
|
|
70
70
|
removedAt?: string;
|
|
71
|
-
removedReason?: "scope_shrink" | "narrow_apply" | "manual";
|
|
71
|
+
removedReason?: "scope_shrink" | "narrow_apply" | "manual" | "local-delete";
|
|
72
72
|
/**
|
|
73
73
|
* Durable automatic-pull retention marker. Set when a scope shrink keeps an
|
|
74
74
|
* out-of-scope caller-authored or unknown-author entry on disk instead of
|
|
@@ -77,6 +77,16 @@ export interface JournalEntry {
|
|
|
77
77
|
* the entry becomes in-scope again.
|
|
78
78
|
*/
|
|
79
79
|
outOfScopeProtected?: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* Journal-honesty marker. Set when a conflict was resolved by KEEPing a local
|
|
82
|
+
* copy that diverges from the remote: the entry still records the remote
|
|
83
|
+
* `remoteEtag` (so the conflict can't re-fire — the #137 invariant), but the
|
|
84
|
+
* local bytes never matched that remote. The currency-gated delete planner
|
|
85
|
+
* refuses to propagate a delete for such an entry — its currency would
|
|
86
|
+
* falsely "match" on HEAD, and deleting would destroy the divergent remote
|
|
87
|
+
* version. Cleared by any genuine download (which makes local == remote).
|
|
88
|
+
*/
|
|
89
|
+
localDiverges?: boolean;
|
|
80
90
|
}
|
|
81
91
|
/**
|
|
82
92
|
* Per-pull boundary record (Journal v2, US-005).
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,WAAW;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,IAAI,GAAG,MAAM,CAAC;IACzB;;;;;;;;;;OAUG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;;;;;;;;;OAcG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,cAAc,GAAG,cAAc,GAAG,QAAQ,CAAC;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -3580,6 +3580,47 @@ describe("resolvePullScope", () => {
|
|
|
3580
3580
|
expect(scope.prefixSet).toEqual(["knowledge/", "shared/"]);
|
|
3581
3581
|
});
|
|
3582
3582
|
|
|
3583
|
+
// Regression lock-in (Michal / ridge report): a non-owner member whose
|
|
3584
|
+
// ONLY grant is a COMPANY-WIDE write grant (surfaced by the server as
|
|
3585
|
+
// `source: "open"`) on a projects prefix — and who has NO direct
|
|
3586
|
+
// person/group/email grant — MUST resolve a non-empty shared scope that
|
|
3587
|
+
// COVERS that prefix. The reported bug was the push resolving an empty/
|
|
3588
|
+
// sparse prefixSet here (ignoring company-wide grants), so every file was
|
|
3589
|
+
// scope-excluded (403 SCOPE_EXCEEDS_PARENT on old clients, silent no-op on
|
|
3590
|
+
// 5.54.1). resolvePullScope treats a company-wide `open` grant identically
|
|
3591
|
+
// to a direct grant; this test guards against a future filter that drops
|
|
3592
|
+
// `source: "open"` and re-introduces the silent-drop regression.
|
|
3593
|
+
it("honors a company-wide-only write grant as the shared push scope (no empty-prune)", async () => {
|
|
3594
|
+
const scope = await resolvePullScope(
|
|
3595
|
+
stubClient({
|
|
3596
|
+
listMyMemberships: async () => [membership("cmp_a", "mk_a")],
|
|
3597
|
+
getMembershipSyncConfig: async () => ({
|
|
3598
|
+
membershipId: "mk_a",
|
|
3599
|
+
syncMode: "shared",
|
|
3600
|
+
isDefault: false,
|
|
3601
|
+
}),
|
|
3602
|
+
// Exactly one grant, company-wide WRITE, no direct grant.
|
|
3603
|
+
listMyExplicitGrants: async () =>
|
|
3604
|
+
[
|
|
3605
|
+
{
|
|
3606
|
+
companyUid: "cmp_a",
|
|
3607
|
+
path: "projects/alpha/",
|
|
3608
|
+
permission: "write",
|
|
3609
|
+
source: "open",
|
|
3610
|
+
},
|
|
3611
|
+
] as never,
|
|
3612
|
+
}),
|
|
3613
|
+
"cmp_a",
|
|
3614
|
+
"acme",
|
|
3615
|
+
);
|
|
3616
|
+
expect(scope.syncMode).toBe("shared");
|
|
3617
|
+
// The granted prefix is present — NOT an empty set (which would scope-
|
|
3618
|
+
// exclude every file and silently push nothing).
|
|
3619
|
+
expect(scope.prefixSet).toEqual(["projects/alpha/"]);
|
|
3620
|
+
expect(scope.prefixSet).not.toEqual([]);
|
|
3621
|
+
expect(scope.prefixSet?.length).toBeGreaterThan(0);
|
|
3622
|
+
});
|
|
3623
|
+
|
|
3583
3624
|
it("normalizes real-world mixed/glob grant paths into company-relative prefixes", async () => {
|
|
3584
3625
|
// The exact shapes observed in the live hq-pro vault for `indigo`:
|
|
3585
3626
|
// bare glob, full-anchored + /*, full-anchored exact file, slug-anchored
|
package/src/cli/share.test.ts
CHANGED
|
@@ -2470,6 +2470,61 @@ describe("share", () => {
|
|
|
2470
2470
|
expect(journal.files["core/policies/old.md"]).toBeUndefined();
|
|
2471
2471
|
});
|
|
2472
2472
|
|
|
2473
|
+
it("currency-gated: propagates the S3 delete for a local-delete tombstone (pull-leg producer hand-off)", async () => {
|
|
2474
|
+
// The pull-leg producer stamped a persisting `local-delete` tombstone for a
|
|
2475
|
+
// file the user deleted (no respawn). The cross-leg hand-off: on the next
|
|
2476
|
+
// push the S3 object must actually be DELETED and the entry dropped, so the
|
|
2477
|
+
// deletion propagates to the cloud + every other surface.
|
|
2478
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2479
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2480
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2481
|
+
fs.writeFileSync(
|
|
2482
|
+
journalPath,
|
|
2483
|
+
JSON.stringify({
|
|
2484
|
+
version: "2",
|
|
2485
|
+
lastSync: new Date().toISOString(),
|
|
2486
|
+
files: {
|
|
2487
|
+
"docs/deleted.md": {
|
|
2488
|
+
hash: "h",
|
|
2489
|
+
size: 100,
|
|
2490
|
+
syncedAt: new Date().toISOString(),
|
|
2491
|
+
direction: "down",
|
|
2492
|
+
remoteEtag: "abc123",
|
|
2493
|
+
removedAt: new Date().toISOString(),
|
|
2494
|
+
removedReason: "local-delete",
|
|
2495
|
+
},
|
|
2496
|
+
},
|
|
2497
|
+
pulls: [],
|
|
2498
|
+
}),
|
|
2499
|
+
);
|
|
2500
|
+
|
|
2501
|
+
// HEAD returns the same etag — this device had the current version, so the
|
|
2502
|
+
// currency-gated delete is safe to propagate.
|
|
2503
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
2504
|
+
lastModified: new Date(),
|
|
2505
|
+
etag: '"abc123"',
|
|
2506
|
+
size: 100,
|
|
2507
|
+
});
|
|
2508
|
+
|
|
2509
|
+
const result = await share({
|
|
2510
|
+
paths: [companyRoot],
|
|
2511
|
+
company: "acme",
|
|
2512
|
+
vaultConfig: mockConfig,
|
|
2513
|
+
hqRoot: tmpDir,
|
|
2514
|
+
skipUnchanged: true,
|
|
2515
|
+
propagateDeletes: true,
|
|
2516
|
+
propagateDeletePolicy: "currency-gated",
|
|
2517
|
+
});
|
|
2518
|
+
|
|
2519
|
+
expect(result.filesDeleted).toBe(1);
|
|
2520
|
+
expect(deleteRemoteFile).toHaveBeenCalledWith(
|
|
2521
|
+
expect.anything(),
|
|
2522
|
+
"docs/deleted.md",
|
|
2523
|
+
);
|
|
2524
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2525
|
+
expect(journal.files["docs/deleted.md"]).toBeUndefined();
|
|
2526
|
+
});
|
|
2527
|
+
|
|
2473
2528
|
it("currency-gated: refuses delete + emits stale-etag event when remote moved since last sync", async () => {
|
|
2474
2529
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2475
2530
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
@@ -2531,6 +2586,70 @@ describe("share", () => {
|
|
|
2531
2586
|
expect(result.filesTombstoned).toBe(0);
|
|
2532
2587
|
});
|
|
2533
2588
|
|
|
2589
|
+
it("currency-gated: refuses delete for a divergent-local entry (journal-honesty — never delete a remote the local never genuinely matched)", async () => {
|
|
2590
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2591
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2592
|
+
// A keep-conflict recorded the REMOTE etag (so the conflict can't re-fire —
|
|
2593
|
+
// the #137 invariant) but the kept LOCAL content DIVERGED from that remote
|
|
2594
|
+
// (`localDiverges:true`). The local copy was then deleted. The currency HEAD
|
|
2595
|
+
// would MATCH the recorded etag — the trap — yet the etag never reflected a
|
|
2596
|
+
// genuine local==remote sync, so propagating the delete would destroy the
|
|
2597
|
+
// (different) remote version. Must be REFUSED, not propagated.
|
|
2598
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2599
|
+
fs.writeFileSync(
|
|
2600
|
+
journalPath,
|
|
2601
|
+
JSON.stringify({
|
|
2602
|
+
version: "2",
|
|
2603
|
+
lastSync: new Date().toISOString(),
|
|
2604
|
+
files: {
|
|
2605
|
+
"core/policies/kept.md": {
|
|
2606
|
+
hash: "local-divergent-hash",
|
|
2607
|
+
size: 100,
|
|
2608
|
+
syncedAt: new Date().toISOString(),
|
|
2609
|
+
direction: "down",
|
|
2610
|
+
remoteEtag: "abc123",
|
|
2611
|
+
localDiverges: true,
|
|
2612
|
+
},
|
|
2613
|
+
},
|
|
2614
|
+
pulls: [],
|
|
2615
|
+
}),
|
|
2616
|
+
);
|
|
2617
|
+
|
|
2618
|
+
// Deliberately NO HEAD mock: the divergence guard short-circuits BEFORE the
|
|
2619
|
+
// currency HEAD, so `headRemoteFile` is never called for this entry — the
|
|
2620
|
+
// refusal costs no network round-trip (asserted below).
|
|
2621
|
+
const events: Array<{ type: string; path?: string; reason?: string }> = [];
|
|
2622
|
+
const result = await share({
|
|
2623
|
+
paths: [companyRoot],
|
|
2624
|
+
company: "acme",
|
|
2625
|
+
vaultConfig: mockConfig,
|
|
2626
|
+
hqRoot: tmpDir,
|
|
2627
|
+
skipUnchanged: true,
|
|
2628
|
+
propagateDeletes: true,
|
|
2629
|
+
propagateDeletePolicy: "currency-gated",
|
|
2630
|
+
onEvent: (e) => events.push(e as { type: string }),
|
|
2631
|
+
});
|
|
2632
|
+
|
|
2633
|
+
// No remote DELETE — the recorded etag never reflected a genuine sync.
|
|
2634
|
+
expect(result.filesDeleted).toBe(0);
|
|
2635
|
+
expect(deleteRemoteFile).not.toHaveBeenCalledWith(
|
|
2636
|
+
expect.anything(),
|
|
2637
|
+
"core/policies/kept.md",
|
|
2638
|
+
);
|
|
2639
|
+
// Refusal short-circuits before the currency HEAD — no network needed.
|
|
2640
|
+
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
2641
|
+
// Journal entry preserved, not silently dropped.
|
|
2642
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2643
|
+
expect(journal.files["core/policies/kept.md"]).toBeDefined();
|
|
2644
|
+
// Surfaced as a refusal with the divergent-local reason.
|
|
2645
|
+
const refused = events.find((e) => e.type === "delete-refused-stale-etag");
|
|
2646
|
+
expect(refused).toMatchObject({
|
|
2647
|
+
type: "delete-refused-stale-etag",
|
|
2648
|
+
path: "core/policies/kept.md",
|
|
2649
|
+
reason: "divergent-local",
|
|
2650
|
+
});
|
|
2651
|
+
});
|
|
2652
|
+
|
|
2534
2653
|
it("currency-gated: tombstones journal entry when remote returns 404 (out-of-band cleanup)", async () => {
|
|
2535
2654
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2536
2655
|
fs.mkdirSync(companyRoot, { recursive: true });
|
package/src/cli/share.ts
CHANGED
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
readShortMachineId,
|
|
65
65
|
} from "../lib/conflict-file.js";
|
|
66
66
|
import { appendConflictEntry } from "../lib/conflict-index.js";
|
|
67
|
+
import { isCloudAuthoritative } from "../lib/cloud-authoritative.js";
|
|
67
68
|
|
|
68
69
|
/**
|
|
69
70
|
* Push-side fresh-collision convergence probe.
|
|
@@ -1260,6 +1261,16 @@ async function executeUploads(
|
|
|
1260
1261
|
}
|
|
1261
1262
|
|
|
1262
1263
|
if ((localChanged && remoteChanged) || isFreshCollision) {
|
|
1264
|
+
// Cloud-authoritative paths (server-regenerated: company-brief.md,
|
|
1265
|
+
// board.json, ontology/ signals/ sources/) are PULL-WINS. Never push the
|
|
1266
|
+
// local copy over the server-owned version and never mint a `.conflict-`
|
|
1267
|
+
// mirror — the pull leg overwrites local from cloud. Declining here is
|
|
1268
|
+
// what stops the per-sync conflict-mirror loop on these files.
|
|
1269
|
+
if (isCloudAuthoritative(relativePath)) {
|
|
1270
|
+
run.emit({ type: "reconciled", path: relativePath, direction: "push" });
|
|
1271
|
+
counters.filesSkipped++;
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1263
1274
|
conflictPaths.push(relativePath);
|
|
1264
1275
|
|
|
1265
1276
|
const resolution = await resolveConflictSerialized({
|
|
@@ -1976,7 +1987,11 @@ function resolveDeleteScopeRoots(
|
|
|
1976
1987
|
* tracked. `journalEtag` and `remoteEtag` are
|
|
1977
1988
|
* placeholder sentinels — do not display as ETags.
|
|
1978
1989
|
*/
|
|
1979
|
-
type RefusedStaleReason =
|
|
1990
|
+
type RefusedStaleReason =
|
|
1991
|
+
| "stale-etag"
|
|
1992
|
+
| "legacy-no-etag"
|
|
1993
|
+
| "bulk-asymmetry"
|
|
1994
|
+
| "divergent-local";
|
|
1980
1995
|
|
|
1981
1996
|
/**
|
|
1982
1997
|
* Bulk-asymmetry circuit-breaker — refuses to convert a suspiciously-large
|
|
@@ -2230,6 +2245,22 @@ async function computeDeletePlan(
|
|
|
2230
2245
|
// documentation of the policy-side intent.
|
|
2231
2246
|
if (isEphemeralPath(relativeKey)) continue;
|
|
2232
2247
|
|
|
2248
|
+
// Journal-honesty guard: an entry flagged `localDiverges` recorded the
|
|
2249
|
+
// remote etag only to silence a re-fired conflict (#137) — the local copy
|
|
2250
|
+
// NEVER matched that remote. Its currency would falsely "match" on HEAD, so
|
|
2251
|
+
// propagating a delete would destroy the divergent remote version. Refuse it
|
|
2252
|
+
// regardless of policy, and do NOT count it toward the bulk-asymmetry ratio
|
|
2253
|
+
// (it isn't a genuine mirror-loss candidate). Cleared by a real download.
|
|
2254
|
+
if (entry.localDiverges) {
|
|
2255
|
+
plan.refusedStale.push({
|
|
2256
|
+
key: relativeKey,
|
|
2257
|
+
journalEtag: entry.remoteEtag ?? "<divergent-local>",
|
|
2258
|
+
remoteEtag: "<not-checked>",
|
|
2259
|
+
reason: "divergent-local",
|
|
2260
|
+
});
|
|
2261
|
+
continue;
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2233
2264
|
if (policy === "all") {
|
|
2234
2265
|
// policy:"all" is the explicit-opt-out emergency-reconcile mode; the
|
|
2235
2266
|
// bulk-asymmetry guard skips this branch (caller asserted intent).
|