@indigoai-us/hq-cloud 6.11.8 → 6.11.10
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.map +1 -1
- package/dist/bin/sync-runner.js +4 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +72 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.test.js +44 -0
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/rescue-classify-ordering.test.js +25 -0
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/company-resolver.d.ts +77 -0
- package/dist/company-resolver.d.ts.map +1 -0
- package/dist/company-resolver.js +124 -0
- package/dist/company-resolver.js.map +1 -0
- package/dist/company-resolver.test.d.ts +7 -0
- package/dist/company-resolver.test.d.ts.map +1 -0
- package/dist/company-resolver.test.js +120 -0
- package/dist/company-resolver.test.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/personal-vault.d.ts +24 -0
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +36 -1
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.js +46 -1
- package/dist/personal-vault.test.js.map +1 -1
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +22 -3
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +101 -1
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/event-sync.test.js +18 -0
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/feature-flags.test.js +37 -4
- package/dist/sync/feature-flags.test.js.map +1 -1
- package/dist/sync/index.d.ts +1 -1
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/logger.test.js +1 -0
- package/dist/sync/logger.test.js.map +1 -1
- package/dist/sync/metrics.test.js +1 -0
- package/dist/sync/metrics.test.js.map +1 -1
- package/dist/sync/pull-scope.d.ts +1 -0
- package/dist/sync/pull-scope.d.ts.map +1 -1
- package/dist/sync/pull-scope.js +26 -0
- package/dist/sync/pull-scope.js.map +1 -1
- package/dist/sync/push-event.d.ts +23 -11
- package/dist/sync/push-event.d.ts.map +1 -1
- package/dist/sync/push-event.js +15 -8
- package/dist/sync/push-event.js.map +1 -1
- package/dist/sync/push-event.test.js +39 -3
- package/dist/sync/push-event.test.js.map +1 -1
- package/dist/sync/push-receiver.test.js +1 -0
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/telemetry.d.ts +18 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +28 -2
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +93 -1
- package/dist/telemetry.test.js.map +1 -1
- package/dist/vault-client.d.ts +4 -2
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js.map +1 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +25 -9
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +65 -1
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +90 -0
- package/src/bin/sync-runner.ts +4 -0
- package/src/cli/reindex.test.ts +53 -0
- package/src/cli/rescue-classify-ordering.test.ts +28 -0
- package/src/company-resolver.test.ts +136 -0
- package/src/company-resolver.ts +147 -0
- package/src/index.ts +1 -0
- package/src/personal-vault.test.ts +56 -0
- package/src/personal-vault.ts +36 -1
- package/src/skill-telemetry.test.ts +126 -1
- package/src/skill-telemetry.ts +26 -3
- package/src/sync/event-sync.test.ts +21 -0
- package/src/sync/feature-flags.test.ts +40 -4
- package/src/sync/index.ts +5 -1
- package/src/sync/logger.test.ts +1 -0
- package/src/sync/metrics.test.ts +1 -0
- package/src/sync/pull-scope.ts +26 -1
- package/src/sync/push-event.test.ts +45 -3
- package/src/sync/push-event.ts +28 -12
- package/src/sync/push-receiver.test.ts +1 -0
- package/src/telemetry.test.ts +118 -1
- package/src/telemetry.ts +50 -2
- package/src/vault-client.ts +4 -2
- package/src/watcher.test.ts +81 -0
- package/src/watcher.ts +27 -9
- package/test/e2e/sync/cross-tenant-isolation.test.ts +2 -0
package/src/telemetry.test.ts
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
* boundary is `TelemetryClientSurface`, not the HTTP layer.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
11
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
12
12
|
import * as fs from "fs";
|
|
13
|
+
import { promises as fsp } from "fs";
|
|
13
14
|
import * as os from "os";
|
|
14
15
|
import * as path from "path";
|
|
15
16
|
import {
|
|
@@ -186,11 +187,20 @@ describe("sanitizeRow", () => {
|
|
|
186
187
|
"sessionId", "timestamp", "uuid", "cwd", "gitBranch", "userType",
|
|
187
188
|
"model", "inputTokens", "outputTokens",
|
|
188
189
|
"cacheCreationInputTokens", "cacheReadInputTokens",
|
|
190
|
+
"companyUid",
|
|
189
191
|
]);
|
|
190
192
|
for (const key of Object.keys(out)) {
|
|
191
193
|
expect(allowed.has(key), `field "${key}" must be in server allowlist`).toBe(true);
|
|
192
194
|
}
|
|
193
195
|
});
|
|
196
|
+
|
|
197
|
+
it("(US-002) stamps companyUid when provided, omits it when not", () => {
|
|
198
|
+
const withUid = sanitizeRow(JSON.parse(USER_ROW), "cmp_01INDIGO")!;
|
|
199
|
+
expect(withUid.companyUid).toBe("cmp_01INDIGO");
|
|
200
|
+
|
|
201
|
+
const withoutUid = sanitizeRow(JSON.parse(USER_ROW))!;
|
|
202
|
+
expect("companyUid" in withoutUid).toBe(false);
|
|
203
|
+
});
|
|
194
204
|
});
|
|
195
205
|
|
|
196
206
|
// ── collectAndSendTelemetry ───────────────────────────────────────────────────
|
|
@@ -392,3 +402,110 @@ describe("collectAndSendTelemetry", () => {
|
|
|
392
402
|
expect(client.posts).toHaveLength(1); // no second POST
|
|
393
403
|
});
|
|
394
404
|
});
|
|
405
|
+
|
|
406
|
+
// ── companyUid edge attribution (US-002) ────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
describe("collectAndSendTelemetry — companyUid attribution", () => {
|
|
409
|
+
const COMPANY_UID = "cmp_01INDIGOTEST";
|
|
410
|
+
let env: TestEnv;
|
|
411
|
+
let hqRoot: string;
|
|
412
|
+
|
|
413
|
+
// The manifest lives at <hqRoot>/companies/manifest.yaml; the repo it maps is
|
|
414
|
+
// a real subdir of hqRoot so the resolver's absolute-path math lines up.
|
|
415
|
+
function writeManifest(): void {
|
|
416
|
+
fs.mkdirSync(path.join(hqRoot, "companies"), { recursive: true });
|
|
417
|
+
fs.writeFileSync(
|
|
418
|
+
path.join(hqRoot, "companies", "manifest.yaml"),
|
|
419
|
+
`companies:\n indigo:\n repos:\n - repos/private/hq-cloud\n cloud_uid: ${COMPANY_UID}\n`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function rowWithCwd(uuid: string, cwd: string): string {
|
|
424
|
+
return JSON.stringify({
|
|
425
|
+
type: "user",
|
|
426
|
+
timestamp: "2026-06-10T10:00:00Z",
|
|
427
|
+
sessionId: "s1",
|
|
428
|
+
uuid,
|
|
429
|
+
userType: "human",
|
|
430
|
+
cwd,
|
|
431
|
+
message: { role: "user", content: [{ type: "text", text: "hi" }] },
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
beforeEach(() => {
|
|
436
|
+
env = setupEnv();
|
|
437
|
+
// Use the same tmp root as hqRoot so the repo path resolves under it.
|
|
438
|
+
hqRoot = env.root;
|
|
439
|
+
writeManifest();
|
|
440
|
+
});
|
|
441
|
+
afterEach(() => { teardownEnv(env); });
|
|
442
|
+
|
|
443
|
+
it("stamps companyUid on events whose cwd is inside a company repo", async () => {
|
|
444
|
+
const client = makeClient();
|
|
445
|
+
const inRepo = path.join(hqRoot, "repos/private/hq-cloud/src");
|
|
446
|
+
writeJsonl(env, "proj", "s.jsonl", [rowWithCwd("u1", inRepo)]);
|
|
447
|
+
|
|
448
|
+
await collectAndSendTelemetry({ ...makeOpts(env, client), hqRoot });
|
|
449
|
+
|
|
450
|
+
expect(client.posts).toHaveLength(1);
|
|
451
|
+
expect(client.posts[0].events[0].companyUid).toBe(COMPANY_UID);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("omits companyUid when the cwd maps to no company repo", async () => {
|
|
455
|
+
const client = makeClient();
|
|
456
|
+
writeJsonl(env, "proj", "s.jsonl", [rowWithCwd("u1", "/Users/x/some-other-repo")]);
|
|
457
|
+
|
|
458
|
+
await collectAndSendTelemetry({ ...makeOpts(env, client), hqRoot });
|
|
459
|
+
|
|
460
|
+
expect(client.posts).toHaveLength(1);
|
|
461
|
+
expect("companyUid" in client.posts[0].events[0]).toBe(false);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("never sends the reserved 'unattributed' value", async () => {
|
|
465
|
+
const client = makeClient();
|
|
466
|
+
writeJsonl(env, "proj", "s.jsonl", [
|
|
467
|
+
rowWithCwd("u1", path.join(hqRoot, "repos/private/hq-cloud")),
|
|
468
|
+
rowWithCwd("u2", "/elsewhere"),
|
|
469
|
+
]);
|
|
470
|
+
|
|
471
|
+
await collectAndSendTelemetry({ ...makeOpts(env, client), hqRoot });
|
|
472
|
+
|
|
473
|
+
for (const ev of client.posts.flatMap((p) => p.events)) {
|
|
474
|
+
expect(ev.companyUid).not.toBe("unattributed");
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("parses the manifest once per run (cache), not per event", async () => {
|
|
479
|
+
const client = makeClient();
|
|
480
|
+
const inRepo = path.join(hqRoot, "repos/private/hq-cloud");
|
|
481
|
+
// 5 events in one run; assert the manifest file is read exactly once.
|
|
482
|
+
writeJsonl(
|
|
483
|
+
env,
|
|
484
|
+
"proj",
|
|
485
|
+
"s.jsonl",
|
|
486
|
+
Array.from({ length: 5 }, (_, i) => rowWithCwd(`u${i}`, inRepo)),
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
const manifestPath = path.join(hqRoot, "companies", "manifest.yaml");
|
|
490
|
+
const realReadFile = fsp.readFile;
|
|
491
|
+
let manifestReads = 0;
|
|
492
|
+
const spy = vi
|
|
493
|
+
.spyOn(fsp, "readFile")
|
|
494
|
+
.mockImplementation((async (p: Parameters<typeof realReadFile>[0], ...rest: unknown[]) => {
|
|
495
|
+
if (typeof p === "string" && p === manifestPath) manifestReads++;
|
|
496
|
+
// @ts-expect-error pass-through to the real implementation
|
|
497
|
+
return realReadFile(p, ...rest);
|
|
498
|
+
}) as typeof realReadFile);
|
|
499
|
+
|
|
500
|
+
try {
|
|
501
|
+
await collectAndSendTelemetry({ ...makeOpts(env, client), hqRoot });
|
|
502
|
+
} finally {
|
|
503
|
+
spy.mockRestore();
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
expect(manifestReads).toBe(1); // one parse for all 5 events
|
|
507
|
+
const events = client.posts.flatMap((p) => p.events);
|
|
508
|
+
expect(events).toHaveLength(5);
|
|
509
|
+
for (const ev of events) expect(ev.companyUid).toBe(COMPANY_UID);
|
|
510
|
+
});
|
|
511
|
+
});
|
package/src/telemetry.ts
CHANGED
|
@@ -27,6 +27,11 @@ import { promises as fs } from "node:fs";
|
|
|
27
27
|
import * as os from "node:os";
|
|
28
28
|
import * as path from "node:path";
|
|
29
29
|
|
|
30
|
+
import {
|
|
31
|
+
buildRepoCompanyMap,
|
|
32
|
+
resolveCompanyForCwd,
|
|
33
|
+
type RepoCompanyMap,
|
|
34
|
+
} from "./company-resolver.js";
|
|
30
35
|
import type {
|
|
31
36
|
TelemetryOptInResponse,
|
|
32
37
|
UsageBatch,
|
|
@@ -51,6 +56,15 @@ export interface CollectTelemetryOptions {
|
|
|
51
56
|
machineId: string;
|
|
52
57
|
/** Version of the wrapping caller (menubar app, CLI, etc.). Reaches CloudWatch metrics as the `installerVersion` dimension. */
|
|
53
58
|
installerVersion: string;
|
|
59
|
+
/**
|
|
60
|
+
* HQ root, used to resolve each event's `cwd` → owning repo → owning company
|
|
61
|
+
* via `<hqRoot>/companies/manifest.yaml` and stamp `companyUid` on the event
|
|
62
|
+
* (surface-hq-console-telemetry US-002). The manifest is parsed ONCE per run
|
|
63
|
+
* (see `buildRepoCompanyMap`); the resulting map is reused for every event.
|
|
64
|
+
* When omitted (or when no repo matches), `companyUid` is left UNSET and the
|
|
65
|
+
* server treats the event as unattributed/personal.
|
|
66
|
+
*/
|
|
67
|
+
hqRoot?: string;
|
|
54
68
|
/** Override `~/.claude/projects` for tests. */
|
|
55
69
|
claudeProjectsRoot?: string;
|
|
56
70
|
/** Override `~/.hq/telemetry-cursor.json` for tests. */
|
|
@@ -148,12 +162,23 @@ const KEEP_TOP_LEVEL = [
|
|
|
148
162
|
* camelCase top-level fields. The original `message` object — which
|
|
149
163
|
* carries prompt/response text, thinking, and tool data — is dropped.
|
|
150
164
|
*
|
|
165
|
+
* `companyUid` (US-002): when the caller has resolved the row's `cwd` to an
|
|
166
|
+
* owning company (`resolveCompanyForCwd`), it passes that `cmp_*` uid here and
|
|
167
|
+
* it is stamped on the wire row. It is on the server's KEEP allowlist
|
|
168
|
+
* (`apps/hq-pro/src/vault-service/handlers/usage.ts`). When `companyUid` is
|
|
169
|
+
* undefined (cwd maps to no company repo) the field is OMITTED — the server
|
|
170
|
+
* treats absence as unattributed/personal. The reserved value `unattributed`
|
|
171
|
+
* is never produced (it can only come from a resolved manifest `cmp_*` uid).
|
|
172
|
+
*
|
|
151
173
|
* Returns `null` when the input isn't an object. Empty results (e.g. a row
|
|
152
174
|
* with no recognised fields) are still returned as `{}` and emitted; the
|
|
153
175
|
* server accepts empty rows and they're useful as a "Claude Code was run at
|
|
154
176
|
* this time" heartbeat.
|
|
155
177
|
*/
|
|
156
|
-
export function sanitizeRow(
|
|
178
|
+
export function sanitizeRow(
|
|
179
|
+
row: unknown,
|
|
180
|
+
companyUid?: string,
|
|
181
|
+
): Record<string, unknown> | null {
|
|
157
182
|
if (!row || typeof row !== "object" || Array.isArray(row)) return null;
|
|
158
183
|
const obj = row as Record<string, unknown>;
|
|
159
184
|
const out: Record<string, unknown> = {};
|
|
@@ -164,6 +189,12 @@ export function sanitizeRow(row: unknown): Record<string, unknown> | null {
|
|
|
164
189
|
}
|
|
165
190
|
}
|
|
166
191
|
|
|
192
|
+
// Stamp the resolved company attribution. Omit when unresolved so the server
|
|
193
|
+
// reads it as unattributed/personal. Never the reserved `unattributed`.
|
|
194
|
+
if (companyUid !== undefined) {
|
|
195
|
+
out.companyUid = companyUid;
|
|
196
|
+
}
|
|
197
|
+
|
|
167
198
|
const message = obj.message;
|
|
168
199
|
if (message && typeof message === "object" && !Array.isArray(message)) {
|
|
169
200
|
const m = message as Record<string, unknown>;
|
|
@@ -261,6 +292,13 @@ export async function collectAndSendTelemetry(
|
|
|
261
292
|
const menubarPath = opts.menubarPath ?? path.join(home, ".hq", "menubar.json");
|
|
262
293
|
const log = opts.log ?? (() => {});
|
|
263
294
|
|
|
295
|
+
// Company attribution (US-002): parse the manifest ONCE per run and reuse the
|
|
296
|
+
// repo-path→companyUid map for every event below. No per-event manifest read.
|
|
297
|
+
// When `hqRoot` is omitted the map is empty → every event stays unattributed.
|
|
298
|
+
const repoCompanyMap: RepoCompanyMap = opts.hqRoot
|
|
299
|
+
? await buildRepoCompanyMap(opts.hqRoot)
|
|
300
|
+
: { entries: [] };
|
|
301
|
+
|
|
264
302
|
// 1. Opt-in check (server-authoritative, with local fallback).
|
|
265
303
|
let enabled: boolean;
|
|
266
304
|
let optInSource: CollectTelemetryResult["optInSource"];
|
|
@@ -390,7 +428,17 @@ export async function collectAndSendTelemetry(
|
|
|
390
428
|
} catch {
|
|
391
429
|
continue;
|
|
392
430
|
}
|
|
393
|
-
|
|
431
|
+
// Resolve this row's cwd → owning company (cmp_* uid) before sanitizing,
|
|
432
|
+
// using the per-run map. Unresolved → undefined → companyUid omitted.
|
|
433
|
+
const rowCwd =
|
|
434
|
+
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
435
|
+
? (parsed as Record<string, unknown>).cwd
|
|
436
|
+
: undefined;
|
|
437
|
+
const companyUid = resolveCompanyForCwd(
|
|
438
|
+
typeof rowCwd === "string" ? rowCwd : undefined,
|
|
439
|
+
repoCompanyMap,
|
|
440
|
+
);
|
|
441
|
+
const sanitized = sanitizeRow(parsed, companyUid);
|
|
394
442
|
if (!sanitized) continue;
|
|
395
443
|
|
|
396
444
|
// Cost of appending this row to the current batch: the row's JSON
|
package/src/vault-client.ts
CHANGED
|
@@ -393,7 +393,8 @@ export interface UsageBatch {
|
|
|
393
393
|
* Sanitized event rows. Each row is a plain object containing only the
|
|
394
394
|
* fields in the server's KEEP allowlist (sessionId, timestamp, uuid, cwd,
|
|
395
395
|
* gitBranch, userType, model, inputTokens, outputTokens,
|
|
396
|
-
* cacheCreationInputTokens, cacheReadInputTokens
|
|
396
|
+
* cacheCreationInputTokens, cacheReadInputTokens, and the optional
|
|
397
|
+
* companyUid edge-attribution field — US-002). Any extra field is
|
|
397
398
|
* rejected by hq-pro with `unexpected-event-field`, so the sanitizer in
|
|
398
399
|
* `./telemetry.ts` is the only thing allowed to produce these.
|
|
399
400
|
*/
|
|
@@ -416,7 +417,8 @@ export interface SkillInvocationBatch {
|
|
|
416
417
|
/**
|
|
417
418
|
* Skill-invocation event rows. Each row contains only the fields in the
|
|
418
419
|
* server's KEEP allowlist (skill, source, sessionId, timestamp, uuid, cwd,
|
|
419
|
-
* hasArgs
|
|
420
|
+
* hasArgs, and the optional companyUid edge-attribution field — US-002).
|
|
421
|
+
* Raw argument text is never included — see the privacy note in
|
|
420
422
|
* `./skill-telemetry.ts`. Any extra field is rejected by hq-pro with
|
|
421
423
|
* `unexpected-event-field`, so the extractor in `./skill-telemetry.ts` is the
|
|
422
424
|
* only thing allowed to produce these.
|
package/src/watcher.test.ts
CHANGED
|
@@ -7,7 +7,11 @@ import {
|
|
|
7
7
|
WatchPushDriver,
|
|
8
8
|
TreeWatcher,
|
|
9
9
|
createWatchPathFilter,
|
|
10
|
+
PushEventEmitter,
|
|
10
11
|
} from "./watcher.js";
|
|
12
|
+
import { StaticFlagProvider } from "./sync/feature-flags.js";
|
|
13
|
+
import type { PushEvent } from "./sync/push-event.js";
|
|
14
|
+
import type { PushTransport } from "./sync/push-transport.js";
|
|
11
15
|
|
|
12
16
|
/**
|
|
13
17
|
* US-001 — Phase 1 test harness: watch-triggered push seam + latency assertion.
|
|
@@ -427,3 +431,80 @@ describe("US-002: TreeWatcher — lifecycle (real chokidar over a temp dir)", ()
|
|
|
427
431
|
w.dispose();
|
|
428
432
|
}, 10_000);
|
|
429
433
|
});
|
|
434
|
+
|
|
435
|
+
describe("PushEventEmitter — directory and delete tombstone handling", () => {
|
|
436
|
+
let dir: string;
|
|
437
|
+
|
|
438
|
+
beforeEach(() => {
|
|
439
|
+
dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "pushemit-")));
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
afterEach(() => {
|
|
443
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
function makeEmitter(opts?: {
|
|
447
|
+
published?: PushEvent[];
|
|
448
|
+
onError?: ReturnType<typeof vi.fn>;
|
|
449
|
+
getSequenceNumber?: () => number;
|
|
450
|
+
}): PushEventEmitter {
|
|
451
|
+
const published = opts?.published ?? [];
|
|
452
|
+
const transport: PushTransport = {
|
|
453
|
+
start: async () => {},
|
|
454
|
+
dispose: async () => {},
|
|
455
|
+
connected: true,
|
|
456
|
+
publish: async (event) => {
|
|
457
|
+
published.push(event);
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
return new PushEventEmitter({
|
|
461
|
+
originTenantId: "tenant-indigo",
|
|
462
|
+
originDeviceId: "device-a",
|
|
463
|
+
transport,
|
|
464
|
+
flagProvider: new StaticFlagProvider(["tenant-indigo"]),
|
|
465
|
+
now: () => new Date("2026-06-18T12:00:00.000Z"),
|
|
466
|
+
getSequenceNumber: opts?.getSequenceNumber,
|
|
467
|
+
onError: opts?.onError,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
it("skips directory changes silently without publishing or reporting an error", async () => {
|
|
472
|
+
const published: PushEvent[] = [];
|
|
473
|
+
const onError = vi.fn();
|
|
474
|
+
const subdir = path.join(dir, "folder");
|
|
475
|
+
fs.mkdirSync(subdir);
|
|
476
|
+
const emitter = makeEmitter({ published, onError });
|
|
477
|
+
|
|
478
|
+
await emitter.emitForBatch({ paths: new Map([[subdir, "folder"]]) });
|
|
479
|
+
|
|
480
|
+
expect(published).toEqual([]);
|
|
481
|
+
expect(onError).not.toHaveBeenCalled();
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("emits and publishes a delete tombstone when the changed file is gone", async () => {
|
|
485
|
+
const published: PushEvent[] = [];
|
|
486
|
+
const onError = vi.fn();
|
|
487
|
+
const emitter = makeEmitter({
|
|
488
|
+
published,
|
|
489
|
+
onError,
|
|
490
|
+
getSequenceNumber: () => 99,
|
|
491
|
+
});
|
|
492
|
+
const missing = path.join(dir, "deleted.md");
|
|
493
|
+
|
|
494
|
+
await emitter.emitForBatch({ paths: new Map([[missing, "deleted.md"]]) });
|
|
495
|
+
|
|
496
|
+
expect(onError).not.toHaveBeenCalled();
|
|
497
|
+
expect(published).toEqual([
|
|
498
|
+
{
|
|
499
|
+
kind: "delete",
|
|
500
|
+
relativePath: "deleted.md",
|
|
501
|
+
originDeviceId: "device-a",
|
|
502
|
+
originTenantId: "tenant-indigo",
|
|
503
|
+
sequenceNumber: 99,
|
|
504
|
+
eventTimestamp: "2026-06-18T12:00:00.000Z",
|
|
505
|
+
},
|
|
506
|
+
]);
|
|
507
|
+
expect(published[0]).not.toHaveProperty("contentHash");
|
|
508
|
+
expect(published[0]).not.toHaveProperty("mtime");
|
|
509
|
+
});
|
|
510
|
+
});
|
package/src/watcher.ts
CHANGED
|
@@ -862,11 +862,12 @@ export class PushEventEmitter {
|
|
|
862
862
|
): Promise<void> {
|
|
863
863
|
let event: PushEvent | undefined;
|
|
864
864
|
try {
|
|
865
|
-
const
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
865
|
+
const st = await stat(absolutePath);
|
|
866
|
+
if (st.isDirectory()) return;
|
|
867
|
+
|
|
868
|
+
const contentHash = await computeContentHash(absolutePath);
|
|
869
869
|
event = {
|
|
870
|
+
kind: "upsert",
|
|
870
871
|
relativePath,
|
|
871
872
|
contentHash,
|
|
872
873
|
mtime: st.mtime.toISOString(),
|
|
@@ -876,12 +877,28 @@ export class PushEventEmitter {
|
|
|
876
877
|
eventTimestamp: this.now().toISOString(),
|
|
877
878
|
};
|
|
878
879
|
} catch (err) {
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
880
|
+
const code =
|
|
881
|
+
err && typeof err === "object" && "code" in err
|
|
882
|
+
? (err as { code?: string }).code
|
|
883
|
+
: undefined;
|
|
884
|
+
if (code !== "ENOENT") {
|
|
885
|
+
// Genuine stat/read failure. Surface, don't crash.
|
|
886
|
+
this.onError(err instanceof Error ? err : new Error(String(err)), {
|
|
887
|
+
relativePath,
|
|
888
|
+
});
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Deleted before/during capture: publish a tombstone so peers run a
|
|
893
|
+
// targeted pull and let the vault-confirmed tombstone path remove it.
|
|
894
|
+
event = {
|
|
895
|
+
kind: "delete",
|
|
882
896
|
relativePath,
|
|
883
|
-
|
|
884
|
-
|
|
897
|
+
originDeviceId: this.originDeviceId,
|
|
898
|
+
originTenantId: this.originTenantId,
|
|
899
|
+
sequenceNumber: this.nextSeq(),
|
|
900
|
+
eventTimestamp: this.now().toISOString(),
|
|
901
|
+
};
|
|
885
902
|
}
|
|
886
903
|
|
|
887
904
|
// US-011: 1st link of the 3-log diagnostic chain. Stamps the same
|
|
@@ -891,6 +908,7 @@ export class PushEventEmitter {
|
|
|
891
908
|
this.logger?.info(
|
|
892
909
|
{
|
|
893
910
|
event: "watcher.emit",
|
|
911
|
+
kind: event.kind,
|
|
894
912
|
originTenantId: event.originTenantId,
|
|
895
913
|
originDeviceId: event.originDeviceId,
|
|
896
914
|
relativePath: event.relativePath,
|
|
@@ -215,6 +215,7 @@ function makeEvent(opts: {
|
|
|
215
215
|
}): PushEvent {
|
|
216
216
|
return {
|
|
217
217
|
relativePath: opts.relativePath,
|
|
218
|
+
kind: "upsert",
|
|
218
219
|
contentHash: ZERO_HASH,
|
|
219
220
|
mtime: "2026-05-21T12:00:00.000Z",
|
|
220
221
|
originDeviceId: opts.deviceId,
|
|
@@ -540,6 +541,7 @@ describe("US-018: receive path with vended credentials (isolation extension)", (
|
|
|
540
541
|
function phase3Event(overrides: Partial<PushEvent>): PushEvent {
|
|
541
542
|
return {
|
|
542
543
|
relativePath: "companies/indigo/docs/x.md",
|
|
544
|
+
kind: "upsert",
|
|
543
545
|
contentHash: `sha256:${"b".repeat(64)}`,
|
|
544
546
|
mtime: "2026-06-10T12:00:00.000Z",
|
|
545
547
|
originDeviceId: "peer",
|