@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
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TelemetryEventsBatch,
|
|
3
|
+
TelemetryEventsIngestResult,
|
|
4
|
+
} from "./vault-client.js";
|
|
5
|
+
|
|
6
|
+
const MAX_EVENTS_PER_BATCH = 50;
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 1500;
|
|
8
|
+
const MAX_PROPERTIES = 40;
|
|
9
|
+
const MAX_ARRAY_VALUES = 20;
|
|
10
|
+
|
|
11
|
+
type TelemetryPrimitive = string | number;
|
|
12
|
+
export type TelemetryProperties = Record<
|
|
13
|
+
string,
|
|
14
|
+
TelemetryPrimitive | TelemetryPrimitive[] | null | undefined
|
|
15
|
+
>;
|
|
16
|
+
|
|
17
|
+
export interface TelemetryEvent {
|
|
18
|
+
eventName: string;
|
|
19
|
+
source: string;
|
|
20
|
+
occurredAt?: string;
|
|
21
|
+
companyUid?: string;
|
|
22
|
+
agentUid?: string;
|
|
23
|
+
sessionId?: string;
|
|
24
|
+
schemaVersion?: 1;
|
|
25
|
+
properties?: TelemetryProperties;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TelemetryClaims {
|
|
29
|
+
"custom:entityType"?: unknown;
|
|
30
|
+
"custom:entityUid"?: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CloudTelemetryClient {
|
|
34
|
+
postTelemetryEvents?: (
|
|
35
|
+
batch: TelemetryEventsBatch,
|
|
36
|
+
options?: { timeoutMs?: number },
|
|
37
|
+
) => Promise<TelemetryEventsIngestResult>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface EmitCloudTelemetryOptions {
|
|
41
|
+
claims?: TelemetryClaims | null;
|
|
42
|
+
timeoutMs?: number;
|
|
43
|
+
log?: (message: string) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const SAFE_KEY = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
|
|
47
|
+
const SAFE_LABEL = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
48
|
+
const BLOCKED_STRING_PROPERTY_KEY =
|
|
49
|
+
/(path|filename|name|email|secret|token|text|message|prompt|body|content|cwd|dir|url|uri|prefix|link)/i;
|
|
50
|
+
|
|
51
|
+
export function agentUidFromClaims(
|
|
52
|
+
claims: TelemetryClaims | null | undefined,
|
|
53
|
+
): string | undefined {
|
|
54
|
+
if (
|
|
55
|
+
claims?.["custom:entityType"] === "agent" &&
|
|
56
|
+
typeof claims["custom:entityUid"] === "string" &&
|
|
57
|
+
claims["custom:entityUid"].startsWith("agt_")
|
|
58
|
+
) {
|
|
59
|
+
return claims["custom:entityUid"];
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function emitCloudTelemetry(
|
|
65
|
+
client: CloudTelemetryClient | null | undefined,
|
|
66
|
+
events: TelemetryEvent | TelemetryEvent[],
|
|
67
|
+
options: EmitCloudTelemetryOptions = {},
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
if (!client?.postTelemetryEvents) return;
|
|
70
|
+
|
|
71
|
+
const derivedAgentUid = agentUidFromClaims(options.claims);
|
|
72
|
+
const now = new Date().toISOString();
|
|
73
|
+
const sanitized = (Array.isArray(events) ? events : [events])
|
|
74
|
+
.slice(0, MAX_EVENTS_PER_BATCH)
|
|
75
|
+
.map((event) => sanitizeEvent(event, now, derivedAgentUid))
|
|
76
|
+
.filter((event): event is TelemetryEventsBatch["events"][number] => event !== null);
|
|
77
|
+
|
|
78
|
+
if (sanitized.length === 0) return;
|
|
79
|
+
|
|
80
|
+
const timeoutMs = normalizeTimeout(options.timeoutMs);
|
|
81
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
82
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
83
|
+
timeout = setTimeout(
|
|
84
|
+
() => reject(new Error(`telemetry events timed out after ${timeoutMs}ms`)),
|
|
85
|
+
timeoutMs,
|
|
86
|
+
);
|
|
87
|
+
timeout.unref?.();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const postPromise = client
|
|
91
|
+
.postTelemetryEvents({ events: sanitized }, { timeoutMs })
|
|
92
|
+
.catch((err) => {
|
|
93
|
+
throw err;
|
|
94
|
+
});
|
|
95
|
+
postPromise.catch(() => undefined);
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
await Promise.race([postPromise, timeoutPromise]);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
warn(options.log, `postTelemetryEvents failed (${describeError(err)})`);
|
|
101
|
+
} finally {
|
|
102
|
+
if (timeout !== undefined) clearTimeout(timeout);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function sanitizeEvent(
|
|
107
|
+
event: TelemetryEvent,
|
|
108
|
+
fallbackOccurredAt: string,
|
|
109
|
+
derivedAgentUid: string | undefined,
|
|
110
|
+
): TelemetryEventsBatch["events"][number] | null {
|
|
111
|
+
const eventName = safeLabel(event.eventName);
|
|
112
|
+
const source = safeLabel(event.source);
|
|
113
|
+
if (!eventName || !source) return null;
|
|
114
|
+
|
|
115
|
+
const occurredAt = safeIso(event.occurredAt) ?? fallbackOccurredAt;
|
|
116
|
+
const properties = sanitizeProperties(event.properties);
|
|
117
|
+
const companyUid = safeLabel(event.companyUid);
|
|
118
|
+
const agentUid = safeLabel(event.agentUid) ?? derivedAgentUid;
|
|
119
|
+
const sessionId = safeLabel(event.sessionId);
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
eventName,
|
|
123
|
+
app: "hq-cloud",
|
|
124
|
+
source,
|
|
125
|
+
occurredAt,
|
|
126
|
+
...(companyUid ? { companyUid } : {}),
|
|
127
|
+
...(agentUid ? { agentUid } : {}),
|
|
128
|
+
...(sessionId ? { sessionId } : {}),
|
|
129
|
+
schemaVersion: 1,
|
|
130
|
+
...(properties ? { properties } : {}),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function sanitizeProperties(
|
|
135
|
+
properties: TelemetryProperties | undefined,
|
|
136
|
+
): Record<string, unknown> | undefined {
|
|
137
|
+
if (!properties) return undefined;
|
|
138
|
+
const out: Record<string, unknown> = {};
|
|
139
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
140
|
+
if (Object.keys(out).length >= MAX_PROPERTIES) break;
|
|
141
|
+
if (!SAFE_KEY.test(key)) continue;
|
|
142
|
+
const sanitized = sanitizePropertyValue(key, value);
|
|
143
|
+
if (sanitized !== undefined) out[key] = sanitized;
|
|
144
|
+
}
|
|
145
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function sanitizePropertyValue(
|
|
149
|
+
key: string,
|
|
150
|
+
value: unknown,
|
|
151
|
+
): TelemetryPrimitive | TelemetryPrimitive[] | undefined {
|
|
152
|
+
if (value === null || value === undefined) return undefined;
|
|
153
|
+
if (Array.isArray(value)) {
|
|
154
|
+
const values = value
|
|
155
|
+
.slice(0, MAX_ARRAY_VALUES)
|
|
156
|
+
.map((item) => sanitizeScalar(key, item))
|
|
157
|
+
.filter((item): item is TelemetryPrimitive => item !== undefined);
|
|
158
|
+
return values.length > 0 ? values : undefined;
|
|
159
|
+
}
|
|
160
|
+
return sanitizeScalar(key, value);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function sanitizeScalar(
|
|
164
|
+
key: string,
|
|
165
|
+
value: unknown,
|
|
166
|
+
): TelemetryPrimitive | undefined {
|
|
167
|
+
if (typeof value === "number") {
|
|
168
|
+
return Number.isFinite(value) ? value : undefined;
|
|
169
|
+
}
|
|
170
|
+
if (typeof value !== "string") return undefined;
|
|
171
|
+
if (BLOCKED_STRING_PROPERTY_KEY.test(key)) return undefined;
|
|
172
|
+
return safeLabel(value);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function safeLabel(value: unknown): string | undefined {
|
|
176
|
+
if (typeof value !== "string") return undefined;
|
|
177
|
+
const trimmed = value.trim();
|
|
178
|
+
if (!SAFE_LABEL.test(trimmed)) return undefined;
|
|
179
|
+
return trimmed;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function safeIso(value: unknown): string | undefined {
|
|
183
|
+
if (typeof value !== "string") return undefined;
|
|
184
|
+
const parsed = Date.parse(value);
|
|
185
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function normalizeTimeout(value: number | undefined): number {
|
|
189
|
+
if (value !== undefined && Number.isFinite(value) && value > 0) {
|
|
190
|
+
return Math.trunc(value);
|
|
191
|
+
}
|
|
192
|
+
return DEFAULT_TIMEOUT_MS;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function warn(log: ((message: string) => void) | undefined, message: string): void {
|
|
196
|
+
try {
|
|
197
|
+
(log ?? console.warn)(`[telemetry-events] ${message}`);
|
|
198
|
+
} catch {
|
|
199
|
+
/* diagnostics must never affect the caller */
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function describeError(err: unknown): string {
|
|
204
|
+
return err instanceof Error ? err.message : String(err);
|
|
205
|
+
}
|
package/src/telemetry.ts
CHANGED
|
@@ -342,7 +342,7 @@ export async function collectAndSendTelemetry(
|
|
|
342
342
|
// When `hqRoot` is omitted the map is empty → every event stays unattributed.
|
|
343
343
|
const repoCompanyMap: RepoCompanyMap = opts.hqRoot
|
|
344
344
|
? await buildRepoCompanyMap(opts.hqRoot)
|
|
345
|
-
: { entries: [] };
|
|
345
|
+
: { entries: [], bySlug: new Map() };
|
|
346
346
|
|
|
347
347
|
// 1. Opt-in check (server-authoritative, with local fallback).
|
|
348
348
|
let enabled: boolean;
|
package/src/vault-client.test.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type EntityInfo,
|
|
17
17
|
type ExplicitGrant,
|
|
18
18
|
type MembershipSyncConfig,
|
|
19
|
+
type TelemetryEventsBatch,
|
|
19
20
|
} from "./vault-client.js";
|
|
20
21
|
|
|
21
22
|
// ---------------------------------------------------------------------------
|
|
@@ -275,6 +276,40 @@ describe("API surface", () => {
|
|
|
275
276
|
});
|
|
276
277
|
});
|
|
277
278
|
|
|
279
|
+
it("postTelemetryEvents sends action events without personUid", async () => {
|
|
280
|
+
fetchSpy.mockResolvedValueOnce(
|
|
281
|
+
jsonResponse(200, { ok: true, written: 1, skipped: [] }),
|
|
282
|
+
);
|
|
283
|
+
const batch: TelemetryEventsBatch = {
|
|
284
|
+
events: [
|
|
285
|
+
{
|
|
286
|
+
eventName: "sync_runner_completed",
|
|
287
|
+
app: "hq-cloud",
|
|
288
|
+
source: "sync-runner",
|
|
289
|
+
occurredAt: "2026-07-07T12:00:00.000Z",
|
|
290
|
+
companyUid: "cmp_abc",
|
|
291
|
+
sessionId: "sync_123",
|
|
292
|
+
schemaVersion: 1,
|
|
293
|
+
properties: {
|
|
294
|
+
result: "completed",
|
|
295
|
+
fileCount: 2,
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const result = await client.postTelemetryEvents(batch);
|
|
302
|
+
|
|
303
|
+
expect(result).toEqual({ ok: true, written: 1, skipped: [] });
|
|
304
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
305
|
+
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
306
|
+
expect(url).toBe("https://vault.test.example.com/v1/telemetry/events");
|
|
307
|
+
expect((init.method as string).toUpperCase()).toBe("POST");
|
|
308
|
+
const body = JSON.parse(init.body as string) as Record<string, unknown>;
|
|
309
|
+
expect(body).toEqual(batch);
|
|
310
|
+
expect(JSON.stringify(body)).not.toContain("personUid");
|
|
311
|
+
});
|
|
312
|
+
|
|
278
313
|
it("updateRole sends correct payload", async () => {
|
|
279
314
|
fetchSpy.mockResolvedValueOnce(
|
|
280
315
|
jsonResponse(200, {
|
|
@@ -1067,7 +1102,10 @@ describe("setMembershipSyncConfig (US-004)", () => {
|
|
|
1067
1102
|
await expect(
|
|
1068
1103
|
client.setMembershipSyncConfig("psn_1#cmp_abc", { syncMode: "all" }),
|
|
1069
1104
|
).rejects.toThrow(/ETIMEDOUT/);
|
|
1070
|
-
|
|
1105
|
+
const syncConfigCalls = fetchSpy.mock.calls.filter(([url]) =>
|
|
1106
|
+
String(url).includes("/v1/memberships/psn_1%23cmp_abc/sync-config"),
|
|
1107
|
+
);
|
|
1108
|
+
expect(syncConfigCalls).toHaveLength(4);
|
|
1071
1109
|
});
|
|
1072
1110
|
});
|
|
1073
1111
|
|
package/src/vault-client.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { ClientInfo, VaultServiceConfig } from "./types.js";
|
|
10
10
|
import { buildClientHeaders } from "./client-info.js";
|
|
11
|
+
import { emitCloudTelemetry } from "./telemetry-events.js";
|
|
11
12
|
import { z } from "zod";
|
|
12
13
|
|
|
13
14
|
// ---------------------------------------------------------------------------
|
|
@@ -432,6 +433,34 @@ export interface UsageIngestResult {
|
|
|
432
433
|
skipped: Array<{ index: number; code: string; error: string }>;
|
|
433
434
|
}
|
|
434
435
|
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
// Cloud action telemetry (hq-pro `/v1/telemetry/events`)
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
export interface RawTelemetryEventInput {
|
|
441
|
+
eventName: string;
|
|
442
|
+
app: "hq-cloud";
|
|
443
|
+
source: string;
|
|
444
|
+
occurredAt: string;
|
|
445
|
+
companyUid?: string;
|
|
446
|
+
agentUid?: string;
|
|
447
|
+
sessionId?: string;
|
|
448
|
+
schemaVersion?: 1;
|
|
449
|
+
properties?: Record<string, unknown>;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export interface TelemetryEventsBatch {
|
|
453
|
+
/**
|
|
454
|
+
* Action events emitted by sync/CLI surfaces. `personUid` MUST NOT appear in
|
|
455
|
+
* either the batch or any event — hq-pro resolves the caller server-side from
|
|
456
|
+
* the Cognito JWT and rejects client-supplied person identifiers.
|
|
457
|
+
*/
|
|
458
|
+
events: RawTelemetryEventInput[];
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Same wire shape as `UsageIngestResult`; aliased for call-site clarity. */
|
|
462
|
+
export type TelemetryEventsIngestResult = UsageIngestResult;
|
|
463
|
+
|
|
435
464
|
// ---------------------------------------------------------------------------
|
|
436
465
|
// Skill-invocation telemetry (hq-pro `/v1/skill-invocations`)
|
|
437
466
|
// ---------------------------------------------------------------------------
|
|
@@ -442,11 +471,11 @@ export interface SkillInvocationBatch {
|
|
|
442
471
|
/**
|
|
443
472
|
* Skill-invocation event rows. Each row contains only the fields in the
|
|
444
473
|
* server's KEEP allowlist (skill, source, sessionId, timestamp, uuid, cwd,
|
|
445
|
-
* hasArgs,
|
|
446
|
-
*
|
|
447
|
-
* `./skill-telemetry.ts`. Any extra
|
|
448
|
-
* `unexpected-event-field`, so the extractor
|
|
449
|
-
* only thing allowed to produce these.
|
|
474
|
+
* hasArgs, the optional companyUid edge-attribution field — US-002, and the
|
|
475
|
+
* optional skillVersion content-hash marker — US-015). Raw argument text is
|
|
476
|
+
* never included — see the privacy note in `./skill-telemetry.ts`. Any extra
|
|
477
|
+
* field is rejected by hq-pro with `unexpected-event-field`, so the extractor
|
|
478
|
+
* in `./skill-telemetry.ts` is the only thing allowed to produce these.
|
|
450
479
|
*/
|
|
451
480
|
events: Array<Record<string, unknown>>;
|
|
452
481
|
}
|
|
@@ -461,6 +490,11 @@ export type SkillInvocationIngestResult = UsageIngestResult;
|
|
|
461
490
|
const MAX_RETRIES = 3;
|
|
462
491
|
const BASE_DELAY_MS = 500;
|
|
463
492
|
|
|
493
|
+
interface VaultRequestOptions {
|
|
494
|
+
timeoutMs?: number;
|
|
495
|
+
maxRetries?: number;
|
|
496
|
+
}
|
|
497
|
+
|
|
464
498
|
function isTransient(status: number): boolean {
|
|
465
499
|
return status === 429 || status >= 500;
|
|
466
500
|
}
|
|
@@ -769,6 +803,12 @@ function summarizeZodIssues(issues: readonly z.core.$ZodIssue[]): string {
|
|
|
769
803
|
.join("; ");
|
|
770
804
|
}
|
|
771
805
|
|
|
806
|
+
function companyUidFromMembershipId(membershipId: string): string | undefined {
|
|
807
|
+
const parts = membershipId.split("#");
|
|
808
|
+
const candidate = parts.length > 1 ? parts[parts.length - 1] : undefined;
|
|
809
|
+
return candidate?.startsWith("cmp_") ? candidate : undefined;
|
|
810
|
+
}
|
|
811
|
+
|
|
772
812
|
// ---------------------------------------------------------------------------
|
|
773
813
|
// VaultClient
|
|
774
814
|
// ---------------------------------------------------------------------------
|
|
@@ -1019,12 +1059,39 @@ export class VaultClient {
|
|
|
1019
1059
|
membershipId: string,
|
|
1020
1060
|
partial: SetMembershipSyncConfigInput,
|
|
1021
1061
|
): Promise<MembershipSyncConfig> {
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1062
|
+
const companyUid = companyUidFromMembershipId(membershipId);
|
|
1063
|
+
try {
|
|
1064
|
+
const result = await this.request(
|
|
1065
|
+
"PUT",
|
|
1066
|
+
`/v1/memberships/${encodeURIComponent(membershipId)}/sync-config`,
|
|
1067
|
+
partial,
|
|
1068
|
+
membershipSyncConfigSchema,
|
|
1069
|
+
);
|
|
1070
|
+
void emitCloudTelemetry(this, {
|
|
1071
|
+
eventName: "membership_sync_config_changed",
|
|
1072
|
+
source: "vault-client",
|
|
1073
|
+
...(companyUid ? { companyUid } : {}),
|
|
1074
|
+
properties: {
|
|
1075
|
+
syncMode: result.syncMode,
|
|
1076
|
+
customPathCount: result.customPaths?.length ?? 0,
|
|
1077
|
+
result: "success",
|
|
1078
|
+
defaultState: result.isDefault ? "default" : "configured",
|
|
1079
|
+
},
|
|
1080
|
+
});
|
|
1081
|
+
return result;
|
|
1082
|
+
} catch (err) {
|
|
1083
|
+
void emitCloudTelemetry(this, {
|
|
1084
|
+
eventName: "membership_sync_config_changed",
|
|
1085
|
+
source: "vault-client",
|
|
1086
|
+
...(companyUid ? { companyUid } : {}),
|
|
1087
|
+
properties: {
|
|
1088
|
+
syncMode: partial.syncMode,
|
|
1089
|
+
customPathCount: partial.customPaths?.length ?? 0,
|
|
1090
|
+
result: "failure",
|
|
1091
|
+
},
|
|
1092
|
+
});
|
|
1093
|
+
throw err;
|
|
1094
|
+
}
|
|
1028
1095
|
}
|
|
1029
1096
|
|
|
1030
1097
|
// -- Entity operations ----------------------------------------------------
|
|
@@ -1265,6 +1332,25 @@ export class VaultClient {
|
|
|
1265
1332
|
return this.post("/v1/skill-invocations", batch, usageIngestResultSchema);
|
|
1266
1333
|
}
|
|
1267
1334
|
|
|
1335
|
+
/**
|
|
1336
|
+
* `POST /v1/telemetry/events` — upload a capped batch of hq-cloud ACTION
|
|
1337
|
+
* events.
|
|
1338
|
+
*
|
|
1339
|
+
* Same trust model as `postUsage`: `personUid` MUST NOT appear in the body;
|
|
1340
|
+
* hq-pro resolves the caller from the JWT and validates any `companyUid`
|
|
1341
|
+
* against the caller's memberships. This endpoint is for server/CLI action
|
|
1342
|
+
* telemetry and is not gated by the personal `/v1/usage` opt-in flag.
|
|
1343
|
+
*/
|
|
1344
|
+
async postTelemetryEvents(
|
|
1345
|
+
batch: TelemetryEventsBatch,
|
|
1346
|
+
options: { timeoutMs?: number } = {},
|
|
1347
|
+
): Promise<TelemetryEventsIngestResult> {
|
|
1348
|
+
return this.post("/v1/telemetry/events", batch, usageIngestResultSchema, {
|
|
1349
|
+
timeoutMs: options.timeoutMs ?? 1500,
|
|
1350
|
+
maxRetries: 0,
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1268
1354
|
// -- HTTP primitives with retry -------------------------------------------
|
|
1269
1355
|
|
|
1270
1356
|
private async get<T>(path: string, schema: VaultResponseSchema<T>): Promise<T> {
|
|
@@ -1275,8 +1361,9 @@ export class VaultClient {
|
|
|
1275
1361
|
path: string,
|
|
1276
1362
|
body: unknown | undefined,
|
|
1277
1363
|
schema: VaultResponseSchema<T>,
|
|
1364
|
+
options?: VaultRequestOptions,
|
|
1278
1365
|
): Promise<T> {
|
|
1279
|
-
return this.request("POST", path, body, schema);
|
|
1366
|
+
return this.request("POST", path, body, schema, options);
|
|
1280
1367
|
}
|
|
1281
1368
|
|
|
1282
1369
|
private async request<T>(
|
|
@@ -1284,10 +1371,12 @@ export class VaultClient {
|
|
|
1284
1371
|
path: string,
|
|
1285
1372
|
body: unknown,
|
|
1286
1373
|
schema: VaultResponseSchema<T>,
|
|
1374
|
+
options: VaultRequestOptions = {},
|
|
1287
1375
|
): Promise<T> {
|
|
1288
1376
|
let lastError: Error | undefined;
|
|
1377
|
+
const maxRetries = options.maxRetries ?? MAX_RETRIES;
|
|
1289
1378
|
|
|
1290
|
-
for (let attempt = 0; attempt <=
|
|
1379
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
1291
1380
|
if (attempt > 0) {
|
|
1292
1381
|
const delay = BASE_DELAY_MS * Math.pow(2, attempt - 1);
|
|
1293
1382
|
await sleep(delay);
|
|
@@ -1300,6 +1389,16 @@ export class VaultClient {
|
|
|
1300
1389
|
};
|
|
1301
1390
|
|
|
1302
1391
|
const init: RequestInit = { method, headers };
|
|
1392
|
+
const controller =
|
|
1393
|
+
options.timeoutMs !== undefined && options.timeoutMs > 0
|
|
1394
|
+
? new AbortController()
|
|
1395
|
+
: undefined;
|
|
1396
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
1397
|
+
if (controller) {
|
|
1398
|
+
timeout = setTimeout(() => controller.abort(), options.timeoutMs);
|
|
1399
|
+
timeout.unref?.();
|
|
1400
|
+
init.signal = controller.signal;
|
|
1401
|
+
}
|
|
1303
1402
|
|
|
1304
1403
|
if (body !== undefined) {
|
|
1305
1404
|
headers["Content-Type"] = "application/json";
|
|
@@ -1311,8 +1410,10 @@ export class VaultClient {
|
|
|
1311
1410
|
res = await fetch(`${this.apiUrl}${path}`, init);
|
|
1312
1411
|
} catch (err) {
|
|
1313
1412
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
1314
|
-
if (attempt <
|
|
1413
|
+
if (attempt < maxRetries) continue;
|
|
1315
1414
|
throw lastError;
|
|
1415
|
+
} finally {
|
|
1416
|
+
if (timeout !== undefined) clearTimeout(timeout);
|
|
1316
1417
|
}
|
|
1317
1418
|
|
|
1318
1419
|
if (res.ok) {
|
package/src/watcher.test.ts
CHANGED
|
@@ -331,6 +331,46 @@ describe("US-002: createWatchPathFilter — personal-vault exclusions", () => {
|
|
|
331
331
|
expect(personal(path.join(ROOT, "workspace"), false)).toBe(false);
|
|
332
332
|
});
|
|
333
333
|
|
|
334
|
+
// ── workspace/agency subtree carve-out (personal mode) ──────────────────
|
|
335
|
+
// The personal push uploads workspace/agency/** (computeAgencySyncPaths), so
|
|
336
|
+
// the personal watcher must emit those changes event-driven, not leave them
|
|
337
|
+
// for the 60s poll. Mirrors the continuity-pointer carve-out, for a subtree.
|
|
338
|
+
it("DOES emit for workspace/agency files in personalMode (synced subtree)", () => {
|
|
339
|
+
expect(
|
|
340
|
+
personal(path.join(ROOT, "workspace/agency/indigo/nick/chat.jsonl")),
|
|
341
|
+
).toBe(true);
|
|
342
|
+
expect(
|
|
343
|
+
personal(path.join(ROOT, "workspace/agency/indigo/nick/status.json")),
|
|
344
|
+
).toBe(true);
|
|
345
|
+
// The agency root itself, as a file event, also emits.
|
|
346
|
+
expect(personal(path.join(ROOT, "workspace/agency"))).toBe(true);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("allows chokidar to DESCEND to workspace/agency in personalMode (dir probes)", () => {
|
|
350
|
+
// workspace/ and workspace/agency queried as dirs must return true so the
|
|
351
|
+
// chokidar backend descends instead of pruning workspace/ at the top.
|
|
352
|
+
expect(personal(path.join(ROOT, "workspace"), true)).toBe(true);
|
|
353
|
+
expect(personal(path.join(ROOT, "workspace/agency"), true)).toBe(true);
|
|
354
|
+
expect(
|
|
355
|
+
personal(path.join(ROOT, "workspace/agency/indigo"), true),
|
|
356
|
+
).toBe(true);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("agency carve-out is exact-segment (does NOT leak to workspace/agencyX)", () => {
|
|
360
|
+
// Only workspace/agency (and its subtree) is re-included; a sibling whose
|
|
361
|
+
// name merely starts with 'agency' stays excluded like other workspace litter.
|
|
362
|
+
expect(personal(path.join(ROOT, "workspace/agencyX/foo.txt"))).toBe(false);
|
|
363
|
+
expect(personal(path.join(ROOT, "workspace/agency-notes.md"))).toBe(false);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("agency carve-out does not re-include other workspace/ litter", () => {
|
|
367
|
+
// Regression: the new carve-out must not widen the workspace exclusion for
|
|
368
|
+
// anything other than agency + the continuity pointer.
|
|
369
|
+
expect(personal(path.join(ROOT, "workspace/locks/x.lock"))).toBe(false);
|
|
370
|
+
expect(personal(path.join(ROOT, "workspace/threads/T-old.json"))).toBe(false);
|
|
371
|
+
expect(personal(path.join(ROOT, "workspace/worktrees/x/a.ts"))).toBe(false);
|
|
372
|
+
});
|
|
373
|
+
|
|
334
374
|
it("does not apply the carve-out in non-personal mode", () => {
|
|
335
375
|
// In non-personal mode the excluded-top-level layer never runs, so the
|
|
336
376
|
// carve-out is irrelevant; handoff.json passes via the ordinary ignore
|
|
@@ -350,6 +390,48 @@ describe("US-002: createWatchPathFilter — personal-vault exclusions", () => {
|
|
|
350
390
|
// mode the ignore stack alone governs and lets it through.
|
|
351
391
|
expect(nonPersonal(path.join(ROOT, "companies/indigo/board.json"))).toBe(true);
|
|
352
392
|
});
|
|
393
|
+
|
|
394
|
+
// ── Never-synced heavy buckets pruned in BOTH modes ─────────────────────
|
|
395
|
+
// In --companies mode personalMode=false, so the excluded-top-level layer
|
|
396
|
+
// never ran and the watcher descended into the local git worktrees
|
|
397
|
+
// (workspace/worktrees/, ~200k transient files from agent coding runs) and
|
|
398
|
+
// the on-box repos checkout (repos/). Neither ever vault-syncs in any mode,
|
|
399
|
+
// so watching them only burns inotify watches (near the OS limit) + CPU on
|
|
400
|
+
// build churn it discards, and can trip the overflow→full-reconcile fallback.
|
|
401
|
+
it("non-personal mode EXCLUDES never-synced heavy buckets (workspace/worktrees, repos)", () => {
|
|
402
|
+
expect(
|
|
403
|
+
nonPersonal(path.join(ROOT, "workspace/worktrees/hq-console/src/a.ts")),
|
|
404
|
+
).toBe(false);
|
|
405
|
+
// The bucket dir itself is pruned so chokidar never descends into it.
|
|
406
|
+
expect(nonPersonal(path.join(ROOT, "workspace/worktrees"), true)).toBe(false);
|
|
407
|
+
expect(
|
|
408
|
+
nonPersonal(path.join(ROOT, "repos/private/hq-cloud/src/x.ts")),
|
|
409
|
+
).toBe(false);
|
|
410
|
+
expect(nonPersonal(path.join(ROOT, "repos"), true)).toBe(false);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("non-personal mode STILL watches synced content (companies/, workspace/agency, continuity)", () => {
|
|
414
|
+
expect(
|
|
415
|
+
nonPersonal(path.join(ROOT, "companies/indigo/board.json")),
|
|
416
|
+
).toBe(true);
|
|
417
|
+
expect(
|
|
418
|
+
nonPersonal(path.join(ROOT, "workspace/agency/indigo/nick/status.json")),
|
|
419
|
+
).toBe(true);
|
|
420
|
+
expect(
|
|
421
|
+
nonPersonal(path.join(ROOT, "workspace/threads/handoff.json")),
|
|
422
|
+
).toBe(true);
|
|
423
|
+
// chokidar must still descend into workspace/ to reach agency + continuity.
|
|
424
|
+
expect(nonPersonal(path.join(ROOT, "workspace"), true)).toBe(true);
|
|
425
|
+
// A worktrees SIBLING under workspace/ is unaffected (only worktrees pruned).
|
|
426
|
+
expect(
|
|
427
|
+
nonPersonal(path.join(ROOT, "workspace/agency"), true),
|
|
428
|
+
).toBe(true);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("personal mode still excludes worktrees + repos (regression, unchanged)", () => {
|
|
432
|
+
expect(personal(path.join(ROOT, "workspace/worktrees/x/a.ts"))).toBe(false);
|
|
433
|
+
expect(personal(path.join(ROOT, "repos/private/x/a.ts"))).toBe(false);
|
|
434
|
+
});
|
|
353
435
|
});
|
|
354
436
|
|
|
355
437
|
describe("US-002: TreeWatcher — debounce coalesce (FakeClock seam)", () => {
|