@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.
- package/dist/bin/sync-runner.d.ts +5 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +37 -8
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +33 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +24 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +117 -3
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +99 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +21 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +9 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/watch-event-push-conflict.test.d.ts +25 -0
- package/dist/cli/watch-event-push-conflict.test.d.ts.map +1 -0
- package/dist/cli/watch-event-push-conflict.test.js +210 -0
- package/dist/cli/watch-event-push-conflict.test.js.map +1 -0
- package/dist/prefix-coalesce.d.ts +22 -0
- package/dist/prefix-coalesce.d.ts.map +1 -1
- package/dist/prefix-coalesce.js +35 -0
- package/dist/prefix-coalesce.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +39 -0
- package/src/bin/sync-runner.ts +44 -14
- package/src/cli/share.test.ts +109 -0
- package/src/cli/share.ts +144 -3
- package/src/cli/sync.ts +32 -0
- package/src/cli/watch-event-push-conflict.test.ts +234 -0
- package/src/prefix-coalesce.ts +35 -0
|
@@ -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
|
+
});
|
package/src/prefix-coalesce.ts
CHANGED
|
@@ -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`
|