@indigoai-us/hq-cloud 6.2.5 → 6.2.6

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.
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Regression guard: the watch + event-push path must NOT mint phantom
3
+ * `.conflict-*` mirrors for an ordinary single-writer local edit.
4
+ *
5
+ * Background (feedback_ef2b7c8c): a user editing files under
6
+ * `companies/{slug}/` saw the menubar's watch/event-push runner mint a
7
+ * `.conflict-<ts>-<machine>` mirror on EVERY ordinary local Edit/Write —
8
+ * thousands of phantom files in a single session. The watch/event-push runner
9
+ * reacts to a local change by running a targeted `--direction push` pass, i.e.
10
+ * `share()` over the changed company subtree, so the conflict-minting logic is
11
+ * share()'s push-side conflict gate. The original gate flagged a conflict on
12
+ * `localChanged` alone (`journalEntry.hash !== localHash`), which fires for
13
+ * every edit of any already-synced file. The gate now requires
14
+ * `(localChanged && remoteChanged) || isFreshCollision`: a single writer never
15
+ * has `remoteChanged` (the live S3 ETag still equals the journal's recorded
16
+ * baseline), so an ordinary edit is uploaded, not mirrored.
17
+ *
18
+ * This test pins that contract end-to-end through `share()` using the same
19
+ * mocked-S3 harness as `share.test.ts`, modelling a single-writer remote whose
20
+ * ETag is always exactly what THIS device last uploaded. A positive control
21
+ * (a genuine out-of-band peer write) proves the guard is not trivially
22
+ * always-zero: real divergence is still detected.
23
+ */
24
+
25
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
26
+ import * as fs from "fs";
27
+ import * as path from "path";
28
+ import * as os from "os";
29
+ import { clearContextCache } from "../context.js";
30
+ import type { VaultServiceConfig } from "../types.js";
31
+
32
+ vi.mock("../s3.js", () => ({
33
+ toPosixKey: (key: string) => key.split("\\").join("/"),
34
+ uploadFile: vi.fn(),
35
+ uploadSymlink: vi.fn().mockResolvedValue({ etag: '"upload-symlink-etag"' }),
36
+ downloadFile: vi.fn().mockResolvedValue(undefined),
37
+ listRemoteFiles: vi.fn().mockResolvedValue([]),
38
+ deleteRemoteFile: vi.fn().mockResolvedValue(undefined),
39
+ headRemoteFile: vi.fn(),
40
+ primeObjectTransport: vi.fn().mockResolvedValue(undefined),
41
+ primeUploads: vi.fn().mockResolvedValue(undefined),
42
+ }));
43
+
44
+ vi.mock("readline", () => ({
45
+ createInterface: vi.fn(() => ({ question: vi.fn(), close: vi.fn() })),
46
+ }));
47
+
48
+ import { share } from "./share.js";
49
+ import { downloadFile, headRemoteFile, uploadFile } from "../s3.js";
50
+
51
+ const mockConfig: VaultServiceConfig = {
52
+ apiUrl: "https://vault-api.test",
53
+ authToken: "test-jwt-token",
54
+ region: "us-east-1",
55
+ };
56
+
57
+ const mockEntity = {
58
+ uid: "cmp_01ABCDEF",
59
+ slug: "acme",
60
+ bucketName: "hq-vault-acme-123",
61
+ status: "active",
62
+ };
63
+
64
+ function setupFetchMock() {
65
+ vi.stubGlobal(
66
+ "fetch",
67
+ vi.fn().mockImplementation(async (url: string) => {
68
+ const u = String(url);
69
+ if (u.includes("/entity/check-slug/me"))
70
+ return {
71
+ ok: true,
72
+ status: 200,
73
+ json: async () => ({ available: false, conflictingCompanyUid: mockEntity.uid }),
74
+ text: async () => "",
75
+ };
76
+ if (u.includes("/entity/by-slug/") || /\/entity\/cmp_/.test(u))
77
+ return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
78
+ if (u.includes("/sts/vend"))
79
+ return {
80
+ ok: true,
81
+ status: 200,
82
+ json: async () => ({
83
+ credentials: {
84
+ accessKeyId: "ASIA_TEST",
85
+ secretAccessKey: "s",
86
+ sessionToken: "t",
87
+ expiration: new Date(Date.now() + 9e5).toISOString(),
88
+ },
89
+ expiresAt: new Date(Date.now() + 9e5).toISOString(),
90
+ }),
91
+ text: async () => "",
92
+ };
93
+ return { ok: false, status: 404, text: async () => "Not found" };
94
+ }),
95
+ );
96
+ }
97
+
98
+ /** Every `.conflict-<ts>-*` mirror file anywhere under `root`. */
99
+ function countConflictMirrors(root: string): string[] {
100
+ const out: string[] = [];
101
+ const walk = (d: string) => {
102
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
103
+ const p = path.join(d, e.name);
104
+ if (e.isDirectory()) walk(p);
105
+ else if (/\.conflict-/.test(e.name)) out.push(p);
106
+ }
107
+ };
108
+ walk(root);
109
+ return out;
110
+ }
111
+
112
+ /**
113
+ * In-memory single-writer S3: the object's ETag is always exactly what THIS
114
+ * device last uploaded (no peer ever writes). `lastModified` advances on each
115
+ * PUT to model S3 stamping it server-side. The S3-module signatures are
116
+ * `uploadFile(ctx, localPath, key)`, `headRemoteFile(ctx, key)`,
117
+ * `downloadFile(ctx, key, dest)` — wire the mocks to match exactly.
118
+ */
119
+ function wireSingleWriterRemote() {
120
+ const remote = new Map<string, { etag: string; lastModified: Date; size: number }>();
121
+ let clock = Date.now();
122
+ let seq = 0;
123
+ vi.mocked(uploadFile).mockImplementation(async (_ctx: unknown, localPath: string, key: string) => {
124
+ const etag = `"etag-${seq++}"`;
125
+ clock += 1000;
126
+ remote.set(key, { etag, lastModified: new Date(clock), size: fs.statSync(localPath).size });
127
+ return { etag };
128
+ });
129
+ vi.mocked(headRemoteFile).mockImplementation(async (_ctx: unknown, key: string) => {
130
+ const r = remote.get(key);
131
+ return r ? { lastModified: r.lastModified, etag: r.etag, size: r.size } : null;
132
+ });
133
+ vi.mocked(downloadFile).mockImplementation(async (_ctx: unknown, _key: string, dest: string) => {
134
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
135
+ fs.writeFileSync(dest, "remote-bytes\n");
136
+ return {};
137
+ });
138
+ return remote;
139
+ }
140
+
141
+ describe("watch + event-push conflict regression (feedback_ef2b7c8c)", () => {
142
+ let tmpDir: string;
143
+ let stateDir: string;
144
+
145
+ beforeEach(() => {
146
+ clearContextCache();
147
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-conflict-"));
148
+ stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-state-"));
149
+ process.env.HQ_STATE_DIR = stateDir;
150
+ setupFetchMock();
151
+ });
152
+
153
+ afterEach(() => {
154
+ vi.unstubAllGlobals();
155
+ vi.clearAllMocks();
156
+ fs.rmSync(tmpDir, { recursive: true, force: true });
157
+ fs.rmSync(stateDir, { recursive: true, force: true });
158
+ delete process.env.HQ_STATE_DIR;
159
+ });
160
+
161
+ it("a single writer's repeated local edits mint ZERO .conflict-* files", async () => {
162
+ const companyRoot = path.join(tmpDir, "companies", "acme");
163
+ fs.mkdirSync(companyRoot, { recursive: true });
164
+ const files = ["notes.md", "plan.md", "ideas.md"].map((n) => path.join(companyRoot, n));
165
+ for (const f of files) fs.writeFileSync(f, "v0\n");
166
+
167
+ wireSingleWriterRemote();
168
+
169
+ const conflictPathsSeen: string[] = [];
170
+ // The event-push runner pushes the changed company subtree. Mirror that:
171
+ // share() over the company root on every settled change.
172
+ const eventPush = async () => {
173
+ const r = await share({
174
+ paths: [companyRoot],
175
+ company: "acme",
176
+ vaultConfig: mockConfig,
177
+ hqRoot: tmpDir,
178
+ onConflict: "keep",
179
+ onEvent: () => {},
180
+ });
181
+ conflictPathsSeen.push(...r.conflictPaths);
182
+ };
183
+
184
+ // Initial sync establishes the journal baseline (first upload of each file).
185
+ await eventPush();
186
+
187
+ // An editing session: 30 ordinary local edits, round-robin across the
188
+ // files, each followed by its targeted event-push.
189
+ for (let i = 0; i < 30; i++) {
190
+ fs.writeFileSync(files[i % files.length], `v${i + 1}\n`);
191
+ await eventPush();
192
+ }
193
+
194
+ expect(countConflictMirrors(tmpDir)).toEqual([]);
195
+ expect(conflictPathsSeen).toEqual([]);
196
+ });
197
+
198
+ it("positive control: a genuine peer write is still detected as a conflict", async () => {
199
+ const companyRoot = path.join(tmpDir, "companies", "acme");
200
+ fs.mkdirSync(companyRoot, { recursive: true });
201
+ const file = path.join(companyRoot, "contested.md");
202
+ fs.writeFileSync(file, "v0\n");
203
+
204
+ const remote = wireSingleWriterRemote();
205
+
206
+ const conflictPathsSeen: string[] = [];
207
+ const eventPush = async () => {
208
+ const r = await share({
209
+ paths: [companyRoot],
210
+ company: "acme",
211
+ vaultConfig: mockConfig,
212
+ hqRoot: tmpDir,
213
+ onConflict: "keep",
214
+ onEvent: () => {},
215
+ });
216
+ conflictPathsSeen.push(...r.conflictPaths);
217
+ };
218
+
219
+ await eventPush(); // baseline
220
+
221
+ // A peer advances the remote object out-of-band to DIFFERENT bytes. Journal
222
+ // keys are company-relative, so the key is "contested.md".
223
+ remote.set("contested.md", { etag: '"peer-etag"', lastModified: new Date(Date.now() + 60_000), size: 99 });
224
+ // ...and we also edit locally → both sides moved → a genuine conflict.
225
+ fs.writeFileSync(file, "v1-local\n");
226
+ await eventPush();
227
+
228
+ // Existing-entry push conflicts surface via conflictPaths (the inspection
229
+ // mirror is written by the pull leg). The contract under test: a REAL
230
+ // divergence is still flagged — the single-writer guard above did not
231
+ // over-correct into swallowing true conflicts.
232
+ expect(conflictPathsSeen).toContain("contested.md");
233
+ });
234
+ });
@@ -71,6 +71,41 @@ export function isCoveredByAny(
71
71
  return false;
72
72
  }
73
73
 
74
+ /**
75
+ * Directory companion to `isCoveredByAny`: should the push/pull walk DESCEND
76
+ * into directory `relDir` (company-relative, no leading slash) given the
77
+ * granted `prefixSet`?
78
+ *
79
+ * A file uses plain `startsWith` (`isCoveredByAny`), but a directory must be
80
+ * descended whenever it COULD contain an in-scope file — which is true in two
81
+ * directions:
82
+ * - the directory sits INSIDE a granted prefix (`knowledge/sub` under
83
+ * `knowledge/`), or
84
+ * - a granted prefix sits INSIDE the directory (`knowledge/` under the
85
+ * company root `""`, or `knowledge/README.md` under `knowledge/`).
86
+ * Without the second case the walk would refuse to descend into `knowledge/`
87
+ * to reach a `knowledge/README.md` exact-file grant, scoping the whole tree
88
+ * to nothing.
89
+ *
90
+ * The directory is normalized to a trailing-slash form so the `startsWith`
91
+ * comparisons line up with coalesced prefixes (which are themselves either
92
+ * trailing-slash dir prefixes or exact-file keys). The empty string (company
93
+ * root) always descends when any prefix exists.
94
+ */
95
+ export function isDirInScope(
96
+ relDir: string,
97
+ prefixSet: readonly string[],
98
+ ): boolean {
99
+ const dir = relDir === "" || relDir.endsWith("/") ? relDir : relDir + "/";
100
+ for (const p of prefixSet) {
101
+ if (p === "") return true; // full scope
102
+ if (dir === "") return true; // company root — descend to reach grants
103
+ if (dir.startsWith(p)) return true; // dir is inside a granted prefix
104
+ if (p.startsWith(dir)) return true; // a granted prefix is inside dir
105
+ }
106
+ return false;
107
+ }
108
+
74
109
  /**
75
110
  * Normalize a raw ACL grant `path` into a COMPANY-RELATIVE prefix suitable
76
111
  * for `coalescePrefixes` + `isCoveredByAny` (which do literal `startsWith`