@indigoai-us/hq-cloud 6.11.11 → 6.11.13
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-company.d.ts +35 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -0
- package/dist/bin/sync-runner-company.js +290 -0
- package/dist/bin/sync-runner-company.js.map +1 -0
- package/dist/bin/sync-runner-events.d.ts +12 -0
- package/dist/bin/sync-runner-events.d.ts.map +1 -0
- package/dist/bin/sync-runner-events.js +12 -0
- package/dist/bin/sync-runner-events.js.map +1 -0
- package/dist/bin/sync-runner-planning.d.ts +53 -0
- package/dist/bin/sync-runner-planning.d.ts.map +1 -0
- package/dist/bin/sync-runner-planning.js +59 -0
- package/dist/bin/sync-runner-planning.js.map +1 -0
- package/dist/bin/sync-runner-rollup.d.ts +24 -0
- package/dist/bin/sync-runner-rollup.d.ts.map +1 -0
- package/dist/bin/sync-runner-rollup.js +46 -0
- package/dist/bin/sync-runner-rollup.js.map +1 -0
- package/dist/bin/sync-runner-telemetry.d.ts +5 -0
- package/dist/bin/sync-runner-telemetry.d.ts.map +1 -0
- package/dist/bin/sync-runner-telemetry.js +5 -0
- package/dist/bin/sync-runner-telemetry.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts +17 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -0
- package/dist/bin/sync-runner-watch-loop.js +372 -0
- package/dist/bin/sync-runner-watch-loop.js.map +1 -0
- package/dist/bin/sync-runner-watch-routes.d.ts +25 -0
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -0
- package/dist/bin/sync-runner-watch-routes.js +74 -0
- package/dist/bin/sync-runner-watch-routes.js.map +1 -0
- package/dist/bin/sync-runner.d.ts +5 -54
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +76 -978
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +265 -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 +34 -17
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +39 -5
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/rescue-classify-ordering.test.js +75 -0
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/cli/rescue-core.d.ts +45 -0
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +320 -170
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/share.d.ts +2 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +276 -660
- 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 +541 -748
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +382 -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/daemon-worker.d.ts +2 -2
- package/dist/daemon-worker.js +3 -3
- package/dist/daemon-worker.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 +149 -30
- 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.map +1 -1
- package/dist/personal-vault.js +8 -2
- 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 +8 -3
- package/dist/remote-pull.d.ts.map +1 -1
- package/dist/remote-pull.js +85 -16
- package/dist/remote-pull.js.map +1 -1
- package/dist/remote-pull.test.js +213 -2
- 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 +12 -5
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +45 -17
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +67 -1
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/sync-core.d.ts +27 -0
- package/dist/sync-core.d.ts.map +1 -0
- package/dist/sync-core.js +54 -0
- package/dist/sync-core.js.map +1 -0
- 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/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +284 -36
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +59 -0
- package/dist/vault-client.test.js.map +1 -1
- package/dist/watcher.d.ts +38 -20
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +155 -143
- 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-company.ts +350 -0
- package/src/bin/sync-runner-events.ts +25 -0
- package/src/bin/sync-runner-planning.ts +121 -0
- package/src/bin/sync-runner-rollup.ts +72 -0
- package/src/bin/sync-runner-telemetry.ts +8 -0
- package/src/bin/sync-runner-watch-loop.ts +443 -0
- package/src/bin/sync-runner-watch-routes.ts +86 -0
- package/src/bin/sync-runner.test.ts +298 -11
- package/src/bin/sync-runner.ts +99 -1054
- package/src/cli/reindex.test.ts +41 -3
- package/src/cli/reindex.ts +35 -19
- package/src/cli/rescue-classify-ordering.test.ts +81 -0
- package/src/cli/rescue-core.ts +400 -165
- package/src/cli/share.test.ts +38 -0
- package/src/cli/share.ts +420 -693
- package/src/cli/sync.test.ts +460 -1
- package/src/cli/sync.ts +788 -825
- package/src/cognito-auth.test.ts +77 -0
- package/src/cognito-auth.ts +73 -11
- package/src/daemon-worker.ts +3 -3
- 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 +183 -31
- 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 +8 -2
- package/src/prefix-coalesce.test.ts +71 -1
- package/src/prefix-coalesce.ts +155 -30
- package/src/remote-pull.test.ts +235 -1
- package/src/remote-pull.ts +106 -18
- 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 +73 -1
- package/src/sync/push-receiver.ts +56 -20
- package/src/sync-core.ts +58 -0
- package/src/telemetry.test.ts +85 -0
- package/src/telemetry.ts +69 -6
- package/src/types.ts +8 -0
- package/src/vault-client.test.ts +74 -0
- package/src/vault-client.ts +395 -43
- package/src/watcher.test.ts +117 -0
- package/src/watcher.ts +215 -174
|
@@ -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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Failing-test seed for the Auto-sync (Beta) remote-pull loop.
|
|
3
3
|
*
|
|
4
|
-
* Background:
|
|
4
|
+
* Background: the watcher push path ships local edits to S3 in seconds,
|
|
5
5
|
* but pulls happen only on a manual sync today. Auto-sync adds a periodic
|
|
6
6
|
* (every 10 min) remote-pull pass per company. The decision of *which* keys
|
|
7
7
|
* to download / delete locally / skip is pure given a remote listing, the
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
POST_FILTER_THRESHOLD,
|
|
26
26
|
pullCompany,
|
|
27
27
|
resolveCompanyScope,
|
|
28
|
+
VEND_FANOUT_CONCURRENCY,
|
|
28
29
|
VEND_PATH_CAP,
|
|
29
30
|
} from "./remote-pull.js";
|
|
30
31
|
import type { RemoteFile } from "./s3.js";
|
|
@@ -157,6 +158,111 @@ describe("decideRemotePulls", () => {
|
|
|
157
158
|
expect(result.download).toEqual([]);
|
|
158
159
|
});
|
|
159
160
|
|
|
161
|
+
it("F07: excludes scope-shrink tombstones from remote-delete decisions", () => {
|
|
162
|
+
const journal: SyncJournal = {
|
|
163
|
+
version: "2",
|
|
164
|
+
lastSync: "2026-05-01T00:00:00Z",
|
|
165
|
+
files: {
|
|
166
|
+
"docs/scope-shrunk-dirty.md": {
|
|
167
|
+
hash: "dirty-before-shrink",
|
|
168
|
+
size: 42,
|
|
169
|
+
syncedAt: "2026-05-01T00:00:00Z",
|
|
170
|
+
direction: "down",
|
|
171
|
+
remoteEtag: "old-dirty",
|
|
172
|
+
removedAt: "2026-05-02T00:00:00Z",
|
|
173
|
+
removedReason: "scope_shrink",
|
|
174
|
+
},
|
|
175
|
+
"docs/live-gone.md": {
|
|
176
|
+
hash: "h",
|
|
177
|
+
size: 100,
|
|
178
|
+
syncedAt: "2026-05-01T00:00:00Z",
|
|
179
|
+
direction: "down",
|
|
180
|
+
remoteEtag: "old-live",
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
pulls: [],
|
|
184
|
+
};
|
|
185
|
+
const result = decideRemotePulls({
|
|
186
|
+
remoteFiles: [],
|
|
187
|
+
journal,
|
|
188
|
+
conflictKeys: new Set(),
|
|
189
|
+
});
|
|
190
|
+
expect(result.deleteLocal).toEqual(["docs/live-gone.md"]);
|
|
191
|
+
expect(result.download).toEqual([]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("R-F07: excludes scope-shrink-protected survivors from remote-delete decisions", async () => {
|
|
195
|
+
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rf07-"));
|
|
196
|
+
try {
|
|
197
|
+
const journal: SyncJournal = {
|
|
198
|
+
version: "2",
|
|
199
|
+
lastSync: "2026-05-01T00:00:00Z",
|
|
200
|
+
files: {
|
|
201
|
+
"companies/indigo/projects/mine.md": {
|
|
202
|
+
hash: "h-mine",
|
|
203
|
+
size: 6,
|
|
204
|
+
syncedAt: "2026-05-01T00:00:00Z",
|
|
205
|
+
direction: "down",
|
|
206
|
+
remoteEtag: "old-mine",
|
|
207
|
+
createdBySub: "owner-sub",
|
|
208
|
+
},
|
|
209
|
+
"companies/indigo/projects/legacy.md": {
|
|
210
|
+
hash: "h-legacy",
|
|
211
|
+
size: 6,
|
|
212
|
+
syncedAt: "2026-05-01T00:00:00Z",
|
|
213
|
+
direction: "down",
|
|
214
|
+
remoteEtag: "old-legacy",
|
|
215
|
+
},
|
|
216
|
+
"companies/indigo/meetings/gone.md": {
|
|
217
|
+
hash: "h-gone",
|
|
218
|
+
size: 4,
|
|
219
|
+
syncedAt: "2026-05-01T00:00:00Z",
|
|
220
|
+
direction: "down",
|
|
221
|
+
remoteEtag: "old-gone",
|
|
222
|
+
createdBySub: "peer-sub",
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
pulls: [
|
|
226
|
+
{
|
|
227
|
+
pullId: "01PREV",
|
|
228
|
+
companyUid: "cmp_indigo",
|
|
229
|
+
startedAt: "2026-05-19T00:00:00.000Z",
|
|
230
|
+
completedAt: "2026-05-19T00:00:05.000Z",
|
|
231
|
+
syncMode: "all",
|
|
232
|
+
prefixSet: ["companies/indigo/"],
|
|
233
|
+
scopeChangeDetected: false,
|
|
234
|
+
orphansRemoved: 0,
|
|
235
|
+
orphansBlocked: 0,
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const result = await pullCompany({
|
|
241
|
+
ctx: makeCtx(),
|
|
242
|
+
journal,
|
|
243
|
+
hqRoot,
|
|
244
|
+
callerSub: "owner-sub",
|
|
245
|
+
scope: {
|
|
246
|
+
companyUid: "cmp_indigo",
|
|
247
|
+
syncMode: "shared",
|
|
248
|
+
prefixSet: ["companies/indigo/meetings/"],
|
|
249
|
+
strategy: "vend-fanout",
|
|
250
|
+
},
|
|
251
|
+
listFn: async () => [],
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
expect(result.decision.deleteLocal).toEqual([
|
|
255
|
+
"companies/indigo/meetings/gone.md",
|
|
256
|
+
]);
|
|
257
|
+
expect(result.decision.skip.map((f) => f.key).sort()).toEqual([
|
|
258
|
+
"companies/indigo/projects/legacy.md",
|
|
259
|
+
"companies/indigo/projects/mine.md",
|
|
260
|
+
]);
|
|
261
|
+
} finally {
|
|
262
|
+
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
160
266
|
it("never deletes locally for entries that have NEVER been synced down", () => {
|
|
161
267
|
// Push-only entries (direction: 'up') represent files we created locally
|
|
162
268
|
// and uploaded. If the user later deletes them remotely from another
|
|
@@ -474,6 +580,34 @@ describe("listRemoteForScope", () => {
|
|
|
474
580
|
expect(calls.size).toBe(VEND_PATH_CAP + 3); // every prefix listed
|
|
475
581
|
});
|
|
476
582
|
|
|
583
|
+
it("F28: bounds concurrent per-prefix listings across vend-fanout batches", async () => {
|
|
584
|
+
const prefixes = Array.from(
|
|
585
|
+
{ length: POST_FILTER_THRESHOLD },
|
|
586
|
+
(_, i) => `companies/indigo/p${i}/`,
|
|
587
|
+
);
|
|
588
|
+
let active = 0;
|
|
589
|
+
let maxActive = 0;
|
|
590
|
+
|
|
591
|
+
await listRemoteForScope({
|
|
592
|
+
ctx: makeCtx(),
|
|
593
|
+
scope: {
|
|
594
|
+
companyUid: "cmp_indigo",
|
|
595
|
+
syncMode: "shared",
|
|
596
|
+
prefixSet: prefixes,
|
|
597
|
+
strategy: "vend-fanout",
|
|
598
|
+
},
|
|
599
|
+
listFn: async () => {
|
|
600
|
+
active += 1;
|
|
601
|
+
maxActive = Math.max(maxActive, active);
|
|
602
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
603
|
+
active -= 1;
|
|
604
|
+
return [];
|
|
605
|
+
},
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
expect(maxActive).toBeLessThanOrEqual(VEND_FANOUT_CONCURRENCY);
|
|
609
|
+
});
|
|
610
|
+
|
|
477
611
|
it("strategy=vend-fanout uses vendForBatchFn to narrow credentials per batch", async () => {
|
|
478
612
|
const vendCalls: Array<{ paths: string[] }> = [];
|
|
479
613
|
await listRemoteForScope({
|
|
@@ -521,6 +655,28 @@ describe("listRemoteForScope", () => {
|
|
|
521
655
|
]);
|
|
522
656
|
});
|
|
523
657
|
|
|
658
|
+
it("R-F16: strategy=vend-fanout post-filters exact-grant siblings", async () => {
|
|
659
|
+
const scope = resolveCompanyScope({
|
|
660
|
+
companyUid: "cmp_indigo",
|
|
661
|
+
companyPrefix: "companies/indigo/",
|
|
662
|
+
syncConfig: makeSyncConfig({ syncMode: "shared" }),
|
|
663
|
+
explicitGrants: [makeGrant("companies/indigo/README.md")],
|
|
664
|
+
});
|
|
665
|
+
const files = await listRemoteForScope({
|
|
666
|
+
ctx: makeCtx(),
|
|
667
|
+
scope,
|
|
668
|
+
listFn: async (_ctx, prefix) => {
|
|
669
|
+
expect(prefix).toBe("companies/indigo/README.md");
|
|
670
|
+
return [
|
|
671
|
+
remote({ key: "companies/indigo/README.md" }),
|
|
672
|
+
remote({ key: "companies/indigo/README.md.bak" }),
|
|
673
|
+
];
|
|
674
|
+
},
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
expect(files.map((f) => f.key)).toEqual(["companies/indigo/README.md"]);
|
|
678
|
+
});
|
|
679
|
+
|
|
524
680
|
it("strategy=vend-fanout short-circuits to [] on empty prefixSet", async () => {
|
|
525
681
|
let listed = false;
|
|
526
682
|
const files = await listRemoteForScope({
|
|
@@ -859,6 +1015,84 @@ describe("pullCompany (engine orchestrator)", () => {
|
|
|
859
1015
|
expect(fs.existsSync(legacyAbs)).toBe(true); // legacy file retained
|
|
860
1016
|
});
|
|
861
1017
|
|
|
1018
|
+
it("RF-F07DUR: scope-shrink-protected survivors are excluded on a second pull", async () => {
|
|
1019
|
+
const journal: SyncJournal = {
|
|
1020
|
+
version: "2",
|
|
1021
|
+
lastSync: "",
|
|
1022
|
+
files: {
|
|
1023
|
+
"companies/indigo/projects/legacy.md": {
|
|
1024
|
+
hash: "h-legacy",
|
|
1025
|
+
size: 6,
|
|
1026
|
+
syncedAt: "2026-05-01T00:00:00.000Z",
|
|
1027
|
+
direction: "down",
|
|
1028
|
+
remoteEtag: "old-legacy",
|
|
1029
|
+
},
|
|
1030
|
+
"companies/indigo/meetings/gone.md": {
|
|
1031
|
+
hash: "h-gone",
|
|
1032
|
+
size: 4,
|
|
1033
|
+
syncedAt: "2026-05-01T00:00:00.000Z",
|
|
1034
|
+
direction: "down",
|
|
1035
|
+
remoteEtag: "old-gone",
|
|
1036
|
+
createdBySub: "peer-sub",
|
|
1037
|
+
},
|
|
1038
|
+
},
|
|
1039
|
+
pulls: [
|
|
1040
|
+
{
|
|
1041
|
+
pullId: "01PREV",
|
|
1042
|
+
companyUid: "cmp_indigo",
|
|
1043
|
+
startedAt: "2026-05-19T00:00:00.000Z",
|
|
1044
|
+
completedAt: "2026-05-19T00:00:05.000Z",
|
|
1045
|
+
syncMode: "all",
|
|
1046
|
+
prefixSet: ["companies/indigo/"],
|
|
1047
|
+
scopeChangeDetected: false,
|
|
1048
|
+
orphansRemoved: 0,
|
|
1049
|
+
orphansBlocked: 0,
|
|
1050
|
+
},
|
|
1051
|
+
],
|
|
1052
|
+
};
|
|
1053
|
+
const narrowedScope = {
|
|
1054
|
+
companyUid: "cmp_indigo",
|
|
1055
|
+
syncMode: "shared" as const,
|
|
1056
|
+
prefixSet: ["companies/indigo/meetings/"],
|
|
1057
|
+
strategy: "vend-fanout" as const,
|
|
1058
|
+
};
|
|
1059
|
+
|
|
1060
|
+
const first = await pullCompany({
|
|
1061
|
+
ctx: makeCtx(),
|
|
1062
|
+
journal,
|
|
1063
|
+
hqRoot,
|
|
1064
|
+
callerSub: "owner-sub",
|
|
1065
|
+
scope: narrowedScope,
|
|
1066
|
+
listFn: async () => [],
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
expect(first.decision.skip.map((f) => f.key)).toContain(
|
|
1070
|
+
"companies/indigo/projects/legacy.md",
|
|
1071
|
+
);
|
|
1072
|
+
expect(first.decision.deleteLocal).toEqual([
|
|
1073
|
+
"companies/indigo/meetings/gone.md",
|
|
1074
|
+
]);
|
|
1075
|
+
expect(
|
|
1076
|
+
journal.files["companies/indigo/projects/legacy.md"]?.outOfScopeProtected,
|
|
1077
|
+
).toBe(true);
|
|
1078
|
+
|
|
1079
|
+
const second = await pullCompany({
|
|
1080
|
+
ctx: makeCtx(),
|
|
1081
|
+
journal,
|
|
1082
|
+
hqRoot,
|
|
1083
|
+
callerSub: "owner-sub",
|
|
1084
|
+
scope: narrowedScope,
|
|
1085
|
+
listFn: async () => [],
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
expect(second.decision.skip.map((f) => f.key)).toContain(
|
|
1089
|
+
"companies/indigo/projects/legacy.md",
|
|
1090
|
+
);
|
|
1091
|
+
expect(second.decision.deleteLocal).toEqual([
|
|
1092
|
+
"companies/indigo/meetings/gone.md",
|
|
1093
|
+
]);
|
|
1094
|
+
});
|
|
1095
|
+
|
|
862
1096
|
it("GC's expired tombstones at the start of every leg", async () => {
|
|
863
1097
|
const old = new Date(
|
|
864
1098
|
Date.now() - 31 * 24 * 60 * 60 * 1000,
|