@indigoai-us/hq-cloud 6.11.10 → 6.11.12
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 +2 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +231 -52
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +330 -11
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +16 -1
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +39 -1
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/rescue-classify-ordering.test.js +58 -0
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/cli/rescue-core.js +229 -15
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-exec-bit-preserve.test.d.ts +2 -0
- package/dist/cli/rescue-exec-bit-preserve.test.d.ts.map +1 -0
- package/dist/cli/rescue-exec-bit-preserve.test.js +169 -0
- package/dist/cli/rescue-exec-bit-preserve.test.js.map +1 -0
- package/dist/cli/share.d.ts +2 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +100 -32
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +30 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +28 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +188 -59
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +487 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +55 -10
- package/dist/cognito-auth.js.map +1 -1
- package/dist/cognito-auth.test.js +61 -0
- package/dist/cognito-auth.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/journal.d.ts.map +1 -1
- package/dist/journal.js +93 -6
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +59 -0
- package/dist/journal.test.js.map +1 -1
- package/dist/machine-auth.test.js +60 -2
- package/dist/machine-auth.test.js.map +1 -1
- package/dist/object-io.d.ts +37 -1
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +148 -29
- package/dist/object-io.js.map +1 -1
- package/dist/object-io.test.js +121 -0
- package/dist/object-io.test.js.map +1 -1
- package/dist/operation-lock.d.ts +8 -8
- package/dist/operation-lock.d.ts.map +1 -1
- package/dist/operation-lock.js +99 -32
- package/dist/operation-lock.js.map +1 -1
- package/dist/operation-lock.test.js +51 -4
- package/dist/operation-lock.test.js.map +1 -1
- package/dist/personal-vault.d.ts +8 -0
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +17 -3
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.js +34 -0
- package/dist/personal-vault.test.js.map +1 -1
- package/dist/prefix-coalesce.d.ts +20 -9
- package/dist/prefix-coalesce.d.ts.map +1 -1
- package/dist/prefix-coalesce.js +124 -28
- package/dist/prefix-coalesce.js.map +1 -1
- package/dist/prefix-coalesce.test.js +57 -2
- package/dist/prefix-coalesce.test.js.map +1 -1
- package/dist/remote-pull.d.ts +6 -1
- package/dist/remote-pull.d.ts.map +1 -1
- package/dist/remote-pull.js +62 -13
- package/dist/remote-pull.js.map +1 -1
- package/dist/remote-pull.test.js +189 -0
- package/dist/remote-pull.test.js.map +1 -1
- package/dist/s3.d.ts +2 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +197 -116
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +109 -0
- package/dist/s3.test.js.map +1 -1
- package/dist/scope-shrink.d.ts +3 -2
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +1 -1
- package/dist/scope-shrink.js.map +1 -1
- package/dist/skill-telemetry.d.ts +1 -1
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +69 -9
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +86 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/event-sync.d.ts +6 -0
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +34 -1
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +73 -0
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/metrics.d.ts +17 -1
- package/dist/sync/metrics.d.ts.map +1 -1
- package/dist/sync/metrics.js +32 -1
- package/dist/sync/metrics.js.map +1 -1
- package/dist/sync/metrics.test.js +74 -1
- package/dist/sync/metrics.test.js.map +1 -1
- package/dist/sync/pull-scope.d.ts.map +1 -1
- package/dist/sync/pull-scope.js +15 -7
- package/dist/sync/pull-scope.js.map +1 -1
- package/dist/sync/push-receiver.d.ts +6 -5
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +13 -15
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +36 -1
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/telemetry.d.ts +1 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +59 -6
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +74 -0
- package/dist/telemetry.test.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/watcher.d.ts +36 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +152 -30
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +103 -0
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +396 -11
- package/src/bin/sync-runner.ts +254 -52
- package/src/cli/reindex.test.ts +47 -1
- package/src/cli/reindex.ts +17 -1
- package/src/cli/rescue-classify-ordering.test.ts +61 -0
- package/src/cli/rescue-core.ts +261 -15
- package/src/cli/rescue-exec-bit-preserve.test.ts +187 -0
- package/src/cli/share.test.ts +38 -0
- package/src/cli/share.ts +103 -34
- package/src/cli/sync.test.ts +594 -1
- package/src/cli/sync.ts +229 -65
- package/src/cognito-auth.test.ts +77 -0
- package/src/cognito-auth.ts +73 -11
- package/src/index.ts +8 -0
- package/src/journal.test.ts +72 -0
- package/src/journal.ts +95 -8
- package/src/machine-auth.test.ts +64 -2
- package/src/object-io.test.ts +142 -0
- package/src/object-io.ts +182 -30
- package/src/operation-lock.test.ts +63 -4
- package/src/operation-lock.ts +99 -31
- package/src/personal-vault.test.ts +42 -0
- package/src/personal-vault.ts +18 -3
- package/src/prefix-coalesce.test.ts +71 -1
- package/src/prefix-coalesce.ts +155 -30
- package/src/remote-pull.test.ts +205 -0
- package/src/remote-pull.ts +77 -14
- package/src/s3.test.ts +126 -0
- package/src/s3.ts +237 -122
- package/src/scope-shrink.ts +6 -3
- package/src/skill-telemetry.test.ts +109 -0
- package/src/skill-telemetry.ts +82 -14
- package/src/sync/event-sync.test.ts +75 -0
- package/src/sync/event-sync.ts +54 -1
- package/src/sync/metrics.test.ts +81 -0
- package/src/sync/metrics.ts +59 -4
- package/src/sync/pull-scope.ts +23 -7
- package/src/sync/push-receiver.test.ts +38 -1
- package/src/sync/push-receiver.ts +15 -18
- package/src/telemetry.test.ts +85 -0
- package/src/telemetry.ts +69 -6
- package/src/types.ts +8 -0
- package/src/watcher.test.ts +117 -0
- package/src/watcher.ts +209 -33
package/src/personal-vault.ts
CHANGED
|
@@ -94,6 +94,15 @@ export interface PersonalVaultOptions {
|
|
|
94
94
|
* hqRoot returns []; callers treat that as "no personal content to push"
|
|
95
95
|
* rather than a hard error.
|
|
96
96
|
*/
|
|
97
|
+
/**
|
|
98
|
+
* S3 key (hq-root-relative, forward-slash) of the companies manifest — the
|
|
99
|
+
* routing source-of-truth — carved into the personal vault even though
|
|
100
|
+
* `companies/` is otherwise excluded. Exported so the PULL plan applies the
|
|
101
|
+
* SAME exemption: skipping it on the pull leaves it unjournaled, which re-fires
|
|
102
|
+
* a transient push-side conflict every sync (no journal baseline).
|
|
103
|
+
*/
|
|
104
|
+
export const PERSONAL_VAULT_MANIFEST_KEY = "companies/manifest.yaml";
|
|
105
|
+
|
|
97
106
|
export function computePersonalVaultPaths(
|
|
98
107
|
hqRoot: string,
|
|
99
108
|
opts: PersonalVaultOptions = {},
|
|
@@ -114,7 +123,7 @@ export function computePersonalVaultPaths(
|
|
|
114
123
|
// because the parent `companies/` is in PERSONAL_VAULT_EXCLUDED_TOP_LEVEL
|
|
115
124
|
// (we never enumerate the whole companies tree wholesale).
|
|
116
125
|
const manifest: string[] = [];
|
|
117
|
-
const manifestPath = path.join(hqRoot,
|
|
126
|
+
const manifestPath = path.join(hqRoot, PERSONAL_VAULT_MANIFEST_KEY);
|
|
118
127
|
try {
|
|
119
128
|
if (fs.statSync(manifestPath).isFile()) {
|
|
120
129
|
manifest.push(manifestPath);
|
|
@@ -203,8 +212,14 @@ export function computeContinuityPointerPaths(hqRoot: string): string[] {
|
|
|
203
212
|
p === resolvedThreads || p.startsWith(resolvedThreads + path.sep);
|
|
204
213
|
if (!withinThreads(candidate)) return out;
|
|
205
214
|
try {
|
|
206
|
-
const
|
|
207
|
-
|
|
215
|
+
const realHqRoot = fs.realpathSync.native(hqRoot);
|
|
216
|
+
const expectedRealThreads = path.join(realHqRoot, "workspace", "threads");
|
|
217
|
+
const realThreads = fs.realpathSync.native(threadsDir);
|
|
218
|
+
if (realThreads !== expectedRealThreads) return out;
|
|
219
|
+
const withinRealThreads = (p: string): boolean =>
|
|
220
|
+
p === realThreads || p.startsWith(realThreads + path.sep);
|
|
221
|
+
const real = fs.realpathSync.native(candidate);
|
|
222
|
+
if (!withinRealThreads(real)) return out;
|
|
208
223
|
if (!fs.statSync(real).isFile()) return out;
|
|
209
224
|
} catch {
|
|
210
225
|
// Pointer references a thread file that doesn't exist here — skip it.
|
|
@@ -3,6 +3,8 @@ import {
|
|
|
3
3
|
coalescePrefixes,
|
|
4
4
|
isCoveredByAny,
|
|
5
5
|
grantPathToPrefix,
|
|
6
|
+
grantPathToScopePrefix,
|
|
7
|
+
toScopePrefixEntries,
|
|
6
8
|
} from "./prefix-coalesce.js";
|
|
7
9
|
|
|
8
10
|
describe("coalescePrefixes", () => {
|
|
@@ -74,6 +76,12 @@ describe("coalescePrefixes", () => {
|
|
|
74
76
|
// prefixes — the grants endpoint always does.)
|
|
75
77
|
expect(coalescePrefixes(["a/", "ab/"]).sort()).toEqual(["a/", "ab/"]);
|
|
76
78
|
});
|
|
79
|
+
|
|
80
|
+
it("F16: keeps exact-file scopes distinct from prefixed sibling keys", () => {
|
|
81
|
+
expect(
|
|
82
|
+
coalescePrefixes(["knowledge/README.md", "knowledge/README.md.bak"]),
|
|
83
|
+
).toEqual(["knowledge/README.md", "knowledge/README.md.bak"]);
|
|
84
|
+
});
|
|
77
85
|
});
|
|
78
86
|
|
|
79
87
|
describe("isCoveredByAny", () => {
|
|
@@ -121,7 +129,11 @@ describe("grantPathToPrefix", () => {
|
|
|
121
129
|
});
|
|
122
130
|
|
|
123
131
|
it("folds a trailing bare * into its parent prefix", () => {
|
|
124
|
-
|
|
132
|
+
const prefix = grantPathToPrefix("reports*", slug);
|
|
133
|
+
expect(toScopePrefixEntries([prefix])).toEqual([
|
|
134
|
+
{ prefix: "reports", match: "prefix" },
|
|
135
|
+
]);
|
|
136
|
+
expect(isCoveredByAny("reports-q1.md", [prefix])).toBe(true);
|
|
125
137
|
});
|
|
126
138
|
|
|
127
139
|
it("leaves a company-relative directory prefix unchanged", () => {
|
|
@@ -167,4 +179,62 @@ describe("grantPathToPrefix", () => {
|
|
|
167
179
|
expect(isCoveredByAny("secrets/db.json", prefixes)).toBe(false);
|
|
168
180
|
expect(isCoveredByAny("knowledge/OTHER.md", prefixes)).toBe(false);
|
|
169
181
|
});
|
|
182
|
+
|
|
183
|
+
it("F16: exact-file grants do not authorize prefixed sibling keys", () => {
|
|
184
|
+
const grants = [
|
|
185
|
+
"companies/indigo/knowledge/README.md",
|
|
186
|
+
"company.yaml",
|
|
187
|
+
"data/vyg/*",
|
|
188
|
+
];
|
|
189
|
+
const prefixes = coalescePrefixes(grants.map((g) => grantPathToPrefix(g, slug)));
|
|
190
|
+
|
|
191
|
+
expect(isCoveredByAny("knowledge/README.md", prefixes)).toBe(true);
|
|
192
|
+
expect(isCoveredByAny("company.yaml", prefixes)).toBe(true);
|
|
193
|
+
expect(isCoveredByAny("data/vyg/2026/q1.md", prefixes)).toBe(true);
|
|
194
|
+
|
|
195
|
+
expect(isCoveredByAny("knowledge/README.md.bak", prefixes)).toBe(false);
|
|
196
|
+
expect(isCoveredByAny("company.yaml.tmp", prefixes)).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("R-F16: exact scope is deterministic after a prior bare-glob scope", () => {
|
|
200
|
+
expect(toScopePrefixEntries([grantPathToPrefix("companies/indigo/foo*", slug)])).toEqual([
|
|
201
|
+
{ prefix: "foo", match: "prefix" },
|
|
202
|
+
]);
|
|
203
|
+
|
|
204
|
+
const exactPrefixes = coalescePrefixes([
|
|
205
|
+
grantPathToPrefix("companies/other/foo", "other"),
|
|
206
|
+
]);
|
|
207
|
+
|
|
208
|
+
expect(isCoveredByAny("foo", exactPrefixes)).toBe(true);
|
|
209
|
+
expect(isCoveredByAny("foo.bak", exactPrefixes)).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("RF-F16DUR: exact-vs-prefix survives array copy and JSON round-trip", () => {
|
|
213
|
+
const priorBareGlob = coalescePrefixes([
|
|
214
|
+
grantPathToScopePrefix("companies/indigo/foo*", slug),
|
|
215
|
+
]);
|
|
216
|
+
const copiedBareGlob = [...priorBareGlob];
|
|
217
|
+
const jsonBareGlob = JSON.parse(JSON.stringify(priorBareGlob)) as string[];
|
|
218
|
+
|
|
219
|
+
expect(jsonBareGlob).toEqual(priorBareGlob);
|
|
220
|
+
expect(toScopePrefixEntries(copiedBareGlob)).toEqual([
|
|
221
|
+
{ prefix: "foo", match: "prefix" },
|
|
222
|
+
]);
|
|
223
|
+
expect(toScopePrefixEntries(jsonBareGlob)).toEqual([
|
|
224
|
+
{ prefix: "foo", match: "prefix" },
|
|
225
|
+
]);
|
|
226
|
+
expect(isCoveredByAny("foo.bak", copiedBareGlob)).toBe(true);
|
|
227
|
+
expect(isCoveredByAny("foo.bak", jsonBareGlob)).toBe(true);
|
|
228
|
+
|
|
229
|
+
const exactAfterPriorGlob = coalescePrefixes([
|
|
230
|
+
grantPathToScopePrefix("companies/indigo/foo", slug),
|
|
231
|
+
]);
|
|
232
|
+
const jsonExact = JSON.parse(JSON.stringify(exactAfterPriorGlob)) as string[];
|
|
233
|
+
|
|
234
|
+
expect(toScopePrefixEntries(jsonExact)).toEqual([
|
|
235
|
+
{ prefix: "foo", match: "exact" },
|
|
236
|
+
]);
|
|
237
|
+
expect(isCoveredByAny("foo", jsonExact)).toBe(true);
|
|
238
|
+
expect(isCoveredByAny("foo.bak", jsonExact)).toBe(false);
|
|
239
|
+
});
|
|
170
240
|
});
|
package/src/prefix-coalesce.ts
CHANGED
|
@@ -21,18 +21,138 @@
|
|
|
21
21
|
* - **Determinism:** output is sorted lexicographically so the journal's
|
|
22
22
|
* `prefixSet` stays diff-stable across runs.
|
|
23
23
|
*
|
|
24
|
-
* Coverage rule:
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
24
|
+
* Coverage rule: exact-file entries cover only themselves. Directory prefixes
|
|
25
|
+
* (ending in `/`) and bare glob prefixes cover descendants with literal
|
|
26
|
+
* `startsWith` matching. When a string is ambiguous (e.g. `foo` from `foo*`,
|
|
27
|
+
* which must be a prefix, not an exact file), the coalesced `prefixSet` stores
|
|
28
|
+
* a small in-band marker in the string itself so the match type survives
|
|
29
|
+
* `[...prefixSet]` copies and JSON persistence.
|
|
28
30
|
*/
|
|
29
31
|
|
|
30
|
-
export
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
export type ScopePrefixMatch = "exact" | "prefix";
|
|
33
|
+
|
|
34
|
+
export interface ScopePrefixEntry {
|
|
35
|
+
prefix: string;
|
|
36
|
+
match: ScopePrefixMatch;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type ScopePrefixInput = string | ScopePrefixEntry;
|
|
40
|
+
|
|
41
|
+
const SCOPE_PREFIX_MARKER = "\u0000hq-scope:";
|
|
42
|
+
const SCOPE_PREFIX_MARKER_PREFIX = `${SCOPE_PREFIX_MARKER}prefix`;
|
|
43
|
+
const SCOPE_PREFIX_MARKER_EXACT = `${SCOPE_PREFIX_MARKER}exact`;
|
|
44
|
+
|
|
45
|
+
function isScopePrefixEntry(value: ScopePrefixInput): value is ScopePrefixEntry {
|
|
46
|
+
return typeof value !== "string";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function inferStringMatch(prefix: string): ScopePrefixMatch {
|
|
50
|
+
return prefix === "" || prefix.endsWith("/") ? "prefix" : "exact";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseStringPrefix(input: string): ScopePrefixEntry {
|
|
54
|
+
if (input.endsWith(SCOPE_PREFIX_MARKER_PREFIX)) {
|
|
55
|
+
return {
|
|
56
|
+
prefix: input.slice(0, -SCOPE_PREFIX_MARKER_PREFIX.length),
|
|
57
|
+
match: "prefix",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (input.endsWith(SCOPE_PREFIX_MARKER_EXACT)) {
|
|
61
|
+
return {
|
|
62
|
+
prefix: input.slice(0, -SCOPE_PREFIX_MARKER_EXACT.length),
|
|
63
|
+
match: "exact",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
prefix: input,
|
|
68
|
+
match: inferStringMatch(input),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function encodeScopePrefix(entry: ScopePrefixEntry): string {
|
|
73
|
+
return entry.match === inferStringMatch(entry.prefix)
|
|
74
|
+
? entry.prefix
|
|
75
|
+
: `${entry.prefix}${SCOPE_PREFIX_MARKER}${entry.match}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalizeScopePrefix(input: ScopePrefixInput): ScopePrefixEntry | null {
|
|
79
|
+
if (typeof input === "string") {
|
|
80
|
+
return parseStringPrefix(input);
|
|
81
|
+
}
|
|
82
|
+
if (typeof input.prefix !== "string") return null;
|
|
83
|
+
return {
|
|
84
|
+
prefix: input.prefix,
|
|
85
|
+
match: input.match === "prefix" ? "prefix" : "exact",
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function materializeScopePrefixes(
|
|
90
|
+
prefixes: readonly ScopePrefixInput[],
|
|
91
|
+
): ScopePrefixEntry[] {
|
|
92
|
+
const entries: ScopePrefixEntry[] = [];
|
|
33
93
|
for (const p of prefixes) {
|
|
34
|
-
|
|
35
|
-
|
|
94
|
+
const entry = normalizeScopePrefix(p);
|
|
95
|
+
if (entry) entries.push(entry);
|
|
96
|
+
}
|
|
97
|
+
return entries;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function compareEntries(a: ScopePrefixEntry, b: ScopePrefixEntry): number {
|
|
101
|
+
if (a.prefix < b.prefix) return -1;
|
|
102
|
+
if (a.prefix > b.prefix) return 1;
|
|
103
|
+
if (a.match === b.match) return 0;
|
|
104
|
+
return a.match === "prefix" ? -1 : 1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function entryCoversPath(entry: ScopePrefixEntry, key: string): boolean {
|
|
108
|
+
if (entry.prefix === "") return true;
|
|
109
|
+
if (key === entry.prefix) return true;
|
|
110
|
+
return entry.match === "prefix" && key.startsWith(entry.prefix);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function entryCoversEntry(
|
|
114
|
+
entry: ScopePrefixEntry,
|
|
115
|
+
candidate: ScopePrefixEntry,
|
|
116
|
+
): boolean {
|
|
117
|
+
if (entry.prefix === "") return true;
|
|
118
|
+
if (entry.match === "prefix") {
|
|
119
|
+
return (
|
|
120
|
+
candidate.prefix === entry.prefix ||
|
|
121
|
+
candidate.prefix.startsWith(entry.prefix)
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return candidate.match === "exact" && candidate.prefix === entry.prefix;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function toScopePrefixEntries(
|
|
128
|
+
prefixSet: readonly ScopePrefixInput[],
|
|
129
|
+
): ScopePrefixEntry[] {
|
|
130
|
+
return materializeScopePrefixes(prefixSet);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function pathToScopePrefix(path: string): ScopePrefixEntry {
|
|
134
|
+
const p = (path ?? "").replace(/^\/+/, "");
|
|
135
|
+
if (
|
|
136
|
+
p.endsWith(SCOPE_PREFIX_MARKER_PREFIX) ||
|
|
137
|
+
p.endsWith(SCOPE_PREFIX_MARKER_EXACT)
|
|
138
|
+
) {
|
|
139
|
+
return parseStringPrefix(p);
|
|
140
|
+
}
|
|
141
|
+
if (p === "*" || p === "") return { prefix: "", match: "prefix" };
|
|
142
|
+
if (p.endsWith("/*")) return { prefix: p.slice(0, -1), match: "prefix" };
|
|
143
|
+
if (p.endsWith("*")) return { prefix: p.slice(0, -1), match: "prefix" };
|
|
144
|
+
if (p.endsWith("/")) return { prefix: p, match: "prefix" };
|
|
145
|
+
return { prefix: p, match: "exact" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function coalescePrefixes(
|
|
149
|
+
prefixes: readonly ScopePrefixInput[],
|
|
150
|
+
): string[] {
|
|
151
|
+
// Dedup + drop empties.
|
|
152
|
+
const unique = new Map<string, ScopePrefixEntry>();
|
|
153
|
+
for (const entry of materializeScopePrefixes(prefixes)) {
|
|
154
|
+
if (entry.prefix !== "") {
|
|
155
|
+
unique.set(`${entry.match}\0${entry.prefix}`, entry);
|
|
36
156
|
}
|
|
37
157
|
}
|
|
38
158
|
if (unique.size === 0) return [];
|
|
@@ -40,33 +160,33 @@ export function coalescePrefixes(prefixes: readonly string[]): string[] {
|
|
|
40
160
|
// Sort lexicographically so a broader prefix (`a/`) appears before its
|
|
41
161
|
// narrower descendants (`a/b/`). Then a single pass keeps the broadest in
|
|
42
162
|
// each cover chain.
|
|
43
|
-
const sorted = [...unique].sort();
|
|
44
|
-
const result:
|
|
45
|
-
let lastKept:
|
|
46
|
-
for (const
|
|
47
|
-
if (lastKept !== null &&
|
|
163
|
+
const sorted = [...unique.values()].sort(compareEntries);
|
|
164
|
+
const result: ScopePrefixEntry[] = [];
|
|
165
|
+
let lastKept: ScopePrefixEntry | null = null;
|
|
166
|
+
for (const entry of sorted) {
|
|
167
|
+
if (lastKept !== null && entryCoversEntry(lastKept, entry)) {
|
|
48
168
|
// `lastKept` already covers `p`; skip.
|
|
49
169
|
continue;
|
|
50
170
|
}
|
|
51
|
-
result.push(
|
|
52
|
-
lastKept =
|
|
171
|
+
result.push(entry);
|
|
172
|
+
lastKept = entry;
|
|
53
173
|
}
|
|
54
|
-
return result;
|
|
174
|
+
return result.map(encodeScopePrefix);
|
|
55
175
|
}
|
|
56
176
|
|
|
57
177
|
/**
|
|
58
178
|
* Predicate companion: does any prefix in `prefixSet` cover `path`?
|
|
59
179
|
*
|
|
60
180
|
* Used by the journal scope-shrink algorithm to test whether a journaled
|
|
61
|
-
* file is still in scope under the current pull's `prefixSet`.
|
|
62
|
-
*
|
|
181
|
+
* file is still in scope under the current pull's `prefixSet`. Exact-file
|
|
182
|
+
* entries match only themselves; directory/glob prefixes use `startsWith`.
|
|
63
183
|
*/
|
|
64
184
|
export function isCoveredByAny(
|
|
65
185
|
path: string,
|
|
66
|
-
prefixSet: readonly
|
|
186
|
+
prefixSet: readonly ScopePrefixInput[],
|
|
67
187
|
): boolean {
|
|
68
|
-
for (const
|
|
69
|
-
if (
|
|
188
|
+
for (const entry of materializeScopePrefixes(prefixSet)) {
|
|
189
|
+
if (entryCoversPath(entry, path)) return true;
|
|
70
190
|
}
|
|
71
191
|
return false;
|
|
72
192
|
}
|
|
@@ -94,13 +214,14 @@ export function isCoveredByAny(
|
|
|
94
214
|
*/
|
|
95
215
|
export function isDirInScope(
|
|
96
216
|
relDir: string,
|
|
97
|
-
prefixSet: readonly
|
|
217
|
+
prefixSet: readonly ScopePrefixInput[],
|
|
98
218
|
): boolean {
|
|
99
219
|
const dir = relDir === "" || relDir.endsWith("/") ? relDir : relDir + "/";
|
|
100
|
-
for (const
|
|
220
|
+
for (const entry of materializeScopePrefixes(prefixSet)) {
|
|
221
|
+
const p = entry.prefix;
|
|
101
222
|
if (p === "") return true; // full scope
|
|
102
223
|
if (dir === "") return true; // company root — descend to reach grants
|
|
103
|
-
if (dir.startsWith(p)) return true; // dir is inside a granted prefix
|
|
224
|
+
if (entry.match === "prefix" && dir.startsWith(p)) return true; // dir is inside a granted prefix
|
|
104
225
|
if (p.startsWith(dir)) return true; // a granted prefix is inside dir
|
|
105
226
|
}
|
|
106
227
|
return false;
|
|
@@ -134,7 +255,10 @@ export function isDirInScope(
|
|
|
134
255
|
* `syncMode: shared` would download nothing and prune everything the caller
|
|
135
256
|
* actually has access to. See the live grant dump in the hq-pro vault.
|
|
136
257
|
*/
|
|
137
|
-
export function
|
|
258
|
+
export function grantPathToScopePrefix(
|
|
259
|
+
grantPath: string,
|
|
260
|
+
slug: string,
|
|
261
|
+
): ScopePrefixEntry {
|
|
138
262
|
let p = (grantPath ?? "").replace(/^\/+/, "");
|
|
139
263
|
// 1. Strip the company anchor, if present.
|
|
140
264
|
const companyAnchor = `companies/${slug}/`;
|
|
@@ -145,8 +269,9 @@ export function grantPathToPrefix(grantPath: string, slug: string): string {
|
|
|
145
269
|
p = p.slice(slugAnchor.length);
|
|
146
270
|
}
|
|
147
271
|
// 2. Fold the ACL glob suffix into a startsWith prefix.
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
272
|
+
return pathToScopePrefix(p);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function grantPathToPrefix(grantPath: string, slug: string): string {
|
|
276
|
+
return encodeScopePrefix(grantPathToScopePrefix(grantPath, slug));
|
|
152
277
|
}
|
package/src/remote-pull.test.ts
CHANGED
|
@@ -157,6 +157,111 @@ describe("decideRemotePulls", () => {
|
|
|
157
157
|
expect(result.download).toEqual([]);
|
|
158
158
|
});
|
|
159
159
|
|
|
160
|
+
it("F07: excludes scope-shrink tombstones from remote-delete decisions", () => {
|
|
161
|
+
const journal: SyncJournal = {
|
|
162
|
+
version: "2",
|
|
163
|
+
lastSync: "2026-05-01T00:00:00Z",
|
|
164
|
+
files: {
|
|
165
|
+
"docs/scope-shrunk-dirty.md": {
|
|
166
|
+
hash: "dirty-before-shrink",
|
|
167
|
+
size: 42,
|
|
168
|
+
syncedAt: "2026-05-01T00:00:00Z",
|
|
169
|
+
direction: "down",
|
|
170
|
+
remoteEtag: "old-dirty",
|
|
171
|
+
removedAt: "2026-05-02T00:00:00Z",
|
|
172
|
+
removedReason: "scope_shrink",
|
|
173
|
+
},
|
|
174
|
+
"docs/live-gone.md": {
|
|
175
|
+
hash: "h",
|
|
176
|
+
size: 100,
|
|
177
|
+
syncedAt: "2026-05-01T00:00:00Z",
|
|
178
|
+
direction: "down",
|
|
179
|
+
remoteEtag: "old-live",
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
pulls: [],
|
|
183
|
+
};
|
|
184
|
+
const result = decideRemotePulls({
|
|
185
|
+
remoteFiles: [],
|
|
186
|
+
journal,
|
|
187
|
+
conflictKeys: new Set(),
|
|
188
|
+
});
|
|
189
|
+
expect(result.deleteLocal).toEqual(["docs/live-gone.md"]);
|
|
190
|
+
expect(result.download).toEqual([]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("R-F07: excludes scope-shrink-protected survivors from remote-delete decisions", async () => {
|
|
194
|
+
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rf07-"));
|
|
195
|
+
try {
|
|
196
|
+
const journal: SyncJournal = {
|
|
197
|
+
version: "2",
|
|
198
|
+
lastSync: "2026-05-01T00:00:00Z",
|
|
199
|
+
files: {
|
|
200
|
+
"companies/indigo/projects/mine.md": {
|
|
201
|
+
hash: "h-mine",
|
|
202
|
+
size: 6,
|
|
203
|
+
syncedAt: "2026-05-01T00:00:00Z",
|
|
204
|
+
direction: "down",
|
|
205
|
+
remoteEtag: "old-mine",
|
|
206
|
+
createdBySub: "owner-sub",
|
|
207
|
+
},
|
|
208
|
+
"companies/indigo/projects/legacy.md": {
|
|
209
|
+
hash: "h-legacy",
|
|
210
|
+
size: 6,
|
|
211
|
+
syncedAt: "2026-05-01T00:00:00Z",
|
|
212
|
+
direction: "down",
|
|
213
|
+
remoteEtag: "old-legacy",
|
|
214
|
+
},
|
|
215
|
+
"companies/indigo/meetings/gone.md": {
|
|
216
|
+
hash: "h-gone",
|
|
217
|
+
size: 4,
|
|
218
|
+
syncedAt: "2026-05-01T00:00:00Z",
|
|
219
|
+
direction: "down",
|
|
220
|
+
remoteEtag: "old-gone",
|
|
221
|
+
createdBySub: "peer-sub",
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
pulls: [
|
|
225
|
+
{
|
|
226
|
+
pullId: "01PREV",
|
|
227
|
+
companyUid: "cmp_indigo",
|
|
228
|
+
startedAt: "2026-05-19T00:00:00.000Z",
|
|
229
|
+
completedAt: "2026-05-19T00:00:05.000Z",
|
|
230
|
+
syncMode: "all",
|
|
231
|
+
prefixSet: ["companies/indigo/"],
|
|
232
|
+
scopeChangeDetected: false,
|
|
233
|
+
orphansRemoved: 0,
|
|
234
|
+
orphansBlocked: 0,
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const result = await pullCompany({
|
|
240
|
+
ctx: makeCtx(),
|
|
241
|
+
journal,
|
|
242
|
+
hqRoot,
|
|
243
|
+
callerSub: "owner-sub",
|
|
244
|
+
scope: {
|
|
245
|
+
companyUid: "cmp_indigo",
|
|
246
|
+
syncMode: "shared",
|
|
247
|
+
prefixSet: ["companies/indigo/meetings/"],
|
|
248
|
+
strategy: "vend-fanout",
|
|
249
|
+
},
|
|
250
|
+
listFn: async () => [],
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
expect(result.decision.deleteLocal).toEqual([
|
|
254
|
+
"companies/indigo/meetings/gone.md",
|
|
255
|
+
]);
|
|
256
|
+
expect(result.decision.skip.map((f) => f.key).sort()).toEqual([
|
|
257
|
+
"companies/indigo/projects/legacy.md",
|
|
258
|
+
"companies/indigo/projects/mine.md",
|
|
259
|
+
]);
|
|
260
|
+
} finally {
|
|
261
|
+
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
160
265
|
it("never deletes locally for entries that have NEVER been synced down", () => {
|
|
161
266
|
// Push-only entries (direction: 'up') represent files we created locally
|
|
162
267
|
// and uploaded. If the user later deletes them remotely from another
|
|
@@ -521,6 +626,28 @@ describe("listRemoteForScope", () => {
|
|
|
521
626
|
]);
|
|
522
627
|
});
|
|
523
628
|
|
|
629
|
+
it("R-F16: strategy=vend-fanout post-filters exact-grant siblings", async () => {
|
|
630
|
+
const scope = resolveCompanyScope({
|
|
631
|
+
companyUid: "cmp_indigo",
|
|
632
|
+
companyPrefix: "companies/indigo/",
|
|
633
|
+
syncConfig: makeSyncConfig({ syncMode: "shared" }),
|
|
634
|
+
explicitGrants: [makeGrant("companies/indigo/README.md")],
|
|
635
|
+
});
|
|
636
|
+
const files = await listRemoteForScope({
|
|
637
|
+
ctx: makeCtx(),
|
|
638
|
+
scope,
|
|
639
|
+
listFn: async (_ctx, prefix) => {
|
|
640
|
+
expect(prefix).toBe("companies/indigo/README.md");
|
|
641
|
+
return [
|
|
642
|
+
remote({ key: "companies/indigo/README.md" }),
|
|
643
|
+
remote({ key: "companies/indigo/README.md.bak" }),
|
|
644
|
+
];
|
|
645
|
+
},
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
expect(files.map((f) => f.key)).toEqual(["companies/indigo/README.md"]);
|
|
649
|
+
});
|
|
650
|
+
|
|
524
651
|
it("strategy=vend-fanout short-circuits to [] on empty prefixSet", async () => {
|
|
525
652
|
let listed = false;
|
|
526
653
|
const files = await listRemoteForScope({
|
|
@@ -859,6 +986,84 @@ describe("pullCompany (engine orchestrator)", () => {
|
|
|
859
986
|
expect(fs.existsSync(legacyAbs)).toBe(true); // legacy file retained
|
|
860
987
|
});
|
|
861
988
|
|
|
989
|
+
it("RF-F07DUR: scope-shrink-protected survivors are excluded on a second pull", async () => {
|
|
990
|
+
const journal: SyncJournal = {
|
|
991
|
+
version: "2",
|
|
992
|
+
lastSync: "",
|
|
993
|
+
files: {
|
|
994
|
+
"companies/indigo/projects/legacy.md": {
|
|
995
|
+
hash: "h-legacy",
|
|
996
|
+
size: 6,
|
|
997
|
+
syncedAt: "2026-05-01T00:00:00.000Z",
|
|
998
|
+
direction: "down",
|
|
999
|
+
remoteEtag: "old-legacy",
|
|
1000
|
+
},
|
|
1001
|
+
"companies/indigo/meetings/gone.md": {
|
|
1002
|
+
hash: "h-gone",
|
|
1003
|
+
size: 4,
|
|
1004
|
+
syncedAt: "2026-05-01T00:00:00.000Z",
|
|
1005
|
+
direction: "down",
|
|
1006
|
+
remoteEtag: "old-gone",
|
|
1007
|
+
createdBySub: "peer-sub",
|
|
1008
|
+
},
|
|
1009
|
+
},
|
|
1010
|
+
pulls: [
|
|
1011
|
+
{
|
|
1012
|
+
pullId: "01PREV",
|
|
1013
|
+
companyUid: "cmp_indigo",
|
|
1014
|
+
startedAt: "2026-05-19T00:00:00.000Z",
|
|
1015
|
+
completedAt: "2026-05-19T00:00:05.000Z",
|
|
1016
|
+
syncMode: "all",
|
|
1017
|
+
prefixSet: ["companies/indigo/"],
|
|
1018
|
+
scopeChangeDetected: false,
|
|
1019
|
+
orphansRemoved: 0,
|
|
1020
|
+
orphansBlocked: 0,
|
|
1021
|
+
},
|
|
1022
|
+
],
|
|
1023
|
+
};
|
|
1024
|
+
const narrowedScope = {
|
|
1025
|
+
companyUid: "cmp_indigo",
|
|
1026
|
+
syncMode: "shared" as const,
|
|
1027
|
+
prefixSet: ["companies/indigo/meetings/"],
|
|
1028
|
+
strategy: "vend-fanout" as const,
|
|
1029
|
+
};
|
|
1030
|
+
|
|
1031
|
+
const first = await pullCompany({
|
|
1032
|
+
ctx: makeCtx(),
|
|
1033
|
+
journal,
|
|
1034
|
+
hqRoot,
|
|
1035
|
+
callerSub: "owner-sub",
|
|
1036
|
+
scope: narrowedScope,
|
|
1037
|
+
listFn: async () => [],
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
expect(first.decision.skip.map((f) => f.key)).toContain(
|
|
1041
|
+
"companies/indigo/projects/legacy.md",
|
|
1042
|
+
);
|
|
1043
|
+
expect(first.decision.deleteLocal).toEqual([
|
|
1044
|
+
"companies/indigo/meetings/gone.md",
|
|
1045
|
+
]);
|
|
1046
|
+
expect(
|
|
1047
|
+
journal.files["companies/indigo/projects/legacy.md"]?.outOfScopeProtected,
|
|
1048
|
+
).toBe(true);
|
|
1049
|
+
|
|
1050
|
+
const second = await pullCompany({
|
|
1051
|
+
ctx: makeCtx(),
|
|
1052
|
+
journal,
|
|
1053
|
+
hqRoot,
|
|
1054
|
+
callerSub: "owner-sub",
|
|
1055
|
+
scope: narrowedScope,
|
|
1056
|
+
listFn: async () => [],
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
expect(second.decision.skip.map((f) => f.key)).toContain(
|
|
1060
|
+
"companies/indigo/projects/legacy.md",
|
|
1061
|
+
);
|
|
1062
|
+
expect(second.decision.deleteLocal).toEqual([
|
|
1063
|
+
"companies/indigo/meetings/gone.md",
|
|
1064
|
+
]);
|
|
1065
|
+
});
|
|
1066
|
+
|
|
862
1067
|
it("GC's expired tombstones at the start of every leg", async () => {
|
|
863
1068
|
const old = new Date(
|
|
864
1069
|
Date.now() - 31 * 24 * 60 * 60 * 1000,
|