@indigoai-us/hq-cloud 6.14.0 → 6.14.2
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 +4 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +50 -1
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +3 -2
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +21 -7
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +6 -2
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +162 -11
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +155 -17
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/accept.d.ts.map +1 -1
- package/dist/cli/accept.js +29 -0
- package/dist/cli/accept.js.map +1 -1
- package/dist/cli/invite.d.ts.map +1 -1
- package/dist/cli/invite.js +33 -0
- package/dist/cli/invite.js.map +1 -1
- package/dist/cli/promote.d.ts.map +1 -1
- package/dist/cli/promote.js +33 -0
- package/dist/cli/promote.js.map +1 -1
- package/dist/cli/reindex.d.ts +23 -11
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +39 -10
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +40 -13
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/share.js +47 -4
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +123 -2
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync-scope.test.js +84 -0
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +44 -3
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +156 -7
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +133 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/company-resolver.d.ts +21 -0
- package/dist/company-resolver.d.ts.map +1 -1
- package/dist/company-resolver.js +31 -6
- package/dist/company-resolver.js.map +1 -1
- package/dist/company-resolver.test.js +44 -1
- package/dist/company-resolver.test.js.map +1 -1
- package/dist/ignore.d.ts +10 -0
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +23 -2
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +13 -1
- package/dist/ignore.test.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts +12 -0
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +12 -0
- package/dist/journal.js.map +1 -1
- package/dist/lib/exit-codes.d.ts +25 -0
- package/dist/lib/exit-codes.d.ts.map +1 -0
- package/dist/lib/exit-codes.js +25 -0
- package/dist/lib/exit-codes.js.map +1 -0
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +45 -14
- package/dist/object-io.js.map +1 -1
- package/dist/object-io.test.js +61 -1
- package/dist/object-io.test.js.map +1 -1
- package/dist/operation-lock.d.ts +36 -6
- package/dist/operation-lock.d.ts.map +1 -1
- package/dist/operation-lock.js +34 -11
- package/dist/operation-lock.js.map +1 -1
- package/dist/operation-lock.test.js +30 -0
- package/dist/operation-lock.test.js.map +1 -1
- package/dist/s3.d.ts +1 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +75 -2
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +56 -1
- package/dist/s3.test.js.map +1 -1
- package/dist/scope-shrink.d.ts +12 -0
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +12 -0
- package/dist/scope-shrink.js.map +1 -1
- package/dist/scope-shrink.test.js +31 -0
- package/dist/scope-shrink.test.js.map +1 -1
- package/dist/skill-telemetry.d.ts +41 -0
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +111 -7
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +187 -5
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/event-sync.d.ts +3 -0
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +23 -0
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/pull-scope.d.ts +21 -0
- package/dist/sync/pull-scope.d.ts.map +1 -1
- package/dist/sync/pull-scope.js +46 -13
- package/dist/sync/pull-scope.js.map +1 -1
- package/dist/telemetry-events.d.ts +31 -0
- package/dist/telemetry-events.d.ts.map +1 -0
- package/dist/telemetry-events.js +140 -0
- package/dist/telemetry-events.js.map +1 -0
- package/dist/telemetry-events.test.d.ts +2 -0
- package/dist/telemetry-events.test.d.ts.map +1 -0
- package/dist/telemetry-events.test.js +73 -0
- package/dist/telemetry-events.test.js.map +1 -0
- package/dist/telemetry.js +1 -1
- package/dist/telemetry.js.map +1 -1
- package/dist/vault-client.d.ts +38 -5
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +69 -6
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +31 -1
- package/dist/vault-client.test.js.map +1 -1
- package/dist/watcher.d.ts +7 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +53 -1
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +57 -0
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/pnpm-workspace.yaml +2 -0
- package/scripts/presign-transport-e2e.mjs +2 -2
- package/src/bin/sync-runner-company.ts +65 -1
- package/src/bin/sync-runner-watch-loop.ts +24 -8
- package/src/bin/sync-runner.test.ts +194 -16
- package/src/bin/sync-runner.ts +209 -11
- package/src/cli/accept.ts +27 -0
- package/src/cli/invite.ts +31 -0
- package/src/cli/promote.ts +31 -0
- package/src/cli/reindex.test.ts +44 -13
- package/src/cli/reindex.ts +74 -21
- package/src/cli/share.test.ts +139 -2
- package/src/cli/share.ts +48 -2
- package/src/cli/sync-scope.test.ts +90 -0
- package/src/cli/sync.test.ts +154 -0
- package/src/cli/sync.ts +224 -5
- package/src/company-resolver.test.ts +51 -0
- package/src/company-resolver.ts +40 -5
- package/src/ignore.test.ts +18 -1
- package/src/ignore.ts +24 -2
- package/src/index.ts +22 -1
- package/src/journal.ts +12 -0
- package/src/lib/exit-codes.ts +24 -0
- package/src/object-io.test.ts +66 -1
- package/src/object-io.ts +51 -14
- package/src/operation-lock.test.ts +33 -0
- package/src/operation-lock.ts +46 -11
- package/src/s3.test.ts +87 -0
- package/src/s3.ts +93 -2
- package/src/scope-shrink.test.ts +32 -0
- package/src/scope-shrink.ts +12 -0
- package/src/skill-telemetry.test.ts +247 -4
- package/src/skill-telemetry.ts +134 -6
- package/src/sync/event-sync.ts +37 -0
- package/src/sync/pull-scope.ts +62 -13
- package/src/telemetry-events.test.ts +88 -0
- package/src/telemetry-events.ts +205 -0
- package/src/telemetry.ts +1 -1
- package/src/vault-client.test.ts +39 -1
- package/src/vault-client.ts +115 -14
- package/src/watcher.test.ts +82 -0
- package/src/watcher.ts +69 -0
package/src/skill-telemetry.ts
CHANGED
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
* swallowed so telemetry never aborts or delays a sync.
|
|
56
56
|
*/
|
|
57
57
|
|
|
58
|
+
import { createHash } from "node:crypto";
|
|
58
59
|
import { promises as fs } from "node:fs";
|
|
59
60
|
import * as os from "node:os";
|
|
60
61
|
import * as path from "node:path";
|
|
@@ -62,6 +63,7 @@ import * as path from "node:path";
|
|
|
62
63
|
import {
|
|
63
64
|
buildRepoCompanyMap,
|
|
64
65
|
resolveCompanyForCwd,
|
|
66
|
+
resolveCompanyForSkill,
|
|
65
67
|
type RepoCompanyMap,
|
|
66
68
|
} from "./company-resolver.js";
|
|
67
69
|
import type {
|
|
@@ -100,6 +102,16 @@ export interface CollectSkillTelemetryOptions {
|
|
|
100
102
|
cursorPath?: string;
|
|
101
103
|
/** Override `~/.hq/menubar.json` (the offline opt-in fallback) for tests. */
|
|
102
104
|
menubarPath?: string;
|
|
105
|
+
/**
|
|
106
|
+
* Override skillVersion resolution (skills-first-class US-015). Given a skill
|
|
107
|
+
* name, return its content-hash version marker (`sha256:<hex>`) or undefined.
|
|
108
|
+
* Defaults to hashing `<hqRoot>/.claude/skills/<skill>/SKILL.md` via
|
|
109
|
+
* {@link computeSkillVersion}. Injected in tests to decouple emission from the
|
|
110
|
+
* on-disk skills tree.
|
|
111
|
+
*/
|
|
112
|
+
resolveSkillVersion?: (
|
|
113
|
+
skill: string,
|
|
114
|
+
) => Promise<string | undefined> | string | undefined;
|
|
103
115
|
/** Diagnostic sink. No-op by default. */
|
|
104
116
|
log?: (msg: string) => void;
|
|
105
117
|
}
|
|
@@ -123,6 +135,15 @@ export interface SkillEvent {
|
|
|
123
135
|
uuid?: string;
|
|
124
136
|
cwd?: string;
|
|
125
137
|
hasArgs: boolean;
|
|
138
|
+
/**
|
|
139
|
+
* Content-version marker (skills-first-class US-015) — the sha256 of the
|
|
140
|
+
* skill's SKILL.md, `sha256:<hex>`. The version half of the analytics-v2 usage
|
|
141
|
+
* join key (`skill_uid` + `skillVersion`), so a run maps to the version that
|
|
142
|
+
* produced it. Resolved + stamped at capture (see `computeSkillVersion` +
|
|
143
|
+
* `collectAndSendSkillTelemetry`); OPTIONAL — absent when the SKILL.md can't
|
|
144
|
+
* be located, and additive on the wire (old clients/rows simply omit it).
|
|
145
|
+
*/
|
|
146
|
+
skillVersion?: string;
|
|
126
147
|
}
|
|
127
148
|
|
|
128
149
|
// Privacy switch — keep false (see file header). When false, raw argument text
|
|
@@ -348,6 +369,25 @@ export function extractCodexSkillEvents(
|
|
|
348
369
|
const CODEX_SKILL_FILE =
|
|
349
370
|
/(?:^|\/)skills\/(?:[^/\s'"]+\/)*?([^/\s'"]+)\/SKILL\.md\b/;
|
|
350
371
|
|
|
372
|
+
/** A company skill read via its REAL path `companies/<slug>/skills/<name>/
|
|
373
|
+
* SKILL.md`. Codex loads a skill by *reading* its file, and a company skill's
|
|
374
|
+
* real file lives under `companies/<slug>/skills/` — so, unlike the namespaced
|
|
375
|
+
* `.claude/skills/<slug>:<name>/` symlink (whose captured directory already
|
|
376
|
+
* carries the `<slug>:` prefix), this path names the owning company in a
|
|
377
|
+
* SEPARATE `<slug>` segment that {@link CODEX_SKILL_FILE} discards, leaving a
|
|
378
|
+
* bare `<name>` that no prefix-based attribution can place (the company view
|
|
379
|
+
* then misses the run). Group 1 = `<slug>`, group 2 = `<name>`: we rebuild the
|
|
380
|
+
* canonical `<slug>:<name>` invocation token from them so the event flows
|
|
381
|
+
* through the SAME attribution as every other runtime — the client
|
|
382
|
+
* `resolveCompanyForSkill` and the server rollup both key off that `<slug>:`
|
|
383
|
+
* prefix — and shares one `skill_uid` with the Claude/typed forms. The slug
|
|
384
|
+
* segment excludes `:` (a real company slug has none); skill discovery is one
|
|
385
|
+
* level deep, so `<name>` is a single segment. The leading boundary admits a
|
|
386
|
+
* RELATIVE `companies/…` path too (preceded by whitespace/quote/start, not just
|
|
387
|
+
* `/`), since Codex often reads it relative to the run cwd. */
|
|
388
|
+
const CODEX_COMPANY_SKILL_FILE =
|
|
389
|
+
/(?:^|[\s/'"])companies\/([^/\s'":]+)\/skills\/([^/\s'"]+)\/SKILL\.md\b/;
|
|
390
|
+
|
|
351
391
|
/** Pull the shell command string out of a Codex `exec_command_end` `command`,
|
|
352
392
|
* which is `["/bin/zsh", "-lc", "<cmd>"]` (array) on the runtimes we see, but
|
|
353
393
|
* tolerate a bare string too. */
|
|
@@ -500,7 +540,13 @@ export function extractCodexSkillToolEvents(
|
|
|
500
540
|
if (cls === "nonread") return [];
|
|
501
541
|
if (cls === "unknown" && !CODEX_READ_VERB.test(cmd)) return [];
|
|
502
542
|
|
|
503
|
-
|
|
543
|
+
// Prefer the canonical `<slug>:<name>` token when the read is of a company
|
|
544
|
+
// skill's real file (`companies/<slug>/skills/<name>/SKILL.md`) — the slug is
|
|
545
|
+
// in the path but dropped by CODEX_SKILL_FILE, so reconstruct it here to keep
|
|
546
|
+
// the run attributable. Otherwise keep the last-segment name (core/personal/
|
|
547
|
+
// namespaced skills already carry their prefix in the captured directory).
|
|
548
|
+
const co = CODEX_COMPANY_SKILL_FILE.exec(cmd);
|
|
549
|
+
const skill = co ? `${co[1]}:${co[2]}` : m[1].trim();
|
|
504
550
|
if (!skill) return [];
|
|
505
551
|
|
|
506
552
|
const sessionId = ctx.sessionId;
|
|
@@ -541,6 +587,55 @@ export function extractCodexSkillToolEvents(
|
|
|
541
587
|
];
|
|
542
588
|
}
|
|
543
589
|
|
|
590
|
+
/**
|
|
591
|
+
* Resolve a skill's content-version marker ("skillVersion", skills-first-class
|
|
592
|
+
* US-015) — the sha256 of its SKILL.md as `sha256:<hex>` (the canonical
|
|
593
|
+
* content-hash shape used across hq-cloud, e.g. `watcher.ts`). This is the
|
|
594
|
+
* version half of the analytics-v2 usage join key (`skill_uid` + `skillVersion`),
|
|
595
|
+
* so a captured run maps to the exact version that produced it.
|
|
596
|
+
*
|
|
597
|
+
* Resolution is best-effort against `<hqRoot>/.claude/skills/<skill>/SKILL.md` —
|
|
598
|
+
* the canonical single-source skills location, which holds an entry (a real dir
|
|
599
|
+
* or a symlink) for every skill by its FULL invocation name incl. namespace
|
|
600
|
+
* (`indigo:hello-world`, `personal:worktree`, `deploy`, …). A `.agents/skills/`
|
|
601
|
+
* fallback covers the Codex bridge layout. `readFile` follows symlinks, so a
|
|
602
|
+
* company/personal skill surfaced into `.claude/skills/` resolves transparently.
|
|
603
|
+
* Returns undefined when `hqRoot` is absent or the file can't be located/read,
|
|
604
|
+
* so the wire field is simply OMITTED — additive + backwards-compatible, exactly
|
|
605
|
+
* like the optional `companyUid` attribution.
|
|
606
|
+
*
|
|
607
|
+
* "At invocation time" is approximated by the SKILL.md's content AT CAPTURE TIME:
|
|
608
|
+
* the collector scans historical session logs during sync, so the file's current
|
|
609
|
+
* bytes are the closest available proxy for the version that ran. A skill edited
|
|
610
|
+
* between run and sync can differ — acceptable for a usage-rollup join key (the
|
|
611
|
+
* overwhelming majority of runs sync before any edit to the skill).
|
|
612
|
+
*/
|
|
613
|
+
export async function computeSkillVersion(
|
|
614
|
+
hqRoot: string | undefined,
|
|
615
|
+
skill: string,
|
|
616
|
+
): Promise<string | undefined> {
|
|
617
|
+
if (!hqRoot || !skill) return undefined;
|
|
618
|
+
// A skill token maps to exactly one directory name here; reject anything that
|
|
619
|
+
// could escape the skills dir (path traversal / separators). Namespaced names
|
|
620
|
+
// use `:` (not a separator) and pass.
|
|
621
|
+
if (skill.includes("/") || skill.includes("\\") || skill.includes("..")) {
|
|
622
|
+
return undefined;
|
|
623
|
+
}
|
|
624
|
+
const candidates = [
|
|
625
|
+
path.join(hqRoot, ".claude", "skills", skill, "SKILL.md"),
|
|
626
|
+
path.join(hqRoot, ".agents", "skills", skill, "SKILL.md"),
|
|
627
|
+
];
|
|
628
|
+
for (const candidate of candidates) {
|
|
629
|
+
try {
|
|
630
|
+
const bytes = await fs.readFile(candidate);
|
|
631
|
+
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
632
|
+
} catch {
|
|
633
|
+
// Not at this candidate — try the next.
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return undefined;
|
|
637
|
+
}
|
|
638
|
+
|
|
544
639
|
/** Shape the event for the wire. Drops raw args unless explicitly enabled.
|
|
545
640
|
*
|
|
546
641
|
* `companyUid` (US-002): the caller resolves the event's `cwd` → owning company
|
|
@@ -548,7 +643,11 @@ export function extractCodexSkillToolEvents(
|
|
|
548
643
|
* server's KEEP allowlist (`apps/hq-pro/src/vault-service/handlers/
|
|
549
644
|
* skill-invocations.ts`). When undefined (cwd maps to no company repo) the
|
|
550
645
|
* field is OMITTED — the server treats absence as unattributed/personal. The
|
|
551
|
-
* reserved value `unattributed` is never produced.
|
|
646
|
+
* reserved value `unattributed` is never produced.
|
|
647
|
+
*
|
|
648
|
+
* `skillVersion` (US-015): the caller stamps `ev.skillVersion` from
|
|
649
|
+
* `computeSkillVersion` before shaping; a `sha256:<hex>` marker (or omitted when
|
|
650
|
+
* unresolved). Also on the server KEEP allowlist; additive + backwards-compatible. */
|
|
552
651
|
function toWireRow(ev: SkillEvent, companyUid?: string): Record<string, unknown> {
|
|
553
652
|
const row: Record<string, unknown> = {
|
|
554
653
|
skill: ev.skill,
|
|
@@ -559,6 +658,7 @@ function toWireRow(ev: SkillEvent, companyUid?: string): Record<string, unknown>
|
|
|
559
658
|
if (ev.timestamp !== undefined) row.timestamp = ev.timestamp;
|
|
560
659
|
if (ev.uuid !== undefined) row.uuid = ev.uuid;
|
|
561
660
|
if (ev.cwd !== undefined) row.cwd = ev.cwd;
|
|
661
|
+
if (ev.skillVersion !== undefined) row.skillVersion = ev.skillVersion;
|
|
562
662
|
if (companyUid !== undefined) row.companyUid = companyUid;
|
|
563
663
|
// INCLUDE_ARGS_PREVIEW is intentionally a compile-time constant `false`;
|
|
564
664
|
// the guarded branch documents the (currently disabled) egress path.
|
|
@@ -754,7 +854,27 @@ export async function collectAndSendSkillTelemetry(
|
|
|
754
854
|
// When `hqRoot` is omitted the map is empty → every event stays unattributed.
|
|
755
855
|
const repoCompanyMap: RepoCompanyMap = opts.hqRoot
|
|
756
856
|
? await buildRepoCompanyMap(opts.hqRoot)
|
|
757
|
-
: { entries: [] };
|
|
857
|
+
: { entries: [], bySlug: new Map() };
|
|
858
|
+
|
|
859
|
+
// skillVersion resolution (US-015): resolve each skill's SKILL.md content hash
|
|
860
|
+
// ONCE per run (skills repeat across a session) and stamp it onto every event.
|
|
861
|
+
// Injectable for tests; defaults to hashing under hqRoot. Errors degrade to
|
|
862
|
+
// undefined so a resolver failure never blocks telemetry (additive field).
|
|
863
|
+
const resolveSkillVersion =
|
|
864
|
+
opts.resolveSkillVersion ??
|
|
865
|
+
((skill: string) => computeSkillVersion(opts.hqRoot, skill));
|
|
866
|
+
const skillVersionCache = new Map<string, string | undefined>();
|
|
867
|
+
const skillVersionFor = async (skill: string): Promise<string | undefined> => {
|
|
868
|
+
if (skillVersionCache.has(skill)) return skillVersionCache.get(skill);
|
|
869
|
+
let version: string | undefined;
|
|
870
|
+
try {
|
|
871
|
+
version = await resolveSkillVersion(skill);
|
|
872
|
+
} catch {
|
|
873
|
+
version = undefined;
|
|
874
|
+
}
|
|
875
|
+
skillVersionCache.set(skill, version);
|
|
876
|
+
return version;
|
|
877
|
+
};
|
|
758
878
|
|
|
759
879
|
// 1. Opt-in check — reuse the same gate as token telemetry.
|
|
760
880
|
let enabled: boolean;
|
|
@@ -909,9 +1029,17 @@ export async function collectAndSendSkillTelemetry(
|
|
|
909
1029
|
continue;
|
|
910
1030
|
}
|
|
911
1031
|
}
|
|
912
|
-
//
|
|
913
|
-
//
|
|
914
|
-
|
|
1032
|
+
// Attribute the event to a company (cmp_* uid) from the per-run map.
|
|
1033
|
+
// A company SKILL (`<slug>:<skill>`) belongs to ITS company regardless of
|
|
1034
|
+
// the cwd it ran in, so skill-based resolution takes precedence; a core
|
|
1035
|
+
// skill falls back to the cwd's owning company. Unresolved by both →
|
|
1036
|
+
// undefined → companyUid omitted (unattributed/personal).
|
|
1037
|
+
const companyUid =
|
|
1038
|
+
resolveCompanyForSkill(ev.skill, repoCompanyMap) ??
|
|
1039
|
+
resolveCompanyForCwd(ev.cwd, repoCompanyMap);
|
|
1040
|
+
// Stamp the skill's content-version marker (US-015). Unresolved → the
|
|
1041
|
+
// field is omitted from the wire row (additive).
|
|
1042
|
+
ev.skillVersion = await skillVersionFor(ev.skill);
|
|
915
1043
|
const wireRow = toWireRow(ev, companyUid);
|
|
916
1044
|
const wasOversized = jsonBytes(wireRow) > maxRowBytes;
|
|
917
1045
|
const bounded = boundRowForPost(wireRow, maxRowBytes);
|
package/src/sync/event-sync.ts
CHANGED
|
@@ -48,6 +48,11 @@ import {
|
|
|
48
48
|
} from "./push-receiver.js";
|
|
49
49
|
import { PushEventEmitter } from "../watcher.js";
|
|
50
50
|
import type { TreeChangeBatch } from "../watcher.js";
|
|
51
|
+
import {
|
|
52
|
+
emitCloudTelemetry,
|
|
53
|
+
type CloudTelemetryClient,
|
|
54
|
+
type TelemetryClaims,
|
|
55
|
+
} from "../telemetry-events.js";
|
|
51
56
|
import {
|
|
52
57
|
createSyncLatencyMetricPublisher,
|
|
53
58
|
type SyncMetricCredentials,
|
|
@@ -415,6 +420,8 @@ export interface StartEventSyncOptions {
|
|
|
415
420
|
subscribe?: (deviceId: string) => Promise<SubscribeSyncResponse>;
|
|
416
421
|
buildSqs?: (resp: SubscribeSyncResponse) => SqsClientLike;
|
|
417
422
|
transport?: HttpPushTransport;
|
|
423
|
+
telemetryClient?: CloudTelemetryClient | null;
|
|
424
|
+
telemetryClaims?: TelemetryClaims | null;
|
|
418
425
|
now?: () => number;
|
|
419
426
|
}
|
|
420
427
|
|
|
@@ -473,6 +480,8 @@ export async function startEventSync(
|
|
|
473
480
|
log(
|
|
474
481
|
`event-sync: publish failed for ${ctx.relativePath ?? "?"}: ${err.message}`,
|
|
475
482
|
),
|
|
483
|
+
telemetryClient: opts.telemetryClient,
|
|
484
|
+
telemetryClaims: opts.telemetryClaims,
|
|
476
485
|
});
|
|
477
486
|
|
|
478
487
|
// ── receive leg (US-018) ──
|
|
@@ -515,6 +524,20 @@ export async function startEventSync(
|
|
|
515
524
|
log(
|
|
516
525
|
`event-sync: live (tenant=${tenantId} device=${deviceId} queue=${initial.queueUrl})`,
|
|
517
526
|
);
|
|
527
|
+
void emitCloudTelemetry(
|
|
528
|
+
opts.telemetryClient,
|
|
529
|
+
{
|
|
530
|
+
eventName: "event_sync_live",
|
|
531
|
+
source: "event-sync",
|
|
532
|
+
properties: {
|
|
533
|
+
deviceId,
|
|
534
|
+
startupResult: "live",
|
|
535
|
+
receiveState: "enabled",
|
|
536
|
+
publishState: "enabled",
|
|
537
|
+
},
|
|
538
|
+
},
|
|
539
|
+
{ claims: opts.telemetryClaims },
|
|
540
|
+
);
|
|
518
541
|
|
|
519
542
|
return {
|
|
520
543
|
publishBatch: (batch) => void emitter.emitForBatch(batch),
|
|
@@ -529,6 +552,20 @@ export async function startEventSync(
|
|
|
529
552
|
log(
|
|
530
553
|
`event-sync: startup failed, continuing poll-only: ${err instanceof Error ? err.message : String(err)}`,
|
|
531
554
|
);
|
|
555
|
+
void emitCloudTelemetry(
|
|
556
|
+
opts.telemetryClient,
|
|
557
|
+
{
|
|
558
|
+
eventName: "event_sync_live",
|
|
559
|
+
source: "event-sync",
|
|
560
|
+
properties: {
|
|
561
|
+
deviceId: opts.deviceId,
|
|
562
|
+
startupResult: "poll_only",
|
|
563
|
+
receiveState: "disabled",
|
|
564
|
+
publishState: "disabled",
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
{ claims: opts.telemetryClaims },
|
|
568
|
+
);
|
|
532
569
|
return null;
|
|
533
570
|
}
|
|
534
571
|
}
|
package/src/sync/pull-scope.ts
CHANGED
|
@@ -18,6 +18,20 @@
|
|
|
18
18
|
* The two surfaces drifting apart is exactly what caused the wedge, so the
|
|
19
19
|
* resolver is defined ONCE here and consumed by both. Any future change to how
|
|
20
20
|
* scope is derived now lands for the runner and the CLI in lockstep.
|
|
21
|
+
*
|
|
22
|
+
* SESSIONS ARE PUSH-ONLY (US-006). Session transcripts are pushed by clients
|
|
23
|
+
* into the company vault under `sessions/{personUid}/...`, but the sync engine
|
|
24
|
+
* NEVER auto-pulls that prefix onto grantee laptops — in EVERY sync mode,
|
|
25
|
+
* including `all` (which otherwise means a full-bucket pull) and an owner's
|
|
26
|
+
* role-wide scope. That never-pull contract is expressed HERE as
|
|
27
|
+
* `PullScope.excludePrefixes` (always carrying {@link SESSIONS_SCOPE_PREFIX}),
|
|
28
|
+
* NOT in `ignore.ts` (which only filters the push-side walk — sessions MUST
|
|
29
|
+
* still upload). A specific session is materialized on demand via `hq files
|
|
30
|
+
* get`, which pins the key so it survives scope-shrink pruning. The exclude set
|
|
31
|
+
* is subtracted from the pull's download filter but is deliberately NOT fed
|
|
32
|
+
* into scope-shrink, so a locally-authored (`direction:"up"`) or pinned
|
|
33
|
+
* on-demand-materialized session is never orphaned. See `computePullPlan`
|
|
34
|
+
* (subtraction) and `scope-shrink.ts` (retention).
|
|
21
35
|
*/
|
|
22
36
|
import * as fs from "fs";
|
|
23
37
|
import * as path from "path";
|
|
@@ -55,6 +69,41 @@ export interface PullScope {
|
|
|
55
69
|
syncMode: SyncMode;
|
|
56
70
|
/** Coalesced company-relative prefixes; omitted/undefined for `all`. */
|
|
57
71
|
prefixSet?: string[];
|
|
72
|
+
/**
|
|
73
|
+
* Company-relative prefixes subtracted from the effective PULL scope in
|
|
74
|
+
* EVERY mode — the push-only set. Always carries {@link SESSIONS_SCOPE_PREFIX}
|
|
75
|
+
* (US-006): session transcripts are pushed into the vault but never
|
|
76
|
+
* auto-pulled onto grantee laptops, even under `all`/owner scope. A key is
|
|
77
|
+
* downloaded iff it is covered by the inclusion `prefixSet` (or `all`) AND
|
|
78
|
+
* NOT covered by any `excludePrefixes` entry. Distinct from `prefixSet` and
|
|
79
|
+
* deliberately NOT fed into scope-shrink, so a locally-authored or pinned
|
|
80
|
+
* on-demand-materialized session is never pruned. Stamped on every
|
|
81
|
+
* `resolvePullScope` return via {@link sessionsPushOnlyScope}.
|
|
82
|
+
*/
|
|
83
|
+
excludePrefixes: string[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Company-relative prefix under which session transcripts are pushed
|
|
88
|
+
* (`sessions/{personUid}/...`). Push-only by contract (US-006): a client
|
|
89
|
+
* uploads its own session transcripts here, but the sync engine NEVER
|
|
90
|
+
* auto-pulls this prefix onto grantee laptops — in ANY sync mode, including
|
|
91
|
+
* `all`. Materializing a specific session is an on-demand action (`hq files
|
|
92
|
+
* get`), which pins the key so it survives scope-shrink pruning.
|
|
93
|
+
*/
|
|
94
|
+
export const SESSIONS_SCOPE_PREFIX = "sessions/";
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Stamp the push-only session exclusion onto a resolved scope. Centralized so
|
|
98
|
+
* NO `resolvePullScope` return site can silently regress to a full session pull
|
|
99
|
+
* (the architect's flagged risk): the exclusion rides EVERY mode — `all`,
|
|
100
|
+
* owner→all, agent→all, config-all, shared, custom, and every degrade-to-`all`
|
|
101
|
+
* path — because each return is built through this helper.
|
|
102
|
+
*/
|
|
103
|
+
function sessionsPushOnlyScope(
|
|
104
|
+
scope: Omit<PullScope, "excludePrefixes">,
|
|
105
|
+
): PullScope {
|
|
106
|
+
return { ...scope, excludePrefixes: [SESSIONS_SCOPE_PREFIX] };
|
|
58
107
|
}
|
|
59
108
|
|
|
60
109
|
/**
|
|
@@ -81,11 +130,11 @@ export async function resolvePullScope(
|
|
|
81
130
|
// When omitted, pins are simply not unioned (no behavior change).
|
|
82
131
|
hqRoot?: string,
|
|
83
132
|
): Promise<PullScope> {
|
|
84
|
-
if (!client.getMembershipSyncConfig) return { syncMode: "all" };
|
|
133
|
+
if (!client.getMembershipSyncConfig) return sessionsPushOnlyScope({ syncMode: "all" });
|
|
85
134
|
try {
|
|
86
135
|
const memberships = await client.listMyMemberships();
|
|
87
136
|
const m = memberships.find((x) => x.companyUid === companyUid);
|
|
88
|
-
if (!m) return { syncMode: "all" };
|
|
137
|
+
if (!m) return sessionsPushOnlyScope({ syncMode: "all" });
|
|
89
138
|
// OWNER role-bypass (feedback_67bdb8a4 / feedback_a46c3b37). An owner's
|
|
90
139
|
// effective vault access is the WHOLE company by role — the data plane
|
|
91
140
|
// resolves owner to `admin` on every key (`resolveEffectivePermission`'s
|
|
@@ -110,7 +159,7 @@ export async function resolvePullScope(
|
|
|
110
159
|
// below — a member/guest's explicit grants ARE their full footprint, and a
|
|
111
160
|
// (conditionally bypassed) admin stays grant-scoped so a company that set
|
|
112
161
|
// `admin.viewOthersFiles=false` keeps its admins narrowed.
|
|
113
|
-
if (m.role === "owner") return { syncMode: "all" };
|
|
162
|
+
if (m.role === "owner") return sessionsPushOnlyScope({ syncMode: "all" });
|
|
114
163
|
// Agent memberships (`agt_…#cmp_…`) belong to an agent identity that owns NO
|
|
115
164
|
// person entity. hq-pro's per-membership sync-config endpoint sits behind an
|
|
116
165
|
// up-front person-gate, so an agent caller is rejected with
|
|
@@ -124,9 +173,9 @@ export async function resolvePullScope(
|
|
|
124
173
|
// 403 spam. (A server-side alternative would be to exempt sync-config from
|
|
125
174
|
// the up-front person-gate and let its own `authorizeSyncConfigAccess`
|
|
126
175
|
// handle agent ownership; tracked as a possible follow-up.)
|
|
127
|
-
if (m.membershipKey?.startsWith("agt_")) return { syncMode: "all" };
|
|
176
|
+
if (m.membershipKey?.startsWith("agt_")) return sessionsPushOnlyScope({ syncMode: "all" });
|
|
128
177
|
const cfg = await client.getMembershipSyncConfig(m.membershipKey);
|
|
129
|
-
if (cfg.syncMode === "all") return { syncMode: "all" };
|
|
178
|
+
if (cfg.syncMode === "all") return sessionsPushOnlyScope({ syncMode: "all" });
|
|
130
179
|
|
|
131
180
|
// Pins are company-relative prefixes a user explicitly materialized via
|
|
132
181
|
// `hq files get`. They're unioned into the scope so a scoped pull keeps
|
|
@@ -143,15 +192,15 @@ export async function resolvePullScope(
|
|
|
143
192
|
// `coalescePrefixes` (which drops empties) to "nothing", which would
|
|
144
193
|
// prune the whole tree. An everything-scope is semantically `all`.
|
|
145
194
|
if (customPrefixes.some((p) => p.prefix === "")) {
|
|
146
|
-
return { syncMode: "all" };
|
|
195
|
+
return sessionsPushOnlyScope({ syncMode: "all" });
|
|
147
196
|
}
|
|
148
|
-
return {
|
|
197
|
+
return sessionsPushOnlyScope({
|
|
149
198
|
syncMode: "custom",
|
|
150
199
|
prefixSet: coalescePrefixes([
|
|
151
200
|
...customPrefixes,
|
|
152
201
|
...pinPrefixes.map(pathToScopePrefix),
|
|
153
202
|
]),
|
|
154
|
-
};
|
|
203
|
+
});
|
|
155
204
|
}
|
|
156
205
|
// shared: scope to the caller's explicit grants. Real grant paths are
|
|
157
206
|
// inconsistent — full (`companies/<slug>/x/*`), slug-anchored
|
|
@@ -165,7 +214,7 @@ export async function resolvePullScope(
|
|
|
165
214
|
// and scope-shrink would prune every clean local file. Degrade to `all`
|
|
166
215
|
// instead. A genuinely-empty grant list (the method exists and returns
|
|
167
216
|
// []) is a real "nothing shared with me" and is allowed to narrow.
|
|
168
|
-
if (!client.listMyExplicitGrants) return { syncMode: "all" };
|
|
217
|
+
if (!client.listMyExplicitGrants) return sessionsPushOnlyScope({ syncMode: "all" });
|
|
169
218
|
const grants = await client.listMyExplicitGrants(companyUid);
|
|
170
219
|
const sharedPrefixes = grants.map((g) =>
|
|
171
220
|
grantPathToScopePrefix(g.path, slug),
|
|
@@ -174,18 +223,18 @@ export async function resolvePullScope(
|
|
|
174
223
|
// `coalescePrefixes` drops empties (collapsing "everything" to "nothing"),
|
|
175
224
|
// treat any such grant as full-access `all` rather than risk pruning.
|
|
176
225
|
if (sharedPrefixes.some((p) => p.prefix === "")) {
|
|
177
|
-
return { syncMode: "all" };
|
|
226
|
+
return sessionsPushOnlyScope({ syncMode: "all" });
|
|
178
227
|
}
|
|
179
|
-
return {
|
|
228
|
+
return sessionsPushOnlyScope({
|
|
180
229
|
syncMode: "shared",
|
|
181
230
|
prefixSet: coalescePrefixes([
|
|
182
231
|
...sharedPrefixes,
|
|
183
232
|
...pinPrefixes.map(pathToScopePrefix),
|
|
184
233
|
]),
|
|
185
|
-
};
|
|
234
|
+
});
|
|
186
235
|
} catch {
|
|
187
236
|
// Degrade to `all` — never prune on a resolution failure.
|
|
188
|
-
return { syncMode: "all" };
|
|
237
|
+
return sessionsPushOnlyScope({ syncMode: "all" });
|
|
189
238
|
}
|
|
190
239
|
}
|
|
191
240
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
emitCloudTelemetry,
|
|
5
|
+
type CloudTelemetryClient,
|
|
6
|
+
type TelemetryEvent,
|
|
7
|
+
} from "./telemetry-events.js";
|
|
8
|
+
import type { TelemetryEventsBatch } from "./vault-client.js";
|
|
9
|
+
|
|
10
|
+
describe("emitCloudTelemetry", () => {
|
|
11
|
+
it("is best-effort, stamps metadata, caps batches, and sanitizes properties", async () => {
|
|
12
|
+
const posts: TelemetryEventsBatch[] = [];
|
|
13
|
+
const client: CloudTelemetryClient = {
|
|
14
|
+
postTelemetryEvents: vi.fn(async (batch) => {
|
|
15
|
+
posts.push(batch);
|
|
16
|
+
throw new Error("vault unavailable");
|
|
17
|
+
}),
|
|
18
|
+
};
|
|
19
|
+
const logs: string[] = [];
|
|
20
|
+
const events: TelemetryEvent[] = Array.from({ length: 55 }, (_, i) => ({
|
|
21
|
+
eventName: `sync_completed_${i}`,
|
|
22
|
+
source: "hq-sync",
|
|
23
|
+
companyUid: "cmp_abc",
|
|
24
|
+
sessionId: "sync_123",
|
|
25
|
+
properties:
|
|
26
|
+
i === 0
|
|
27
|
+
? ({
|
|
28
|
+
fileCount: 2,
|
|
29
|
+
result: "success",
|
|
30
|
+
companyUid: "cmp_abc",
|
|
31
|
+
labels: ["completed", "has spaces", "cmp_abc"],
|
|
32
|
+
path: "/Users/alice/company/secret.md",
|
|
33
|
+
filename: "secret.md",
|
|
34
|
+
secretToken: "tok_123",
|
|
35
|
+
message: "free text should not leave the process",
|
|
36
|
+
unsafeLabel: "contains spaces",
|
|
37
|
+
nanCount: Number.NaN,
|
|
38
|
+
enabled: true,
|
|
39
|
+
nested: { value: "nope" },
|
|
40
|
+
"bad-key": "nope",
|
|
41
|
+
} as unknown as TelemetryEvent["properties"])
|
|
42
|
+
: { fileCount: i },
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
await expect(
|
|
46
|
+
emitCloudTelemetry(client, events, {
|
|
47
|
+
claims: {
|
|
48
|
+
"custom:entityType": "agent",
|
|
49
|
+
"custom:entityUid": "agt_runner",
|
|
50
|
+
},
|
|
51
|
+
timeoutMs: 25,
|
|
52
|
+
log: (message) => logs.push(message),
|
|
53
|
+
}),
|
|
54
|
+
).resolves.toBeUndefined();
|
|
55
|
+
|
|
56
|
+
expect(client.postTelemetryEvents).toHaveBeenCalledTimes(1);
|
|
57
|
+
expect(client.postTelemetryEvents).toHaveBeenCalledWith(
|
|
58
|
+
{ events: expect.any(Array) },
|
|
59
|
+
{ timeoutMs: 25 },
|
|
60
|
+
);
|
|
61
|
+
expect(posts).toHaveLength(1);
|
|
62
|
+
expect(posts[0].events).toHaveLength(50);
|
|
63
|
+
|
|
64
|
+
const first = posts[0].events[0];
|
|
65
|
+
expect(first).toMatchObject({
|
|
66
|
+
eventName: "sync_completed_0",
|
|
67
|
+
app: "hq-cloud",
|
|
68
|
+
source: "hq-sync",
|
|
69
|
+
companyUid: "cmp_abc",
|
|
70
|
+
agentUid: "agt_runner",
|
|
71
|
+
sessionId: "sync_123",
|
|
72
|
+
schemaVersion: 1,
|
|
73
|
+
properties: {
|
|
74
|
+
fileCount: 2,
|
|
75
|
+
result: "success",
|
|
76
|
+
companyUid: "cmp_abc",
|
|
77
|
+
labels: ["completed", "cmp_abc"],
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
expect(Number.isNaN(Date.parse(first.occurredAt))).toBe(false);
|
|
81
|
+
expect(JSON.stringify(posts[0])).not.toContain("personUid");
|
|
82
|
+
expect(JSON.stringify(posts[0])).not.toContain("/Users/alice");
|
|
83
|
+
expect(JSON.stringify(posts[0])).not.toContain("secret.md");
|
|
84
|
+
expect(JSON.stringify(posts[0])).not.toContain("free text");
|
|
85
|
+
expect(JSON.stringify(posts[0])).not.toContain("tok_123");
|
|
86
|
+
expect(logs.join("\n")).toContain("postTelemetryEvents failed");
|
|
87
|
+
});
|
|
88
|
+
});
|