@indigoai-us/hq-cloud 6.13.0 → 6.13.1
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 +2 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +35 -2
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +14 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +39 -4
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +55 -0
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/journal.d.ts +7 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +32 -1
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +34 -1
- package/dist/journal.test.js.map +1 -1
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +7 -3
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +12 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +20 -0
- package/src/bin/sync-runner.ts +57 -14
- package/src/cli/reindex.test.ts +60 -0
- package/src/cli/reindex.ts +47 -8
- package/src/journal.test.ts +44 -0
- package/src/journal.ts +36 -1
- package/src/skill-telemetry.test.ts +24 -0
- package/src/skill-telemetry.ts +6 -3
package/src/cli/reindex.ts
CHANGED
|
@@ -223,6 +223,17 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
|
223
223
|
}
|
|
224
224
|
try {
|
|
225
225
|
|
|
226
|
+
// Overlay/skill-wrapper mirroring is best-effort and, on a normal synced HQ,
|
|
227
|
+
// legitimately no-ops for MANY entries (a real core file already sits where a
|
|
228
|
+
// symlink would go, or a link already points elsewhere). Emitting one stderr
|
|
229
|
+
// line PER such entry floods the runner's Sentry breadcrumb ring (100 entries)
|
|
230
|
+
// and evicts the real per-company sync error, making code-2 syncs impossible to
|
|
231
|
+
// triage (HQ-SYNC-2 / HQ-SYNC-WEB-6). Collapse those per-file lines into a
|
|
232
|
+
// single end-of-run summary; keep per-file detail behind HQ_REINDEX_VERBOSE=1.
|
|
233
|
+
const verbose = process.env.HQ_REINDEX_VERBOSE === "1";
|
|
234
|
+
let skippedNonSymlink = 0;
|
|
235
|
+
let skippedPointsElsewhere = 0;
|
|
236
|
+
|
|
226
237
|
// Best-effort: each wrapper mkdir below is recursive and re-creates this
|
|
227
238
|
// parent as needed, so a failure here is non-fatal — warn and carry on so
|
|
228
239
|
// personal-overlay mirroring and registry regeneration still run.
|
|
@@ -341,12 +352,18 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
|
341
352
|
if (lst && lst.isSymbolicLink()) {
|
|
342
353
|
const current = readlinkOrNull(linkPath);
|
|
343
354
|
if (current === relativeTarget) continue;
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
355
|
+
skippedPointsElsewhere++;
|
|
356
|
+
if (verbose) {
|
|
357
|
+
warn(
|
|
358
|
+
`reindex: .claude/skills/${wrapperName}/${entry} already points to '${current}' (expected '${relativeTarget}'); leaving alone`,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
347
361
|
continue;
|
|
348
362
|
} else if (lst) {
|
|
349
|
-
|
|
363
|
+
skippedNonSymlink++;
|
|
364
|
+
if (verbose) {
|
|
365
|
+
warn(`reindex: ${linkPath} already exists and is not a symlink; skipping`);
|
|
366
|
+
}
|
|
350
367
|
continue;
|
|
351
368
|
}
|
|
352
369
|
|
|
@@ -440,12 +457,18 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
|
440
457
|
if (lst && lst.isSymbolicLink()) {
|
|
441
458
|
const current = readlinkOrNull(linkPath);
|
|
442
459
|
if (current === relativeTarget) continue;
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
460
|
+
skippedPointsElsewhere++;
|
|
461
|
+
if (verbose) {
|
|
462
|
+
warn(
|
|
463
|
+
`reindex: core/${type}/${entry} already points to '${current}' (expected '${relativeTarget}'); leaving alone`,
|
|
464
|
+
);
|
|
465
|
+
}
|
|
446
466
|
continue;
|
|
447
467
|
} else if (lst) {
|
|
448
|
-
|
|
468
|
+
skippedNonSymlink++;
|
|
469
|
+
if (verbose) {
|
|
470
|
+
warn(`reindex: core/${type}/${entry} already exists and is not a symlink; skipping`);
|
|
471
|
+
}
|
|
449
472
|
continue;
|
|
450
473
|
}
|
|
451
474
|
|
|
@@ -463,6 +486,22 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
|
463
486
|
spawnSync("bash", [genScript], { stdio: ["ignore", 2, 2] });
|
|
464
487
|
}
|
|
465
488
|
|
|
489
|
+
// One-line summary in place of the per-file overlay-skip spam (see the comment
|
|
490
|
+
// at the top of the try). Only emitted when something was skipped, so a clean
|
|
491
|
+
// run stays silent and the breadcrumb ring is free for real sync errors.
|
|
492
|
+
if (skippedNonSymlink > 0 || skippedPointsElsewhere > 0) {
|
|
493
|
+
const parts: string[] = [];
|
|
494
|
+
if (skippedNonSymlink > 0) {
|
|
495
|
+
parts.push(`${skippedNonSymlink} pre-existing non-symlink ${skippedNonSymlink === 1 ? "entry" : "entries"}`);
|
|
496
|
+
}
|
|
497
|
+
if (skippedPointsElsewhere > 0) {
|
|
498
|
+
parts.push(`${skippedPointsElsewhere} ${skippedPointsElsewhere === 1 ? "entry" : "entries"} already pointing elsewhere`);
|
|
499
|
+
}
|
|
500
|
+
warn(
|
|
501
|
+
`reindex: left ${parts.join(" + ")} untouched (overlay mirror is best-effort; set HQ_REINDEX_VERBOSE=1 for per-file detail)`,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
466
505
|
return { status: 0 };
|
|
467
506
|
} finally {
|
|
468
507
|
opLock?.release();
|
package/src/journal.test.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
gcTombstones,
|
|
26
26
|
generatePullId,
|
|
27
27
|
TOMBSTONE_TTL_MS,
|
|
28
|
+
MAX_PULLS_PER_COMPANY,
|
|
28
29
|
PERSONAL_VAULT_JOURNAL_SLUG,
|
|
29
30
|
migratePersonalVaultJournal,
|
|
30
31
|
listJournals,
|
|
@@ -463,6 +464,49 @@ describe("journal", () => {
|
|
|
463
464
|
"2026-05-20T00:00:00.000Z",
|
|
464
465
|
);
|
|
465
466
|
});
|
|
467
|
+
|
|
468
|
+
it("bounds pulls per company and preserves newest lastPullRecord", () => {
|
|
469
|
+
const j: SyncJournal = {
|
|
470
|
+
version: "2",
|
|
471
|
+
lastSync: "",
|
|
472
|
+
files: {},
|
|
473
|
+
pulls: [],
|
|
474
|
+
};
|
|
475
|
+
const completedAt = (n: number) =>
|
|
476
|
+
`2026-05-20T00:00:${String(n).padStart(2, "0")}.000Z`;
|
|
477
|
+
|
|
478
|
+
for (let i = 0; i < MAX_PULLS_PER_COMPANY + 5; i++) {
|
|
479
|
+
appendPullRecord(
|
|
480
|
+
j,
|
|
481
|
+
pull({
|
|
482
|
+
pullId: `pull-a-${String(i).padStart(2, "0")}`,
|
|
483
|
+
companyUid: "cmp_a",
|
|
484
|
+
completedAt: completedAt(i),
|
|
485
|
+
}),
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
for (let i = 0; i < 3; i++) {
|
|
489
|
+
appendPullRecord(
|
|
490
|
+
j,
|
|
491
|
+
pull({
|
|
492
|
+
pullId: `pull-b-${i}`,
|
|
493
|
+
companyUid: "cmp_b",
|
|
494
|
+
completedAt: completedAt(i),
|
|
495
|
+
}),
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const pulls = j.pulls ?? [];
|
|
500
|
+
const companyA = pulls.filter((p) => p.companyUid === "cmp_a");
|
|
501
|
+
const companyB = pulls.filter((p) => p.companyUid === "cmp_b");
|
|
502
|
+
expect(companyA).toHaveLength(MAX_PULLS_PER_COMPANY);
|
|
503
|
+
expect(companyB).toHaveLength(3);
|
|
504
|
+
expect(companyA[0].pullId).toBe("pull-a-05");
|
|
505
|
+
expect(companyA.at(-1)?.pullId).toBe("pull-a-54");
|
|
506
|
+
expect(companyA.some((p) => p.pullId === "pull-a-00")).toBe(false);
|
|
507
|
+
expect(lastPullRecord(j, "cmp_a")?.pullId).toBe("pull-a-54");
|
|
508
|
+
expect(lastPullRecord(j, "cmp_b")?.pullId).toBe("pull-b-2");
|
|
509
|
+
});
|
|
466
510
|
});
|
|
467
511
|
|
|
468
512
|
describe("tombstoneEntry + isTombstone", () => {
|
package/src/journal.ts
CHANGED
|
@@ -26,6 +26,13 @@ export const TOMBSTONE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
|
26
26
|
/** Current journal schema version written by all v2-aware writers. */
|
|
27
27
|
export const JOURNAL_VERSION_CURRENT = "2" as const;
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Retain bounded pull history per company. Scope-shrink logic only needs the
|
|
31
|
+
* newest record, but a small tail keeps diagnostics useful without letting
|
|
32
|
+
* long-running sync loops grow journals forever.
|
|
33
|
+
*/
|
|
34
|
+
export const MAX_PULLS_PER_COMPANY = 50;
|
|
35
|
+
|
|
29
36
|
const JOURNAL_FILE_PREFIX = "sync-journal.";
|
|
30
37
|
const JOURNAL_FILE_SUFFIX = ".json";
|
|
31
38
|
const JOURNAL_LAST_GOOD_SUFFIX = ".last-good";
|
|
@@ -517,13 +524,41 @@ export function lastPullRecord(
|
|
|
517
524
|
return best;
|
|
518
525
|
}
|
|
519
526
|
|
|
520
|
-
|
|
527
|
+
function trimPullRecords(journal: SyncJournal): void {
|
|
528
|
+
const pulls = journal.pulls;
|
|
529
|
+
if (!pulls || pulls.length === 0) return;
|
|
530
|
+
|
|
531
|
+
const byCompany = new Map<string, Array<{ record: PullRecord; index: number }>>();
|
|
532
|
+
pulls.forEach((record, index) => {
|
|
533
|
+
const entries = byCompany.get(record.companyUid);
|
|
534
|
+
if (entries) entries.push({ record, index });
|
|
535
|
+
else byCompany.set(record.companyUid, [{ record, index }]);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
const keep = new Set<number>();
|
|
539
|
+
for (const entries of byCompany.values()) {
|
|
540
|
+
entries
|
|
541
|
+
.sort((a, b) => {
|
|
542
|
+
const byCompletedAt = b.record.completedAt.localeCompare(
|
|
543
|
+
a.record.completedAt,
|
|
544
|
+
);
|
|
545
|
+
return byCompletedAt === 0 ? b.index - a.index : byCompletedAt;
|
|
546
|
+
})
|
|
547
|
+
.slice(0, MAX_PULLS_PER_COMPANY)
|
|
548
|
+
.forEach((entry) => keep.add(entry.index));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
journal.pulls = pulls.filter((_, index) => keep.has(index));
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Append a `PullRecord` (mutates `journal.pulls`) and cap retained history. */
|
|
521
555
|
export function appendPullRecord(
|
|
522
556
|
journal: SyncJournal,
|
|
523
557
|
record: PullRecord,
|
|
524
558
|
): void {
|
|
525
559
|
migrateToV2(journal);
|
|
526
560
|
journal.pulls!.push(record);
|
|
561
|
+
trimPullRecords(journal);
|
|
527
562
|
}
|
|
528
563
|
|
|
529
564
|
/**
|
|
@@ -226,6 +226,30 @@ describe("extractCodexSkillToolEvents (Codex model-driven SKILL.md reads)", () =
|
|
|
226
226
|
expect(ev.source).toBe("model");
|
|
227
227
|
});
|
|
228
228
|
|
|
229
|
+
it("matches normal Codex skill paths without backtracking ambiguity", () => {
|
|
230
|
+
expect(
|
|
231
|
+
extractCodexSkillToolEvents(
|
|
232
|
+
execEnd("cat /home/u/.claude/skills/hq/land/SKILL.md"),
|
|
233
|
+
ctx,
|
|
234
|
+
)[0].skill,
|
|
235
|
+
).toBe("land");
|
|
236
|
+
expect(
|
|
237
|
+
extractCodexSkillToolEvents(
|
|
238
|
+
execEnd("cat /home/u/.claude/skills/a/b/c/SKILL.md"),
|
|
239
|
+
ctx,
|
|
240
|
+
)[0].skill,
|
|
241
|
+
).toBe("c");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("returns quickly for pathological non-matching skills paths", () => {
|
|
245
|
+
const cmd = "/home/u/.claude/skills/" + "a/".repeat(2000) + "z z";
|
|
246
|
+
const started = Date.now();
|
|
247
|
+
const events = extractCodexSkillToolEvents(execEnd(cmd), ctx);
|
|
248
|
+
const elapsedMs = Date.now() - started;
|
|
249
|
+
expect(events).toEqual([]);
|
|
250
|
+
expect(elapsedMs).toBeLessThan(200);
|
|
251
|
+
});
|
|
252
|
+
|
|
229
253
|
it("absolute and relative SKILL.md paths both resolve", () => {
|
|
230
254
|
expect(
|
|
231
255
|
extractCodexSkillToolEvents(execEnd("cat /abs/path/.agents/skills/quiz/SKILL.md"), ctx)[0].skill,
|
package/src/skill-telemetry.ts
CHANGED
|
@@ -342,10 +342,11 @@ export function extractCodexSkillEvents(
|
|
|
342
342
|
* may be nested arbitrarily deep (`.agents/skills/…`, `.codex/skills/hq/…`),
|
|
343
343
|
* and `<name>` is always the directory immediately above SKILL.md — captured
|
|
344
344
|
* as the last segment so the bridge's `skills/hq/<name>/` layout resolves to
|
|
345
|
-
* `<name>`, not `hq`. The
|
|
346
|
-
*
|
|
345
|
+
* `<name>`, not `hq`. The segment classes deliberately exclude `/` so segment
|
|
346
|
+
* boundaries are unambiguous and non-matching `skills/...` paths cannot
|
|
347
|
+
* catastrophically backtrack. */
|
|
347
348
|
const CODEX_SKILL_FILE =
|
|
348
|
-
/(?:^|\/)skills\/(?:[
|
|
349
|
+
/(?:^|\/)skills\/(?:[^/\s'"]+\/)*?([^/\s'"]+)\/SKILL\.md\b/;
|
|
349
350
|
|
|
350
351
|
/** Pull the shell command string out of a Codex `exec_command_end` `command`,
|
|
351
352
|
* which is `["/bin/zsh", "-lc", "<cmd>"]` (array) on the runtimes we see, but
|
|
@@ -488,6 +489,8 @@ export function extractCodexSkillToolEvents(
|
|
|
488
489
|
const exec = codexExecParams(obj, payload, ctx);
|
|
489
490
|
if (!exec) return [];
|
|
490
491
|
const { cmd } = exec;
|
|
492
|
+
// Real exec commands are tiny; avoid running regexes over pathological rows.
|
|
493
|
+
if (cmd.length > 100_000) return [];
|
|
491
494
|
const m = CODEX_SKILL_FILE.exec(cmd);
|
|
492
495
|
if (!m) return [];
|
|
493
496
|
// Confirm the exec is a read of the skill file, not a write to it. Codex's own
|