@indigoai-us/hq-cloud 6.14.3 → 6.14.4
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 +1 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +44 -19
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +25 -36
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +128 -4
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +209 -29
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +116 -37
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +16 -14
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +73 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/watcher.d.ts +1 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +5 -6
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +54 -0
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +50 -22
- package/src/bin/sync-runner.test.ts +147 -4
- package/src/bin/sync-runner.ts +49 -54
- package/src/cli/reindex.test.ts +130 -37
- package/src/cli/reindex.ts +198 -28
- package/src/cli/sync.test.ts +90 -0
- package/src/cli/sync.ts +16 -14
- package/src/watcher.test.ts +59 -0
- package/src/watcher.ts +6 -8
package/src/cli/reindex.ts
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* hq reindex — surfaces namespaced skills as Claude Code skill wrappers under
|
|
3
3
|
* .claude/skills/<ns>:<skill>/ (one symlink per source file), mirrors
|
|
4
4
|
* personal/{knowledge,policies,workers,settings}/<entry> into core/<type>/,
|
|
5
|
-
* prunes orphan wrappers + legacy command symlinks, captures this
|
|
6
|
-
*
|
|
7
|
-
* registry.
|
|
5
|
+
* prunes orphan wrappers + legacy command symlinks, captures this HQ tree's
|
|
6
|
+
* coding-harness session logs (Claude Code / Codex / Grok) into
|
|
7
|
+
* workspace/.session-logs/<harness>/, and regenerates the workers registry.
|
|
8
8
|
*
|
|
9
9
|
* The logic historically lived in a bundled bash script (scripts/reindex.sh)
|
|
10
10
|
* that this module exec'd via `bash`. It is now implemented natively in
|
|
@@ -199,46 +199,216 @@ function safeSymlink(target: string, linkPath: string, label: string): boolean {
|
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
// `workspace/.session-logs`, so the raw session transcripts are captured inside
|
|
202
|
+
// Capture the raw session transcripts of every supported coding harness for
|
|
203
|
+
// this HQ tree into `workspace/.session-logs/<harness>/`, so they live inside
|
|
205
204
|
// the HQ tree (where sync/search can see them) on each reindex.
|
|
206
205
|
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
// - NEVER deletes dest entries that lack a source counterpart — this is a
|
|
211
|
-
// one-way overlay, not a mirror, so anything already parked in
|
|
212
|
-
// workspace/.session-logs survives even after Claude Code rotates or prunes
|
|
213
|
-
// its own projects dir.
|
|
206
|
+
// Scope — "this HQ tree" = the HQ root cwd OR any working directory UNDER it
|
|
207
|
+
// (worktrees, repo subdirs). Harnesses key their logs by cwd differently, so
|
|
208
|
+
// each helper re-derives the same scope in its own encoding.
|
|
214
209
|
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
210
|
+
// Contract (per the feature request), applied by every helper:
|
|
211
|
+
// - copies ALL matching files/folders into `.session-logs/<harness>/…`,
|
|
212
|
+
// - OVERWRITES existing dest files, and
|
|
213
|
+
// - NEVER deletes dest entries that lack a source counterpart — a one-way
|
|
214
|
+
// overlay, not a mirror, so anything already parked in `.session-logs`
|
|
215
|
+
// survives even after a harness rotates or prunes its own store.
|
|
216
|
+
//
|
|
217
|
+
// Best-effort throughout: any failure warns to stderr and is swallowed so it
|
|
218
|
+
// can never fail the reindex. Each harness is independent — one failing must
|
|
219
|
+
// not stop the others. Home dirs honor CLAUDE_CONFIG_DIR / CODEX_HOME /
|
|
220
|
+
// GROK_HOME, else ~/.claude, ~/.codex, ~/.grok.
|
|
217
221
|
function copySessionLogs(root: string): void {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
222
|
+
const destBase = path.join(root, "workspace", ".session-logs");
|
|
223
|
+
for (const capture of [
|
|
224
|
+
copyClaudeSessionLogs,
|
|
225
|
+
copyCodexSessionLogs,
|
|
226
|
+
copyGrokSessionLogs,
|
|
227
|
+
]) {
|
|
228
|
+
try {
|
|
229
|
+
capture(root, destBase);
|
|
230
|
+
} catch (err) {
|
|
231
|
+
warn(`reindex: session-log capture step failed (${(err as Error).message}); skipping.`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
225
235
|
|
|
226
|
-
|
|
227
|
-
|
|
236
|
+
/** `fs.statSync` without throwing (follows symlinks; null when missing). */
|
|
237
|
+
function statOrNull(p: string): fs.Stats | null {
|
|
238
|
+
try {
|
|
239
|
+
return fs.statSync(p);
|
|
240
|
+
} catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
228
244
|
|
|
245
|
+
// Overlay-copy a whole directory tree: mkdir dest, then recursive+force cpSync
|
|
246
|
+
// (overwrite existing, never prune dest-only entries). Best-effort — warns and
|
|
247
|
+
// returns on any failure. Shared by the Claude + Grok per-cwd-folder captures.
|
|
248
|
+
function copyTreeOverlay(src: string, dest: string, label: string): void {
|
|
249
|
+
if (!safeMkdir(dest, `${label} directory`)) return;
|
|
229
250
|
try {
|
|
230
|
-
// recursive: copy the whole tree; force: overwrite existing dest files.
|
|
231
|
-
// cpSync copies src INTO dest and never removes dest-only entries — exactly
|
|
232
|
-
// the "overwrite, but don't delete extras" contract we want.
|
|
233
251
|
fs.cpSync(src, dest, { recursive: true, force: true });
|
|
234
252
|
} catch (err) {
|
|
235
253
|
warn(
|
|
236
|
-
`reindex: could not copy
|
|
254
|
+
`reindex: could not copy ${label} from '${src}' to '${dest}' ` +
|
|
237
255
|
`(${(err as NodeJS.ErrnoException).code ?? "error"}: ${(err as Error).message}); skipping.`,
|
|
238
256
|
);
|
|
239
257
|
}
|
|
240
258
|
}
|
|
241
259
|
|
|
260
|
+
// Claude Code: `<claude-config>/projects/<encoded-cwd>/…`, where the cwd is
|
|
261
|
+
// encoded by replacing every non-alphanumeric char with '-'. Because that map
|
|
262
|
+
// is per-character, a descendant cwd's dir name is exactly the root's encoded
|
|
263
|
+
// name + '-' + <encoded-subpath> — so "this HQ tree" is the exact-root dir OR
|
|
264
|
+
// any dir whose name starts with `<slug>-`. Copy each into `.session-logs/claude/`.
|
|
265
|
+
function copyClaudeSessionLogs(root: string, destBase: string): void {
|
|
266
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
267
|
+
const projectsDir = path.join(configDir, "projects");
|
|
268
|
+
if (!isDir(projectsDir)) return;
|
|
269
|
+
|
|
270
|
+
const slug = root.replace(/[^a-zA-Z0-9]/g, "-");
|
|
271
|
+
for (const entry of globEntries(projectsDir)) {
|
|
272
|
+
if (entry !== slug && !entry.startsWith(`${slug}-`)) continue;
|
|
273
|
+
const src = path.join(projectsDir, entry);
|
|
274
|
+
if (!isDir(src)) continue;
|
|
275
|
+
copyTreeOverlay(src, path.join(destBase, "claude", entry), "claude session logs");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Grok: `<grok-home>/sessions/<url-encoded-cwd>/<session-uuid>/…`, where the cwd
|
|
280
|
+
// is URL-encoded (encodeURIComponent: '/' -> '%2F'). A descendant cwd's key is
|
|
281
|
+
// the root's key + '%2F' + <encoded-subpath>, so match the exact-root key OR
|
|
282
|
+
// that prefix. Copy each matching cwd folder into `.session-logs/grok/`.
|
|
283
|
+
function copyGrokSessionLogs(root: string, destBase: string): void {
|
|
284
|
+
const grokHome = process.env.GROK_HOME || path.join(os.homedir(), ".grok");
|
|
285
|
+
const sessionsDir = path.join(grokHome, "sessions");
|
|
286
|
+
if (!isDir(sessionsDir)) return;
|
|
287
|
+
|
|
288
|
+
const key = encodeURIComponent(root);
|
|
289
|
+
for (const entry of globEntries(sessionsDir)) {
|
|
290
|
+
if (entry !== key && !entry.startsWith(`${key}%2F`)) continue;
|
|
291
|
+
const src = path.join(sessionsDir, entry);
|
|
292
|
+
if (!isDir(src)) continue;
|
|
293
|
+
copyTreeOverlay(src, path.join(destBase, "grok", entry), "grok session logs");
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Codex: `<codex-home>/sessions/YYYY/MM/DD/rollout-*.jsonl` — a DATE tree, not
|
|
298
|
+
// per-project. The working dir is only recorded inside each rollout's first
|
|
299
|
+
// line (`session_meta`, at `payload.cwd`). Walk the tree and capture a rollout
|
|
300
|
+
// iff its recorded cwd is the HQ root or under it, mirroring the date-relative
|
|
301
|
+
// path under `.session-logs/codex/`. A rollout already copied and unchanged is
|
|
302
|
+
// skipped by mtime, so a steady-state reindex only classifies newly-written
|
|
303
|
+
// rollouts — the full-tree cwd scan is a one-time first-run cost.
|
|
304
|
+
function copyCodexSessionLogs(root: string, destBase: string): void {
|
|
305
|
+
const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|
306
|
+
const sessionsDir = path.join(codexHome, "sessions");
|
|
307
|
+
if (!isDir(sessionsDir)) return;
|
|
308
|
+
|
|
309
|
+
const destDir = path.join(destBase, "codex");
|
|
310
|
+
const rootPrefix = root.endsWith(path.sep) ? root : root + path.sep;
|
|
311
|
+
|
|
312
|
+
for (const abs of walkFiles(sessionsDir)) {
|
|
313
|
+
if (!abs.endsWith(".jsonl")) continue;
|
|
314
|
+
const srcStat = statOrNull(abs);
|
|
315
|
+
if (!srcStat) continue;
|
|
316
|
+
|
|
317
|
+
const dest = path.join(destDir, path.relative(sessionsDir, abs));
|
|
318
|
+
// Cheap skip: already captured and unchanged since (dest mtime is the copy
|
|
319
|
+
// time, so it stays >= src mtime until the rollout is appended to again).
|
|
320
|
+
const destStat = statOrNull(dest);
|
|
321
|
+
if (destStat && destStat.mtimeMs >= srcStat.mtimeMs) continue;
|
|
322
|
+
|
|
323
|
+
const cwd = readCodexRolloutCwd(abs);
|
|
324
|
+
if (cwd === null) continue;
|
|
325
|
+
if (cwd !== root && !cwd.startsWith(rootPrefix)) continue;
|
|
326
|
+
|
|
327
|
+
if (!safeMkdir(path.dirname(dest), "codex session-log dir")) continue;
|
|
328
|
+
try {
|
|
329
|
+
fs.copyFileSync(abs, dest);
|
|
330
|
+
} catch (err) {
|
|
331
|
+
warn(
|
|
332
|
+
`reindex: could not copy codex session log '${abs}' -> '${dest}' ` +
|
|
333
|
+
`(${(err as NodeJS.ErrnoException).code ?? "error"}: ${(err as Error).message}); skipping.`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Recursively collect every regular file under `dir` (absolute paths). Symlinks
|
|
340
|
+
// are not followed (lstat gate) so a stray link can't escape the tree or loop.
|
|
341
|
+
// Missing/unreadable dirs yield []. Best-effort — mirrors globEntries' tolerance.
|
|
342
|
+
function walkFiles(dir: string): string[] {
|
|
343
|
+
const out: string[] = [];
|
|
344
|
+
let names: string[];
|
|
345
|
+
try {
|
|
346
|
+
names = fs.readdirSync(dir);
|
|
347
|
+
} catch {
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
for (const name of names) {
|
|
351
|
+
const abs = path.join(dir, name);
|
|
352
|
+
const st = lstatOrNull(abs);
|
|
353
|
+
if (!st) continue;
|
|
354
|
+
if (st.isDirectory()) out.push(...walkFiles(abs));
|
|
355
|
+
else if (st.isFile()) out.push(abs);
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Extract `payload.cwd` (Codex ≥ the session_meta format; falls back to a
|
|
361
|
+
// top-level `cwd`) from a rollout's first line, reading only that line. Codex
|
|
362
|
+
// records cwd early, before the large `base_instructions` blob, but the line
|
|
363
|
+
// can still be tens of KB — so read up to a generous cap and stop at the first
|
|
364
|
+
// newline. Returns null on any parse/read failure (the rollout is then skipped).
|
|
365
|
+
function readCodexRolloutCwd(file: string): string | null {
|
|
366
|
+
const line = readFirstLine(file, 4 * 1024 * 1024);
|
|
367
|
+
if (line === null) return null;
|
|
368
|
+
try {
|
|
369
|
+
const obj = JSON.parse(line) as { cwd?: unknown; payload?: { cwd?: unknown } };
|
|
370
|
+
const cwd = obj?.payload?.cwd ?? obj?.cwd;
|
|
371
|
+
return typeof cwd === "string" ? cwd : null;
|
|
372
|
+
} catch {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Read the first line of `file` (up to `capBytes`), returning it WITHOUT the
|
|
378
|
+
// trailing newline, or null on error / empty. Byte-accumulates and decodes once
|
|
379
|
+
// so a multibyte char split across a read boundary can't corrupt the result.
|
|
380
|
+
function readFirstLine(file: string, capBytes: number): string | null {
|
|
381
|
+
let fd: number | null = null;
|
|
382
|
+
try {
|
|
383
|
+
fd = fs.openSync(file, "r");
|
|
384
|
+
const chunks: Buffer[] = [];
|
|
385
|
+
const chunk = Buffer.allocUnsafe(65536);
|
|
386
|
+
let total = 0;
|
|
387
|
+
while (total < capBytes) {
|
|
388
|
+
const n = fs.readSync(fd, chunk, 0, chunk.length, total);
|
|
389
|
+
if (n <= 0) break;
|
|
390
|
+
const nl = chunk.indexOf(0x0a, 0);
|
|
391
|
+
if (nl !== -1 && nl < n) {
|
|
392
|
+
chunks.push(Buffer.from(chunk.subarray(0, nl)));
|
|
393
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
394
|
+
}
|
|
395
|
+
chunks.push(Buffer.from(chunk.subarray(0, n)));
|
|
396
|
+
total += n;
|
|
397
|
+
}
|
|
398
|
+
return chunks.length ? Buffer.concat(chunks).toString("utf8") : null;
|
|
399
|
+
} catch {
|
|
400
|
+
return null;
|
|
401
|
+
} finally {
|
|
402
|
+
if (fd !== null) {
|
|
403
|
+
try {
|
|
404
|
+
fs.closeSync(fd);
|
|
405
|
+
} catch {
|
|
406
|
+
/* best-effort */
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
242
412
|
// --- legacy `.claude/commands/<ns>/<skill>.md` symlink matcher --------------
|
|
243
413
|
// Mirrors the bash `case` patterns; `*` matches any chars (including `/`).
|
|
244
414
|
function isLegacyCommandTarget(t: string): boolean {
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -3518,6 +3518,17 @@ describe("reportNewFilesToNotify chunking (server cap = 1000 files/report)", ()
|
|
|
3518
3518
|
(fetchMock.mock.calls as Array<[string, RequestInit?]>)
|
|
3519
3519
|
.filter(([u]) => String(u).includes("/v1/notify/file-added"))
|
|
3520
3520
|
.map(([, init]) => JSON.parse(String(init!.body)).files.length);
|
|
3521
|
+
const notificationTelemetryEvents = (
|
|
3522
|
+
fetchMock: ReturnType<typeof vi.fn>,
|
|
3523
|
+
): Array<{ eventName: string; properties?: Record<string, unknown> }> =>
|
|
3524
|
+
(fetchMock.mock.calls as Array<[string, RequestInit?]>)
|
|
3525
|
+
.filter(([u]) => String(u).includes("/v1/telemetry/events"))
|
|
3526
|
+
.flatMap(([, init]) =>
|
|
3527
|
+
JSON.parse(String(init!.body)).events as Array<{
|
|
3528
|
+
eventName: string;
|
|
3529
|
+
properties?: Record<string, unknown>;
|
|
3530
|
+
}>,
|
|
3531
|
+
);
|
|
3521
3532
|
|
|
3522
3533
|
afterEach(() => {
|
|
3523
3534
|
vi.unstubAllGlobals();
|
|
@@ -3580,6 +3591,85 @@ describe("reportNewFilesToNotify chunking (server cap = 1000 files/report)", ()
|
|
|
3580
3591
|
expect(notifyBatchSizes(fetchMock)).toEqual([1000, 1000, 1]);
|
|
3581
3592
|
});
|
|
3582
3593
|
|
|
3594
|
+
it("does not emit notification telemetry after a successful report", async () => {
|
|
3595
|
+
const fetchMock = vi.fn().mockImplementation(async (url: string) => {
|
|
3596
|
+
if (url.includes("/v1/notify/file-added")) {
|
|
3597
|
+
return { ok: true, status: 200, text: async () => "" };
|
|
3598
|
+
}
|
|
3599
|
+
return {
|
|
3600
|
+
ok: true,
|
|
3601
|
+
status: 200,
|
|
3602
|
+
text: async () => JSON.stringify({ ok: true, written: 1, skipped: [] }),
|
|
3603
|
+
};
|
|
3604
|
+
});
|
|
3605
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
3606
|
+
|
|
3607
|
+
await reportNewFilesToNotify(cfg, "cmp_X", "acme", mkFiles(1));
|
|
3608
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
3609
|
+
|
|
3610
|
+
expect(notificationTelemetryEvents(fetchMock)).toEqual([]);
|
|
3611
|
+
});
|
|
3612
|
+
|
|
3613
|
+
it("emits failure telemetry for a non-successful notification response", async () => {
|
|
3614
|
+
const fetchMock = vi.fn().mockImplementation(async (url: string) => {
|
|
3615
|
+
if (url.includes("/v1/notify/file-added")) {
|
|
3616
|
+
return { ok: false, status: 503, text: async () => "unavailable" };
|
|
3617
|
+
}
|
|
3618
|
+
return {
|
|
3619
|
+
ok: true,
|
|
3620
|
+
status: 200,
|
|
3621
|
+
text: async () => JSON.stringify({ ok: true, written: 1, skipped: [] }),
|
|
3622
|
+
};
|
|
3623
|
+
});
|
|
3624
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
3625
|
+
|
|
3626
|
+
await reportNewFilesToNotify(cfg, "cmp_X", "acme", mkFiles(1));
|
|
3627
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
3628
|
+
|
|
3629
|
+
expect(notificationTelemetryEvents(fetchMock)).toEqual([
|
|
3630
|
+
expect.objectContaining({
|
|
3631
|
+
eventName: "new_files_notification_reported",
|
|
3632
|
+
properties: {
|
|
3633
|
+
status: "failure",
|
|
3634
|
+
fileCount: 1,
|
|
3635
|
+
batchIndex: 1,
|
|
3636
|
+
batchCount: 1,
|
|
3637
|
+
statusCode: 503,
|
|
3638
|
+
},
|
|
3639
|
+
}),
|
|
3640
|
+
]);
|
|
3641
|
+
});
|
|
3642
|
+
|
|
3643
|
+
it("emits error-class telemetry when notification reporting throws", async () => {
|
|
3644
|
+
const fetchMock = vi.fn().mockImplementation(async (url: string) => {
|
|
3645
|
+
if (url.includes("/v1/notify/file-added")) {
|
|
3646
|
+
throw new TypeError("network down");
|
|
3647
|
+
}
|
|
3648
|
+
return {
|
|
3649
|
+
ok: true,
|
|
3650
|
+
status: 200,
|
|
3651
|
+
text: async () => JSON.stringify({ ok: true, written: 1, skipped: [] }),
|
|
3652
|
+
};
|
|
3653
|
+
});
|
|
3654
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
3655
|
+
|
|
3656
|
+
await reportNewFilesToNotify(cfg, "cmp_X", "acme", mkFiles(1));
|
|
3657
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
3658
|
+
|
|
3659
|
+
expect(notificationTelemetryEvents(fetchMock)).toEqual([
|
|
3660
|
+
expect.objectContaining({
|
|
3661
|
+
eventName: "new_files_notification_reported",
|
|
3662
|
+
properties: {
|
|
3663
|
+
status: "failure",
|
|
3664
|
+
fileCount: 1,
|
|
3665
|
+
batchIndex: 1,
|
|
3666
|
+
batchCount: 1,
|
|
3667
|
+
errorClass: "TypeError",
|
|
3668
|
+
},
|
|
3669
|
+
}),
|
|
3670
|
+
]);
|
|
3671
|
+
});
|
|
3672
|
+
|
|
3583
3673
|
it("no request at all when there are no new files", async () => {
|
|
3584
3674
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, text: async () => "" });
|
|
3585
3675
|
vi.stubGlobal("fetch", fetchMock);
|
package/src/cli/sync.ts
CHANGED
|
@@ -722,18 +722,20 @@ export async function reportNewFilesToNotify(
|
|
|
722
722
|
}),
|
|
723
723
|
signal: controller.signal,
|
|
724
724
|
});
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
725
|
+
if (!response.ok) {
|
|
726
|
+
void emitCloudTelemetry(telemetryClient, {
|
|
727
|
+
eventName: "new_files_notification_reported",
|
|
728
|
+
source: "hq-sync",
|
|
729
|
+
companyUid,
|
|
730
|
+
properties: {
|
|
731
|
+
status: "failure",
|
|
732
|
+
fileCount: batch.length,
|
|
733
|
+
batchIndex,
|
|
734
|
+
batchCount,
|
|
735
|
+
statusCode: response.status,
|
|
736
|
+
},
|
|
737
|
+
}, { claims: telemetryClaims });
|
|
738
|
+
}
|
|
737
739
|
} catch (err) {
|
|
738
740
|
// Best-effort per chunk: never let notification reporting affect the sync
|
|
739
741
|
// result, and a failed chunk must not abort the remaining chunks.
|
|
@@ -743,11 +745,11 @@ export async function reportNewFilesToNotify(
|
|
|
743
745
|
source: "hq-sync",
|
|
744
746
|
companyUid,
|
|
745
747
|
properties: {
|
|
746
|
-
|
|
748
|
+
status: "failure",
|
|
747
749
|
fileCount: batch.length,
|
|
748
750
|
batchIndex,
|
|
749
751
|
batchCount,
|
|
750
|
-
|
|
752
|
+
errorClass: safeErrorName(err),
|
|
751
753
|
},
|
|
752
754
|
}, { claims: telemetryClaims });
|
|
753
755
|
} finally {
|
package/src/watcher.test.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import { StaticFlagProvider } from "./sync/feature-flags.js";
|
|
13
13
|
import type { PushEvent } from "./sync/push-event.js";
|
|
14
14
|
import type { PushTransport } from "./sync/push-transport.js";
|
|
15
|
+
import type { TelemetryEventsBatch } from "./vault-client.js";
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* US-001 — Phase 1 test harness: watch-triggered push seam + latency assertion.
|
|
@@ -736,4 +737,62 @@ describe("PushEventEmitter — directory and delete tombstone handling", () => {
|
|
|
736
737
|
expect(published[0]).not.toHaveProperty("contentHash");
|
|
737
738
|
expect(published[0]).not.toHaveProperty("mtime");
|
|
738
739
|
});
|
|
740
|
+
|
|
741
|
+
it("emits push telemetry only when publication fails", async () => {
|
|
742
|
+
const posts: TelemetryEventsBatch[] = [];
|
|
743
|
+
const telemetryClient = {
|
|
744
|
+
postTelemetryEvents: vi.fn(async (batch: TelemetryEventsBatch) => {
|
|
745
|
+
posts.push(batch);
|
|
746
|
+
return { ok: true, written: batch.events.length, skipped: [] };
|
|
747
|
+
}),
|
|
748
|
+
};
|
|
749
|
+
const changed = path.join(dir, "changed.md");
|
|
750
|
+
fs.writeFileSync(changed, "changed");
|
|
751
|
+
const success = new PushEventEmitter({
|
|
752
|
+
originTenantId: "tenant-indigo",
|
|
753
|
+
originDeviceId: "device-a",
|
|
754
|
+
transport: {
|
|
755
|
+
start: async () => {},
|
|
756
|
+
dispose: async () => {},
|
|
757
|
+
connected: true,
|
|
758
|
+
publish: async () => {},
|
|
759
|
+
},
|
|
760
|
+
flagProvider: new StaticFlagProvider(["tenant-indigo"]),
|
|
761
|
+
telemetryClient,
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
await success.emitForBatch({ paths: new Map([[changed, "changed.md"]]) });
|
|
765
|
+
await flushImmediate();
|
|
766
|
+
expect(posts).toEqual([]);
|
|
767
|
+
|
|
768
|
+
const failed = new PushEventEmitter({
|
|
769
|
+
originTenantId: "tenant-indigo",
|
|
770
|
+
originDeviceId: "device-a",
|
|
771
|
+
transport: {
|
|
772
|
+
start: async () => {},
|
|
773
|
+
dispose: async () => {},
|
|
774
|
+
connected: true,
|
|
775
|
+
publish: async () => {
|
|
776
|
+
throw new Error("network down");
|
|
777
|
+
},
|
|
778
|
+
},
|
|
779
|
+
flagProvider: new StaticFlagProvider(["tenant-indigo"]),
|
|
780
|
+
telemetryClient,
|
|
781
|
+
onError: vi.fn(),
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
await failed.emitForBatch({ paths: new Map([[changed, "changed.md"]]) });
|
|
785
|
+
await flushImmediate();
|
|
786
|
+
expect(posts.flatMap((post) => post.events)).toEqual([
|
|
787
|
+
expect.objectContaining({
|
|
788
|
+
eventName: "push_event_failed",
|
|
789
|
+
properties: {
|
|
790
|
+
kind: "upsert",
|
|
791
|
+
count: 1,
|
|
792
|
+
status: "failure",
|
|
793
|
+
stage: "publish",
|
|
794
|
+
},
|
|
795
|
+
}),
|
|
796
|
+
]);
|
|
797
|
+
});
|
|
739
798
|
});
|
package/src/watcher.ts
CHANGED
|
@@ -989,7 +989,7 @@ export class PushEventEmitter {
|
|
|
989
989
|
this.onError(err instanceof Error ? err : new Error(String(err)), {
|
|
990
990
|
relativePath,
|
|
991
991
|
});
|
|
992
|
-
this.
|
|
992
|
+
this.emitPublishFailureTelemetry("unknown", "capture");
|
|
993
993
|
return;
|
|
994
994
|
}
|
|
995
995
|
|
|
@@ -1025,31 +1025,29 @@ export class PushEventEmitter {
|
|
|
1025
1025
|
|
|
1026
1026
|
try {
|
|
1027
1027
|
await this.transport.publish(event);
|
|
1028
|
-
this.emitPublishTelemetry("push_event_published", event.kind, "publish");
|
|
1029
1028
|
} catch (err) {
|
|
1030
1029
|
// Push failure (network / non-2xx / timeout). The cadence poll is the
|
|
1031
1030
|
// safety net — log + continue, never throw.
|
|
1032
1031
|
this.onError(err instanceof Error ? err : new Error(String(err)), {
|
|
1033
1032
|
relativePath,
|
|
1034
1033
|
});
|
|
1035
|
-
this.
|
|
1034
|
+
this.emitPublishFailureTelemetry(event.kind, "publish");
|
|
1036
1035
|
}
|
|
1037
1036
|
}
|
|
1038
1037
|
|
|
1039
|
-
private
|
|
1040
|
-
eventName: "push_event_published" | "push_event_failed",
|
|
1038
|
+
private emitPublishFailureTelemetry(
|
|
1041
1039
|
kind: string,
|
|
1042
|
-
stage:
|
|
1040
|
+
stage: "capture" | "publish",
|
|
1043
1041
|
): void {
|
|
1044
1042
|
void emitCloudTelemetry(
|
|
1045
1043
|
this.telemetryClient,
|
|
1046
1044
|
{
|
|
1047
|
-
eventName,
|
|
1045
|
+
eventName: "push_event_failed",
|
|
1048
1046
|
source: "watcher",
|
|
1049
1047
|
properties: {
|
|
1050
1048
|
kind,
|
|
1051
1049
|
count: 1,
|
|
1052
|
-
|
|
1050
|
+
status: "failure",
|
|
1053
1051
|
stage,
|
|
1054
1052
|
},
|
|
1055
1053
|
},
|