refactor: remove root install telemetry (#2673)

* refactor: remove root install telemetry

* chore: add root telemetry cleanup job

* fix: preserve legacy root telemetry deletion

* fix: handle token list after account deletion

* test: isolate local-auth e2e from production crons
This commit is contained in:
Patrick Erichsen
2026-06-15 19:42:51 -07:00
committed by GitHub
parent 0db6b17a62
commit e8a75de2b1
22 changed files with 742 additions and 660 deletions
+2
View File
@@ -131,6 +131,7 @@ import type * as registryArtifactBackups from "../registryArtifactBackups.js";
import type * as registryArtifactBackupsNode from "../registryArtifactBackupsNode.js";
import type * as registryArtifactRestore from "../registryArtifactRestore.js";
import type * as registryArtifactRestoreMutations from "../registryArtifactRestoreMutations.js";
import type * as rootInstallTelemetryCleanup from "../rootInstallTelemetryCleanup.js";
import type * as search from "../search.js";
import type * as securityDataset from "../securityDataset.js";
import type * as securityDatasetNode from "../securityDatasetNode.js";
@@ -278,6 +279,7 @@ declare const fullApi: ApiFromModules<{
registryArtifactBackupsNode: typeof registryArtifactBackupsNode;
registryArtifactRestore: typeof registryArtifactRestore;
registryArtifactRestoreMutations: typeof registryArtifactRestoreMutations;
rootInstallTelemetryCleanup: typeof rootInstallTelemetryCleanup;
search: typeof search;
securityDataset: typeof securityDataset;
securityDatasetNode: typeof securityDatasetNode;
+19 -1
View File
@@ -1,5 +1,5 @@
/* @vitest-environment node */
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
const interval = vi.fn();
@@ -48,6 +48,24 @@ vi.mock("./_generated/api", () => ({
}));
describe("crons", () => {
beforeEach(() => {
vi.resetModules();
mocks.interval.mockReset();
delete process.env.CLAWHUB_DISABLE_CRONS;
});
afterEach(() => {
delete process.env.CLAWHUB_DISABLE_CRONS;
});
it("does not register production cron work when explicitly disabled", async () => {
process.env.CLAWHUB_DISABLE_CRONS = "1";
await import("./crons");
expect(mocks.interval).not.toHaveBeenCalled();
});
it("drains registry artifact backup retries frequently enough for publish bursts", async () => {
await import("./crons");
+104 -102
View File
@@ -3,122 +3,124 @@ import { internal } from "./_generated/api";
const crons = cronJobs();
crons.interval(
"registry-artifact-backup-retries",
{ minutes: 5 },
internal.registryArtifactBackupsNode.processRegistryArtifactBackupRetriesInternal,
{},
);
if (process.env.CLAWHUB_DISABLE_CRONS !== "1") {
crons.interval(
"registry-artifact-backup-retries",
{ minutes: 5 },
internal.registryArtifactBackupsNode.processRegistryArtifactBackupRetriesInternal,
{},
);
crons.interval(
"github-skill-source-sync",
{ minutes: 15 },
internal.githubSkillSyncNode.syncGitHubSkillSourcesInternal,
{},
);
crons.interval(
"github-skill-source-sync",
{ minutes: 15 },
internal.githubSkillSyncNode.syncGitHubSkillSourcesInternal,
{},
);
crons.interval(
"trending-leaderboard",
{ minutes: 60 },
internal.leaderboards.rebuildTrendingLeaderboardAction,
{ limit: 200 },
);
crons.interval(
"trending-leaderboard",
{ minutes: 60 },
internal.leaderboards.rebuildTrendingLeaderboardAction,
{ limit: 200 },
);
crons.interval(
"skill-stats-backfill",
{ hours: 6 },
internal.statsMaintenance.runSkillStatBackfillInternal,
{ batchSize: 200, maxBatches: 5 },
);
crons.interval(
"skill-stats-backfill",
{ hours: 6 },
internal.statsMaintenance.runSkillStatBackfillInternal,
{ batchSize: 200, maxBatches: 5 },
);
// Runs frequently to keep dailyStats/trending accurate,
// but does NOT patch skill documents (only writes to skillDailyStats).
crons.interval(
"skill-stat-events",
{ minutes: 15 },
internal.skillStatEvents.processSkillStatEventsAction,
{},
);
// Runs frequently to keep dailyStats/trending accurate,
// but does NOT patch skill documents (only writes to skillDailyStats).
crons.interval(
"skill-stat-events",
{ minutes: 15 },
internal.skillStatEvents.processSkillStatEventsAction,
{},
);
crons.interval(
"package-stat-events",
{ minutes: 15 },
internal.packages.processPackageStatEventsInternal,
{ batchSize: 500 },
);
crons.interval(
"package-stat-events",
{ minutes: 15 },
internal.packages.processPackageStatEventsInternal,
{ batchSize: 500 },
);
// Syncs accumulated stat deltas to skill documents every 6 hours.
// Runs infrequently to avoid thundering-herd reactive query invalidation.
// Uses processedAt field to track progress (independent of the action cursor).
crons.interval(
"skill-doc-stat-sync",
{ hours: 6 },
internal.skillStatEvents.processSkillStatEventsInternal,
{ batchSize: 100 },
);
// Syncs accumulated stat deltas to skill documents every 6 hours.
// Runs infrequently to avoid thundering-herd reactive query invalidation.
// Uses processedAt field to track progress (independent of the action cursor).
crons.interval(
"skill-doc-stat-sync",
{ hours: 6 },
internal.skillStatEvents.processSkillStatEventsInternal,
{ batchSize: 100 },
);
crons.interval(
"global-stats-update",
{ hours: 24 },
internal.statsMaintenance.updateGlobalStatsAction,
{},
);
crons.interval(
"global-stats-update",
{ hours: 24 },
internal.statsMaintenance.updateGlobalStatsAction,
{},
);
crons.interval(
"publisher-abuse-score-refresh",
{ hours: 24 },
internal.publisherAbuse.runPublisherAbuseScoreRunInternal,
{ batchSize: 250, maxPages: 5, trigger: "cron" },
);
crons.interval(
"publisher-abuse-score-refresh",
{ hours: 24 },
internal.publisherAbuse.runPublisherAbuseScoreRunInternal,
{ batchSize: 250, maxPages: 5, trigger: "cron" },
);
crons.interval(
"publisher-temporal-abuse-scan",
{ hours: 24 },
internal.publisherAbuse.runTemporalPublisherAbuseScanInternal,
{
mode: "current",
dryRun: false,
candidateLimit: 1000,
batchSize: 50,
maxPages: 20,
trigger: "cron",
},
);
crons.interval(
"publisher-temporal-abuse-scan",
{ hours: 24 },
internal.publisherAbuse.runTemporalPublisherAbuseScanInternal,
{
mode: "current",
dryRun: false,
candidateLimit: 1000,
batchSize: 50,
maxPages: 20,
trigger: "cron",
},
);
crons.interval("vt-pending-scans", { minutes: 5 }, internal.vt.pollPendingScans, {
batchSize: 100,
});
crons.interval("vt-pending-scans", { minutes: 5 }, internal.vt.pollPendingScans, {
batchSize: 100,
});
crons.interval("vt-cache-backfill", { minutes: 30 }, internal.vt.backfillActiveSkillsVTCache, {
batchSize: 100,
});
crons.interval("vt-cache-backfill", { minutes: 30 }, internal.vt.backfillActiveSkillsVTCache, {
batchSize: 100,
});
crons.interval(
"package-scan-backfill",
{ minutes: 30 },
internal.packages.backfillPackageReleaseScansInternal,
{ batchSize: 100 },
);
crons.interval(
"package-scan-backfill",
{ minutes: 30 },
internal.packages.backfillPackageReleaseScansInternal,
{ batchSize: 100 },
);
crons.interval(
"skill-scan-request-prune",
{ hours: 6 },
internal.securityScan.pruneExpiredSkillScanRequestsInternal,
{ batchSize: 10 },
);
crons.interval(
"skill-scan-request-prune",
{ hours: 6 },
internal.securityScan.pruneExpiredSkillScanRequestsInternal,
{ batchSize: 10 },
);
crons.interval(
"download-dedupe-prune",
{ hours: 24 },
internal.downloads.pruneDownloadDedupesInternal,
{},
);
crons.interval(
"download-dedupe-prune",
{ hours: 24 },
internal.downloads.pruneDownloadDedupesInternal,
{},
);
crons.interval(
"download-metric-dedupe-prune",
{ hours: 24 },
internal.downloadMetrics.pruneDownloadMetricDedupesInternal,
{},
);
crons.interval(
"download-metric-dedupe-prune",
{ hours: 24 },
internal.downloadMetrics.pruneDownloadMetricDedupesInternal,
{},
);
}
export default crons;
-8
View File
@@ -290,8 +290,6 @@ describe("httpApi handlers", () => {
userId: "users:1",
slug: "weather",
version: "1.0.0",
rootId: "abc",
rootLabel: "~/skills",
});
});
@@ -322,8 +320,6 @@ describe("httpApi handlers", () => {
expect(await response.json()).toEqual({ ok: true });
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
userId: "users:1",
rootId: "abc",
rootLabel: "~/skills",
skills: [
{ slug: "weather", version: "1.0.0" },
{ slug: "calendar", version: undefined },
@@ -354,14 +350,10 @@ describe("httpApi handlers", () => {
expect(runMutation).toHaveBeenCalledTimes(2);
expect(runMutation.mock.calls[0]?.[1]).toMatchObject({
userId: "users:1",
rootId: "abc",
rootLabel: "~/skills",
skills: skills.slice(0, 100),
});
expect(runMutation.mock.calls[1]?.[1]).toMatchObject({
userId: "users:1",
rootId: "abc",
rootLabel: "~/skills",
skills: skills.slice(100),
});
});
-4
View File
@@ -253,8 +253,6 @@ async function cliTelemetryInstallHandler(ctx: ActionCtx, request: Request) {
for (let offset = 0; offset < root.skills.length; offset += LEGACY_TELEMETRY_BATCH_SIZE) {
await ctx.runMutation(internal.telemetry.reportCliLegacyInstallBatchInternal, {
userId,
rootId: root.rootId,
rootLabel: root.label,
skills: root.skills
.slice(offset, offset + LEGACY_TELEMETRY_BATCH_SIZE)
.map((skill) => ({
@@ -269,8 +267,6 @@ async function cliTelemetryInstallHandler(ctx: ActionCtx, request: Request) {
userId,
slug: args.slug,
version: args.version,
rootId: args.rootId,
rootLabel: args.rootLabel,
});
}
const ok = parseArk(ApiCliTelemetryInstallResponseSchema, { ok: true }, "Telemetry response");
+158
View File
@@ -0,0 +1,158 @@
/* @vitest-environment node */
import { describe, expect, it, vi } from "vitest";
vi.mock("./functions", () => ({
internalAction: (def: { handler: unknown }) => ({ _handler: def.handler }),
internalMutation: (def: { handler: unknown }) => ({ _handler: def.handler }),
internalQuery: (def: { handler: unknown }) => ({ _handler: def.handler }),
}));
vi.mock("./_generated/api", () => ({
internal: {
rootInstallTelemetryCleanup: {
cleanupRootInstallTelemetryBatchInternal: Symbol("cleanupRootInstallTelemetryBatchInternal"),
},
skillStatEvents: {
processSkillStatEventsAction: Symbol("processSkillStatEventsAction"),
processSkillStatEventsInternal: Symbol("processSkillStatEventsInternal"),
},
},
}));
const {
cleanupRootInstallTelemetryBatchHandler,
cleanupRootInstallTelemetryHandler,
ROOT_INSTALL_TELEMETRY_CLEANUP_CONFIRMATION,
} = await import("./rootInstallTelemetryCleanup");
describe("root install telemetry cleanup", () => {
it("defaults to dry run and reports legacy active-root rows without changing them", async () => {
const patch = vi.fn();
const insert = vi.fn();
const ctx = {
db: {
query: vi.fn(() => ({
paginate: async () => ({
page: [
{ _id: "installs:active", skillId: "skills:active", activeRoots: 2 },
{ _id: "installs:inactive", skillId: "skills:inactive", activeRoots: 0 },
{ _id: "installs:rootless", skillId: "skills:rootless" },
],
continueCursor: "next",
isDone: false,
}),
})),
patch,
insert,
delete: vi.fn(),
},
};
const result = await cleanupRootInstallTelemetryBatchHandler(ctx as never, {
phase: "activeRoots",
dryRun: true,
batchSize: 3,
});
expect(result).toMatchObject({
phase: "activeRoots",
scanned: 3,
matched: 2,
reactivated: 1,
dryRun: true,
isDone: false,
cursor: "next",
});
expect(patch).not.toHaveBeenCalled();
expect(insert).not.toHaveBeenCalled();
});
it("strips activeRoots and reactivates only legacy inactive installs", async () => {
const patch = vi.fn();
const insert = vi.fn();
const ctx = {
db: {
query: vi.fn(() => ({
paginate: async () => ({
page: [
{ _id: "installs:active", skillId: "skills:active", activeRoots: 2 },
{ _id: "installs:inactive", skillId: "skills:inactive", activeRoots: 0 },
],
continueCursor: null,
isDone: true,
}),
})),
patch,
insert,
delete: vi.fn(),
},
};
const result = await cleanupRootInstallTelemetryBatchHandler(ctx as never, {
phase: "activeRoots",
dryRun: false,
confirm: ROOT_INSTALL_TELEMETRY_CLEANUP_CONFIRMATION,
batchSize: 2,
});
expect(result).toMatchObject({
phase: "activeRoots",
nextPhase: "rootInstalls",
matched: 2,
reactivated: 1,
dryRun: false,
});
expect(patch).toHaveBeenCalledTimes(2);
expect(patch).toHaveBeenCalledWith("installs:active", { activeRoots: undefined });
expect(patch).toHaveBeenCalledWith("installs:inactive", { activeRoots: undefined });
expect(insert).toHaveBeenCalledOnce();
expect(insert).toHaveBeenCalledWith(
"skillStatEvents",
expect.objectContaining({ skillId: "skills:inactive", kind: "install_reactivate" }),
);
});
it("deletes legacy root rows during destructive table phases", async () => {
const deleteDoc = vi.fn();
const ctx = {
db: {
query: vi.fn(() => ({
paginate: async () => ({
page: [{ _id: "rootInstalls:one" }, { _id: "rootInstalls:two" }],
continueCursor: null,
isDone: true,
}),
})),
patch: vi.fn(),
insert: vi.fn(),
delete: deleteDoc,
},
};
const result = await cleanupRootInstallTelemetryBatchHandler(ctx as never, {
phase: "rootInstalls",
dryRun: false,
confirm: ROOT_INSTALL_TELEMETRY_CLEANUP_CONFIRMATION,
batchSize: 2,
});
expect(result).toMatchObject({
phase: "rootInstalls",
nextPhase: "roots",
scanned: 2,
matched: 2,
dryRun: false,
});
expect(deleteDoc).toHaveBeenCalledTimes(2);
expect(deleteDoc).toHaveBeenCalledWith("rootInstalls:one");
expect(deleteDoc).toHaveBeenCalledWith("rootInstalls:two");
});
it("requires an explicit confirmation token for destructive runs", async () => {
await expect(
cleanupRootInstallTelemetryHandler({ runMutation: vi.fn() } as never, {
dryRun: false,
}),
).rejects.toThrow(ROOT_INSTALL_TELEMETRY_CLEANUP_CONFIRMATION);
});
});
+200
View File
@@ -0,0 +1,200 @@
import { ConvexError, v } from "convex/values";
import { internal } from "./_generated/api";
import type { ActionCtx, MutationCtx } from "./_generated/server";
import { internalAction, internalMutation } from "./functions";
import { insertStatEvent } from "./skillStatEvents";
export const ROOT_INSTALL_TELEMETRY_CLEANUP_CONFIRMATION = "DELETE_ROOT_INSTALL_TELEMETRY";
const CLEANUP_PHASES = ["activeRoots", "rootInstalls", "roots"] as const;
type CleanupPhase = (typeof CLEANUP_PHASES)[number];
type CleanupBatchResult = {
phase: CleanupPhase;
nextPhase?: CleanupPhase;
scanned: number;
matched: number;
reactivated: number;
cursor: string | null;
phaseDone: boolean;
isDone: boolean;
dryRun: boolean;
};
function nextCleanupPhase(phase: CleanupPhase): CleanupPhase | undefined {
const index = CLEANUP_PHASES.indexOf(phase);
return CLEANUP_PHASES[index + 1];
}
function clampInt(value: number | undefined, fallback: number, max: number) {
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
return Math.max(1, Math.min(Math.trunc(value), max));
}
function requireCleanupConfirmation(dryRun: boolean, confirm: string | undefined) {
if (!dryRun && confirm !== ROOT_INSTALL_TELEMETRY_CLEANUP_CONFIRMATION) {
throw new ConvexError(
`Destructive cleanup requires confirm="${ROOT_INSTALL_TELEMETRY_CLEANUP_CONFIRMATION}"`,
);
}
}
export async function cleanupRootInstallTelemetryBatchHandler(
ctx: MutationCtx,
args: {
phase: CleanupPhase;
cursor?: string;
batchSize?: number;
dryRun: boolean;
confirm?: string;
},
): Promise<CleanupBatchResult> {
requireCleanupConfirmation(args.dryRun, args.confirm);
const batchSize = clampInt(args.batchSize, 50, 100);
const table =
args.phase === "activeRoots"
? "userSkillInstalls"
: args.phase === "rootInstalls"
? "userSkillRootInstalls"
: "userSyncRoots";
const page = await ctx.db
.query(table)
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
let matched = 0;
let reactivated = 0;
for (const entry of page.page) {
if (args.phase === "activeRoots") {
if (!("activeRoots" in entry) || typeof entry.activeRoots !== "number") continue;
matched++;
if (entry.activeRoots <= 0) {
reactivated++;
if (!args.dryRun) {
await insertStatEvent(ctx, {
skillId: entry.skillId,
kind: "install_reactivate",
});
}
}
if (!args.dryRun) {
await ctx.db.patch(entry._id, { activeRoots: undefined });
}
continue;
}
matched++;
if (!args.dryRun) {
await ctx.db.delete(entry._id);
}
}
const nextPhase = page.isDone ? nextCleanupPhase(args.phase) : undefined;
return {
phase: args.phase,
...(nextPhase ? { nextPhase } : {}),
scanned: page.page.length,
matched,
reactivated,
cursor: page.isDone ? null : page.continueCursor,
phaseDone: page.isDone,
isDone: page.isDone && !nextPhase,
dryRun: args.dryRun,
};
}
export const cleanupRootInstallTelemetryBatchInternal = internalMutation({
args: {
phase: v.union(v.literal("activeRoots"), v.literal("rootInstalls"), v.literal("roots")),
cursor: v.optional(v.string()),
batchSize: v.optional(v.number()),
dryRun: v.boolean(),
confirm: v.optional(v.string()),
},
handler: cleanupRootInstallTelemetryBatchHandler,
});
export async function cleanupRootInstallTelemetryHandler(
ctx: ActionCtx,
args: {
phase?: CleanupPhase;
cursor?: string;
batchSize?: number;
maxBatches?: number;
dryRun?: boolean;
confirm?: string;
},
) {
const dryRun = args.dryRun !== false;
requireCleanupConfirmation(dryRun, args.confirm);
const batchSize = clampInt(args.batchSize, 50, 100);
const maxBatches = clampInt(args.maxBatches, 20, 200);
let phase = args.phase ?? "activeRoots";
let cursor = args.cursor;
let batches = 0;
let scanned = 0;
let matched = 0;
let reactivated = 0;
while (batches < maxBatches) {
const result: CleanupBatchResult = await ctx.runMutation(
internal.rootInstallTelemetryCleanup.cleanupRootInstallTelemetryBatchInternal,
{
phase,
cursor,
batchSize,
dryRun,
confirm: args.confirm,
},
);
batches++;
scanned += result.scanned;
matched += result.matched;
reactivated += result.reactivated;
if (result.isDone) {
return {
dryRun,
isDone: true,
phase: result.phase,
cursor: null,
batches,
scanned,
matched,
reactivated,
};
}
if (result.phaseDone && result.nextPhase) {
phase = result.nextPhase;
cursor = undefined;
continue;
}
cursor = result.cursor ?? undefined;
}
return {
dryRun,
isDone: false,
phase,
cursor: cursor ?? null,
batches,
scanned,
matched,
reactivated,
};
}
// Temporary operator surface. Remove after production cleanup is verified.
export const cleanupRootInstallTelemetryInternal = internalAction({
args: {
phase: v.optional(
v.union(v.literal("activeRoots"), v.literal("rootInstalls"), v.literal("roots")),
),
cursor: v.optional(v.string()),
batchSize: v.optional(v.number()),
maxBatches: v.optional(v.number()),
dryRun: v.optional(v.boolean()),
confirm: v.optional(v.string()),
},
handler: cleanupRootInstallTelemetryHandler,
});
+4 -1
View File
@@ -2511,6 +2511,7 @@ const registryArtifactBackupJobs = defineTable({
.index("by_package_release", ["packageReleaseId"])
.index("by_updatedAt", ["updatedAt"]);
// Temporary cleanup target. Remove after root-install telemetry cleanup is verified in production.
const userSyncRoots = defineTable({
userId: v.id("users"),
rootId: v.string(),
@@ -2527,13 +2528,15 @@ const userSkillInstalls = defineTable({
skillId: v.id("skills"),
firstSeenAt: v.number(),
lastSeenAt: v.number(),
activeRoots: v.number(),
// Temporary compatibility field. New writes omit it; cleanup removes stored values.
activeRoots: v.optional(v.number()),
lastVersion: v.optional(v.string()),
})
.index("by_user", ["userId"])
.index("by_user_skill", ["userId", "skillId"])
.index("by_skill", ["skillId"]);
// Temporary cleanup target. Remove after root-install telemetry cleanup is verified in production.
const userSkillRootInstalls = defineTable({
userId: v.id("users"),
rootId: v.string(),
+153 -185
View File
@@ -18,20 +18,17 @@ vi.mock("./_generated/api", () => ({
},
}));
const { reportCliInstallInternal, reportCliLegacyInstallBatchInternal } =
await import("./telemetry");
const {
clearUserTelemetryInternal,
reportCliInstallInternal,
reportCliLegacyInstallBatchInternal,
} = await import("./telemetry");
const reportCliInstallHandler = (
reportCliInstallInternal as unknown as {
_handler: (
ctx: unknown,
args: {
userId: string;
slug: string;
version?: string;
rootId?: string;
rootLabel?: string;
},
args: { userId: string; slug: string; version?: string },
) => Promise<void>;
}
)._handler;
@@ -40,16 +37,17 @@ const reportCliLegacyInstallBatchHandler = (
reportCliLegacyInstallBatchInternal as unknown as {
_handler: (
ctx: unknown,
args: {
userId: string;
rootId: string;
rootLabel: string;
skills: Array<{ slug: string; version?: string }>;
},
args: { userId: string; skills: Array<{ slug: string; version?: string }> },
) => Promise<void>;
}
)._handler;
const clearUserTelemetryHandler = (
clearUserTelemetryInternal as unknown as {
_handler: (ctx: unknown, args: { userId: string }) => Promise<void>;
}
)._handler;
function makeIndexBuilder() {
const builder = {
eq: vi.fn(() => builder),
@@ -57,55 +55,57 @@ function makeIndexBuilder() {
return builder;
}
describe("telemetry install events", () => {
it("records legacy snapshot batches additively", async () => {
const skills = [
{ _id: "skills:weather", slug: "weather" },
{ _id: "skills:calendar", slug: "calendar" },
];
const insert = vi.fn();
const ctx = {
db: {
query: vi.fn((table: string) => ({
withIndex: vi.fn(
(indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
callback(makeIndexBuilder());
if (table === "skills" && indexName === "by_slug") {
return { unique: async () => skills.shift() ?? null };
}
if (table === "userSyncRoots" && indexName === "by_user_root") {
return { unique: async () => null };
}
if (table === "userSkillRootInstalls" && indexName === "by_user_root_skill") {
return { unique: async () => null };
}
if (table === "userSkillInstalls" && indexName === "by_user_skill") {
return { unique: async () => null };
}
throw new Error(`unexpected query ${table}.${indexName}`);
},
),
})),
insert,
patch: vi.fn(),
function makeInstallCtx(params: {
skills: Array<{ _id: string; slug: string } | null>;
installs: Array<Record<string, unknown> | null>;
}) {
const skills = [...params.skills];
const installs = [...params.installs];
const insert = vi.fn();
const patch = vi.fn();
const query = vi.fn((table: string) => ({
withIndex: vi.fn(
(indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
callback(makeIndexBuilder());
if (table === "skills" && indexName === "by_slug") {
return { unique: async () => skills.shift() ?? null };
}
if (table === "userSkillInstalls" && indexName === "by_user_skill") {
return { unique: async () => installs.shift() ?? null };
}
throw new Error(`unexpected query ${table}.${indexName}`);
},
};
),
}));
return { ctx: { db: { insert, patch, query } }, insert, patch, query };
}
describe("telemetry install events", () => {
it("records legacy snapshot batches as rootless user-skill installs", async () => {
const { ctx, insert, query } = makeInstallCtx({
skills: [
{ _id: "skills:weather", slug: "weather" },
{ _id: "skills:calendar", slug: "calendar" },
],
installs: [null, null],
});
await reportCliLegacyInstallBatchHandler(ctx, {
userId: "users:one",
rootId: "root",
rootLabel: "~/skills",
skills: [{ slug: "weather", version: "1.0.0" }, { slug: "calendar" }],
});
expect(insert).toHaveBeenCalledTimes(7);
expect(query).not.toHaveBeenCalledWith("userSyncRoots");
expect(query).not.toHaveBeenCalledWith("userSkillRootInstalls");
expect(insert).toHaveBeenCalledTimes(4);
expect(insert).toHaveBeenCalledWith(
"userSyncRoots",
expect.objectContaining({ userId: "users:one", rootId: "root", label: "~/skills" }),
);
expect(insert).toHaveBeenCalledWith(
"skillStatEvents",
expect.objectContaining({ skillId: "skills:weather", kind: "install_new" }),
"userSkillInstalls",
expect.objectContaining({
userId: "users:one",
skillId: "skills:weather",
lastVersion: "1.0.0",
}),
);
expect(insert).toHaveBeenCalledWith(
"skillStatEvents",
@@ -113,110 +113,45 @@ describe("telemetry install events", () => {
);
});
it("records the first CLI install as an install stat event", async () => {
const skill = { _id: "skills:demo", slug: "demo" };
const insert = vi.fn();
const ctx = {
db: {
query: vi.fn((table: string) => ({
withIndex: vi.fn(
(indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
callback(makeIndexBuilder());
if (table === "skills" && indexName === "by_slug") {
return { unique: async () => skill };
}
if (table === "userSyncRoots" && indexName === "by_user_root") {
return { unique: async () => null };
}
if (table === "userSkillRootInstalls" && indexName === "by_user_root_skill") {
return { unique: async () => null };
}
if (table === "userSkillInstalls" && indexName === "by_user_skill") {
return { unique: async () => null };
}
throw new Error(`unexpected query ${table}.${indexName}`);
},
),
})),
insert,
patch: vi.fn(),
},
};
it("records the first CLI install without root state", async () => {
const { ctx, insert } = makeInstallCtx({
skills: [{ _id: "skills:demo", slug: "demo" }],
installs: [null],
});
await reportCliInstallHandler(ctx, {
userId: "users:one",
slug: "demo",
version: "1.0.0",
rootId: "root",
rootLabel: "~/skills",
});
expect(insert).toHaveBeenCalledWith(
"userSkillInstalls",
expect.objectContaining({
userId: "users:one",
skillId: "skills:demo",
activeRoots: 1,
lastVersion: "1.0.0",
}),
);
expect(insert).toHaveBeenCalledWith(
"userSyncRoots",
expect.objectContaining({
userId: "users:one",
rootId: "root",
label: "~/skills",
}),
);
expect(insert).toHaveBeenCalledWith(
"userSkillRootInstalls",
expect.objectContaining({
userId: "users:one",
rootId: "root",
skillId: "skills:demo",
lastVersion: "1.0.0",
}),
);
expect(insert).toHaveBeenCalledWith("userSkillInstalls", {
userId: "users:one",
skillId: "skills:demo",
firstSeenAt: expect.any(Number),
lastSeenAt: expect.any(Number),
lastVersion: "1.0.0",
});
expect(insert).not.toHaveBeenCalledWith("userSyncRoots", expect.anything());
expect(insert).not.toHaveBeenCalledWith("userSkillRootInstalls", expect.anything());
expect(insert).toHaveBeenCalledWith(
"skillStatEvents",
expect.objectContaining({
skillId: "skills:demo",
kind: "install_new",
}),
expect.objectContaining({ skillId: "skills:demo", kind: "install_new" }),
);
});
it("keeps repeated CLI install events idempotent per user and skill", async () => {
const skill = { _id: "skills:demo", slug: "demo" };
const existingInstall = {
_id: "userSkillInstalls:one",
userId: "users:one",
skillId: "skills:demo",
activeRoots: 1,
lastVersion: "1.0.0",
};
const insert = vi.fn();
const patch = vi.fn();
const ctx = {
db: {
query: vi.fn((table: string) => ({
withIndex: vi.fn(
(indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
callback(makeIndexBuilder());
if (table === "skills" && indexName === "by_slug") {
return { unique: async () => skill };
}
if (table === "userSkillInstalls" && indexName === "by_user_skill") {
return { unique: async () => existingInstall };
}
throw new Error(`unexpected query ${table}.${indexName}`);
},
),
})),
insert,
patch,
},
};
const { ctx, insert, patch } = makeInstallCtx({
skills: [{ _id: "skills:demo", slug: "demo" }],
installs: [
{
_id: "userSkillInstalls:one",
userId: "users:one",
skillId: "skills:demo",
lastVersion: "1.0.0",
},
],
});
await reportCliInstallHandler(ctx, {
userId: "users:one",
@@ -224,44 +159,27 @@ describe("telemetry install events", () => {
version: "1.0.1",
});
expect(patch).toHaveBeenCalledWith(
"userSkillInstalls:one",
expect.objectContaining({ activeRoots: 1, lastVersion: "1.0.1" }),
);
expect(patch).toHaveBeenCalledWith("userSkillInstalls:one", {
activeRoots: undefined,
lastSeenAt: expect.any(Number),
lastVersion: "1.0.1",
});
expect(insert).not.toHaveBeenCalledWith("skillStatEvents", expect.anything());
});
it("reactivates an inactive CLI install for current install counts", async () => {
const skill = { _id: "skills:demo", slug: "demo" };
const existingInstall = {
_id: "userSkillInstalls:one",
userId: "users:one",
skillId: "skills:demo",
activeRoots: 0,
lastVersion: "1.0.0",
};
const insert = vi.fn();
const patch = vi.fn();
const ctx = {
db: {
query: vi.fn((table: string) => ({
withIndex: vi.fn(
(indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
callback(makeIndexBuilder());
if (table === "skills" && indexName === "by_slug") {
return { unique: async () => skill };
}
if (table === "userSkillInstalls" && indexName === "by_user_skill") {
return { unique: async () => existingInstall };
}
throw new Error(`unexpected query ${table}.${indexName}`);
},
),
})),
insert,
patch,
},
};
it("reactivates a legacy inactive install while removing its root count", async () => {
const { ctx, insert, patch } = makeInstallCtx({
skills: [{ _id: "skills:demo", slug: "demo" }],
installs: [
{
_id: "userSkillInstalls:one",
userId: "users:one",
skillId: "skills:demo",
activeRoots: 0,
lastVersion: "1.0.0",
},
],
});
await reportCliInstallHandler(ctx, {
userId: "users:one",
@@ -271,14 +189,64 @@ describe("telemetry install events", () => {
expect(patch).toHaveBeenCalledWith(
"userSkillInstalls:one",
expect.objectContaining({ activeRoots: 1, lastVersion: "1.0.1" }),
expect.objectContaining({ activeRoots: undefined, lastVersion: "1.0.1" }),
);
expect(insert).toHaveBeenCalledWith(
"skillStatEvents",
expect.objectContaining({ skillId: "skills:demo", kind: "install_reactivate" }),
);
});
it("clears rootless installs while preserving legacy inactive count semantics", async () => {
const insert = vi.fn();
const deleteDoc = vi.fn();
const installs = [
{ _id: "installs:rootless", skillId: "skills:rootless" },
{ _id: "installs:inactive", skillId: "skills:inactive", activeRoots: 0 },
];
const roots = [{ _id: "roots:one" }];
const rootInstalls = [{ _id: "rootInstalls:one" }];
const ctx = {
db: {
query: vi.fn((table: string) => ({
withIndex: vi.fn((_name, callback) => {
callback(makeIndexBuilder());
return {
take: async () =>
table === "userSkillInstalls"
? installs
: table === "userSyncRoots"
? roots
: rootInstalls,
};
}),
})),
get: vi.fn(async (id: string) => ({ _id: id })),
insert,
delete: deleteDoc,
},
};
await clearUserTelemetryHandler(ctx, { userId: "users:one" });
expect(insert).toHaveBeenCalledWith(
"skillStatEvents",
expect.objectContaining({
skillId: "skills:rootless",
kind: "install_clear",
delta: { allTime: -1, current: -1 },
}),
);
expect(insert).toHaveBeenCalledWith(
"skillStatEvents",
expect.objectContaining({
skillId: "skills:demo",
kind: "install_reactivate",
skillId: "skills:inactive",
kind: "install_clear",
delta: { allTime: -1, current: 0 },
}),
);
expect(deleteDoc).toHaveBeenCalledTimes(4);
expect(deleteDoc).toHaveBeenCalledWith("roots:one");
expect(deleteDoc).toHaveBeenCalledWith("rootInstalls:one");
});
});
+45 -316
View File
@@ -1,8 +1,7 @@
import { getAuthUserId } from "@convex-dev/auth/server";
import { v } from "convex/values";
import type { Id } from "./_generated/dataModel";
import type { MutationCtx } from "./_generated/server";
import { internalMutation, mutation, query } from "./functions";
import { internalMutation, mutation } from "./functions";
import { requireUser } from "./lib/access";
import { insertStatEvent } from "./skillStatEvents";
@@ -11,67 +10,15 @@ export const reportCliInstallInternal = internalMutation({
userId: v.id("users"),
slug: v.string(),
version: v.optional(v.string()),
rootId: v.optional(v.string()),
rootLabel: v.optional(v.string()),
},
handler: async (ctx, args) => {
const slug = args.slug.trim().toLowerCase();
if (!slug) return;
const skill = await ctx.db
.query("skills")
.withIndex("by_slug", (q) => q.eq("slug", slug))
.unique();
if (!skill || skill.softDeletedAt) return;
const now = Date.now();
const rootId = args.rootId?.trim();
if (rootId) {
await upsertSingleRootInstall(ctx, {
userId: args.userId,
skillId: skill._id,
rootId,
label: args.rootLabel?.trim() || "Unknown",
now,
version: args.version,
});
return;
}
const existing = await ctx.db
.query("userSkillInstalls")
.withIndex("by_user_skill", (q) => q.eq("userId", args.userId).eq("skillId", skill._id))
.unique();
if (existing) {
const wasInactive = existing.activeRoots <= 0;
await ctx.db.patch(existing._id, {
lastSeenAt: now,
activeRoots: Math.max(1, existing.activeRoots),
lastVersion: args.version,
});
if (wasInactive) {
await insertStatEvent(ctx, { skillId: skill._id, kind: "install_reactivate" });
}
return;
}
await ctx.db.insert("userSkillInstalls", {
userId: args.userId,
skillId: skill._id,
firstSeenAt: now,
lastSeenAt: now,
activeRoots: 1,
lastVersion: args.version,
});
await insertStatEvent(ctx, { skillId: skill._id, kind: "install_new" });
await upsertUserSkillInstall(ctx, args);
},
});
export const reportCliLegacyInstallBatchInternal = internalMutation({
args: {
userId: v.id("users"),
rootId: v.string(),
rootLabel: v.string(),
skills: v.array(
v.object({
slug: v.string(),
@@ -80,35 +27,15 @@ export const reportCliLegacyInstallBatchInternal = internalMutation({
),
},
handler: async (ctx, args) => {
const rootId = args.rootId.trim();
if (!rootId) return;
const now = Date.now();
await upsertRoot(ctx, {
userId: args.userId,
rootId,
label: args.rootLabel.trim() || "Unknown",
now,
});
const seen = new Set<string>();
for (const entry of args.skills) {
const slug = entry.slug.trim().toLowerCase();
if (!slug || seen.has(slug)) continue;
seen.add(slug);
const skill = await ctx.db
.query("skills")
.withIndex("by_slug", (q) => q.eq("slug", slug))
.unique();
if (!skill || skill.softDeletedAt) continue;
await upsertRootSkillInstall(ctx, {
await upsertUserSkillInstall(ctx, {
userId: args.userId,
skillId: skill._id,
rootId,
now,
version: entry.version?.trim() || undefined,
slug,
version: entry.version,
});
}
},
@@ -129,98 +56,48 @@ export const clearUserTelemetryInternal = internalMutation({
},
});
export const getMyInstalled = query({
args: {
includeRemoved: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) return null;
async function upsertUserSkillInstall(
ctx: MutationCtx,
params: { userId: Id<"users">; slug: string; version?: string },
) {
const slug = params.slug.trim().toLowerCase();
if (!slug) return;
const roots = await ctx.db
.query("userSyncRoots")
.withIndex("by_user", (q) => q.eq("userId", userId))
.order("desc")
.take(200);
const skill = await ctx.db
.query("skills")
.withIndex("by_slug", (q) => q.eq("slug", slug))
.unique();
if (!skill || skill.softDeletedAt) return;
const includeRemoved = Boolean(args.includeRemoved);
const resultRoots: Array<{
rootId: string;
label: string;
firstSeenAt: number;
lastSeenAt: number;
expiredAt?: number;
skills: Array<{
skill: {
slug: string;
displayName: string;
summary?: string;
stats: unknown;
ownerUserId: Id<"users">;
};
firstSeenAt: number;
lastSeenAt: number;
lastVersion?: string;
removedAt?: number;
}>;
}> = [];
const now = Date.now();
const version = params.version?.trim() || undefined;
const existing = await ctx.db
.query("userSkillInstalls")
.withIndex("by_user_skill", (q) => q.eq("userId", params.userId).eq("skillId", skill._id))
.unique();
for (const root of roots) {
const installs = await ctx.db
.query("userSkillRootInstalls")
.withIndex("by_user_root", (q) => q.eq("userId", userId).eq("rootId", root.rootId))
.order("desc")
.take(2000);
const filtered = includeRemoved ? installs : installs.filter((entry) => !entry.removedAt);
const skills: Array<{
skill: {
slug: string;
displayName: string;
summary?: string;
stats: unknown;
ownerUserId: Id<"users">;
};
firstSeenAt: number;
lastSeenAt: number;
lastVersion?: string;
removedAt?: number;
}> = [];
for (const entry of filtered) {
const skill = await ctx.db.get(entry.skillId);
if (!skill) continue;
skills.push({
skill: {
slug: skill.slug,
displayName: skill.displayName,
summary: skill.summary,
stats: skill.stats,
ownerUserId: skill.ownerUserId,
},
firstSeenAt: entry.firstSeenAt,
lastSeenAt: entry.lastSeenAt,
lastVersion: entry.lastVersion,
removedAt: entry.removedAt,
});
}
resultRoots.push({
rootId: root.rootId,
label: root.label,
firstSeenAt: root.firstSeenAt,
lastSeenAt: root.lastSeenAt,
expiredAt: root.expiredAt,
skills,
});
if (existing) {
const wasLegacyInactive = typeof existing.activeRoots === "number" && existing.activeRoots <= 0;
await ctx.db.patch(existing._id, {
activeRoots: undefined,
lastSeenAt: now,
lastVersion: version ?? existing.lastVersion,
});
if (wasLegacyInactive) {
await insertStatEvent(ctx, { skillId: skill._id, kind: "install_reactivate" });
}
return;
}
return {
roots: resultRoots,
cutoffDays: 120,
};
},
});
await ctx.db.insert("userSkillInstalls", {
userId: params.userId,
skillId: skill._id,
firstSeenAt: now,
lastSeenAt: now,
lastVersion: version,
});
await insertStatEvent(ctx, { skillId: skill._id, kind: "install_new" });
}
async function clearTelemetryForUser(ctx: MutationCtx, params: { userId: Id<"users"> }) {
const installs = await ctx.db
@@ -234,17 +111,19 @@ async function clearTelemetryForUser(ctx: MutationCtx, params: { userId: Id<"use
await ctx.db.delete(entry._id);
continue;
}
const wasLegacyInactive = typeof entry.activeRoots === "number" && entry.activeRoots <= 0;
await insertStatEvent(ctx, {
skillId: skill._id,
kind: "install_clear",
delta: {
allTime: -1,
current: entry.activeRoots > 0 ? -1 : 0,
current: wasLegacyInactive ? 0 : -1,
},
});
await ctx.db.delete(entry._id);
}
// Keep per-user privacy deletion complete until the global cleanup removes these tables.
const roots = await ctx.db
.query("userSyncRoots")
.withIndex("by_user", (q) => q.eq("userId", params.userId))
@@ -261,153 +140,3 @@ async function clearTelemetryForUser(ctx: MutationCtx, params: { userId: Id<"use
await ctx.db.delete(entry._id);
}
}
async function upsertSingleRootInstall(
ctx: MutationCtx,
params: {
userId: Id<"users">;
skillId: Id<"skills">;
rootId: string;
label: string;
now: number;
version?: string;
},
) {
await upsertRoot(ctx, {
userId: params.userId,
rootId: params.rootId,
label: params.label,
now: params.now,
});
await upsertRootSkillInstall(ctx, params);
}
async function upsertRootSkillInstall(
ctx: MutationCtx,
params: {
userId: Id<"users">;
skillId: Id<"skills">;
rootId: string;
now: number;
version?: string;
},
) {
const existing = await ctx.db
.query("userSkillRootInstalls")
.withIndex("by_user_root_skill", (q) =>
q.eq("userId", params.userId).eq("rootId", params.rootId).eq("skillId", params.skillId),
)
.unique();
if (existing) {
const wasRemoved = Boolean(existing.removedAt);
await ctx.db.patch(existing._id, {
lastSeenAt: params.now,
lastVersion: params.version ?? existing.lastVersion,
removedAt: undefined,
});
if (wasRemoved) {
await incrementActiveRoots(ctx, {
userId: params.userId,
skillId: params.skillId,
now: params.now,
version: params.version,
});
}
return;
}
await ctx.db.insert("userSkillRootInstalls", {
userId: params.userId,
rootId: params.rootId,
skillId: params.skillId,
firstSeenAt: params.now,
lastSeenAt: params.now,
lastVersion: params.version,
});
await incrementActiveRoots(ctx, {
userId: params.userId,
skillId: params.skillId,
now: params.now,
version: params.version,
});
}
async function upsertRoot(
ctx: MutationCtx,
params: { userId: Id<"users">; rootId: string; now: number; label: string },
) {
const existing = await ctx.db
.query("userSyncRoots")
.withIndex("by_user_root", (q) => q.eq("userId", params.userId).eq("rootId", params.rootId))
.unique();
if (existing) {
await ctx.db.patch(existing._id, {
label: params.label,
lastSeenAt: params.now,
expiredAt: undefined,
});
return;
}
await ctx.db.insert("userSyncRoots", {
userId: params.userId,
rootId: params.rootId,
label: params.label,
firstSeenAt: params.now,
lastSeenAt: params.now,
expiredAt: undefined,
});
}
async function incrementActiveRoots(
ctx: MutationCtx,
params: { userId: Id<"users">; skillId: Id<"skills">; now: number; version?: string },
) {
const existing = await ctx.db
.query("userSkillInstalls")
.withIndex("by_user_skill", (q) => q.eq("userId", params.userId).eq("skillId", params.skillId))
.unique();
if (!existing) {
await ctx.db.insert("userSkillInstalls", {
userId: params.userId,
skillId: params.skillId,
firstSeenAt: params.now,
lastSeenAt: params.now,
activeRoots: 1,
lastVersion: params.version,
});
await bumpSkillInstallCounts(ctx, {
skillId: params.skillId,
deltaAllTime: 1,
deltaCurrent: 1,
});
return;
}
const nextActive = Math.max(0, (existing.activeRoots ?? 0) + 1);
await ctx.db.patch(existing._id, {
activeRoots: nextActive,
lastSeenAt: params.now,
lastVersion: params.version ?? existing.lastVersion,
});
if ((existing.activeRoots ?? 0) === 0 && nextActive > 0) {
await bumpSkillInstallCounts(ctx, {
skillId: params.skillId,
deltaAllTime: 0,
deltaCurrent: 1,
});
}
}
async function bumpSkillInstallCounts(
ctx: MutationCtx,
params: { skillId: Id<"skills">; deltaAllTime: number; deltaCurrent: number },
) {
if (params.deltaAllTime === 1 && params.deltaCurrent === 1) {
await insertStatEvent(ctx, { skillId: params.skillId, kind: "install_new" });
} else if (params.deltaAllTime === 0 && params.deltaCurrent === 1) {
await insertStatEvent(ctx, { skillId: params.skillId, kind: "install_reactivate" });
}
}
+42
View File
@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@convex-dev/auth/server", () => ({
getAuthUserId: vi.fn(),
authTables: {},
}));
const { getAuthUserId } = await import("@convex-dev/auth/server");
const { listMine } = await import("./tokens");
type WrappedHandler = {
_handler: (ctx: unknown, args: Record<string, never>) => Promise<unknown>;
};
const listMineHandler = (listMine as unknown as WrappedHandler)._handler;
beforeEach(() => {
vi.mocked(getAuthUserId).mockReset();
});
describe("tokens.listMine", () => {
it("returns an empty list while a deleted user's auth session is expiring", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:deleted" as never);
const query = vi.fn();
const result = await listMineHandler(
{
db: {
get: vi.fn().mockResolvedValue({
_id: "users:deleted",
deactivatedAt: Date.now(),
}),
query,
},
},
{},
);
expect(result).toEqual([]);
expect(query).not.toHaveBeenCalled();
});
});
+3 -2
View File
@@ -1,7 +1,7 @@
import { v } from "convex/values";
import type { Doc } from "./_generated/dataModel";
import { internalMutation, internalQuery, mutation, query } from "./functions";
import { requireUser } from "./lib/access";
import { getOptionalActiveAuthUserId, requireUser } from "./lib/access";
import { generateToken, hashToken } from "./lib/tokens";
const TOKEN_TOUCH_MIN_INTERVAL_MS = 15 * 60_000;
@@ -9,7 +9,8 @@ const TOKEN_TOUCH_MIN_INTERVAL_MS = 15 * 60_000;
export const listMine = query({
args: {},
handler: async (ctx) => {
const { userId } = await requireUser(ctx);
const userId = await getOptionalActiveAuthUserId(ctx);
if (!userId) return [];
const tokens = await ctx.db
.query("apiTokens")
.withIndex("by_user", (q) => q.eq("userId", userId))
+2 -10
View File
@@ -25,14 +25,12 @@ On each reported `clawhub install`, the CLI sends one best-effort install event.
The event includes:
- `rootId`: a **SHA-256 hash** of the canonical root path (server never sees the raw path).
- `rootLabel`: a short label derived from the last two path segments (home paths are shown with `~`).
- `slug`: the installed skill slug.
- `version`: the installed version, when known.
### What we do _not_ collect
- No raw absolute folder paths (only hashed `rootId` + a short display label).
- No folder paths or folder-derived identifiers.
- No file contents.
- No per-run logs, prompts, or other CLI output.
@@ -46,13 +44,7 @@ ClawHub maintains aggregate counters per skill:
## Transparency + user controls
ClawHub provides a private “Installed” tab on your own profile:
- Shows install telemetry associated with your account.
- Includes a **JSON export** view.
- Includes a **Delete telemetry** action to remove all stored telemetry for your account.
Everyone else only sees **aggregated install counters**.
Everyone only sees **aggregated install counters**.
Deleting your account also deletes your telemetry data.
@@ -1,12 +1,9 @@
import { createHash } from "node:crypto";
import { resolveHome } from "../../homedir.js";
import { apiRequest } from "../../http.js";
import { ApiCliTelemetryInstallResponseSchema, LegacyApiRoutes } from "../../schema/index.js";
export async function reportInstalledSkillsTelemetryIfEnabled(params: {
token: string | undefined;
registry: string;
root: string;
slug: string;
version?: string | null;
}) {
@@ -25,8 +22,6 @@ export async function reportInstalledSkillsTelemetryIfEnabled(params: {
event: "install",
slug,
version: params.version ?? undefined,
rootId: rootTelemetryId(params.root),
rootLabel: formatRootLabel(params.root),
},
},
ApiCliTelemetryInstallResponseSchema,
@@ -42,23 +37,3 @@ function isTelemetryDisabled() {
if (!raw) return false;
return ["1", "true", "yes", "on"].includes(raw.trim().toLowerCase());
}
function rootTelemetryId(value: string) {
return createHash("sha256").update(value).digest("hex");
}
function formatRootLabel(value: string) {
const home = resolveHome();
if (value === home) return "~";
const normalized = value.replaceAll("\\", "/");
const normalizedHome = home.replaceAll("\\", "/");
const isHome = normalized === normalizedHome || normalized.startsWith(`${normalizedHome}/`);
const stripped = isHome ? normalized.slice(normalizedHome.length).replace(/^\//, "") : normalized;
const parts = stripped.split("/").filter(Boolean);
const tail = parts.slice(-2).join("/");
if (!tail) return isHome ? "~" : "…";
return isHome ? `~/${tail}` : `…/${tail}`;
}
@@ -1080,8 +1080,6 @@ describe("cmdInstall", () => {
event: "install",
slug: "demo",
version: "1.0.0",
rootId: expect.any(String),
rootLabel: expect.any(String),
},
}),
expect.anything(),
@@ -259,7 +259,6 @@ export async function cmdInstall(
await reportInstalledSkillsTelemetryIfEnabled({
token,
registry,
root: opts.dir,
slug: skillMeta.skill?.slug ?? trimmed,
version: resolvedVersion,
});
+2
View File
@@ -156,9 +156,11 @@ export const CliTelemetryInstallRequestSchema = type({
event: '"install"',
slug: "string",
version: "string?",
// Deprecated compatibility fields accepted and ignored by the backend.
rootId: "string?",
rootLabel: "string?",
}).or({
// Legacy bulk snapshots remain accepted while older CLIs are in circulation.
roots: type({
rootId: "string",
label: "string",
@@ -498,8 +498,6 @@ describe("built CLI artifact", () => {
event: "install",
slug: "demo",
version: "1.0.0",
rootId: expect.any(String),
rootLabel: expect.stringContaining("skills"),
},
});
+2
View File
@@ -131,9 +131,11 @@ export const CliTelemetryInstallRequestSchema = type({
event: '"install"',
slug: "string",
version: "string?",
// Deprecated compatibility fields accepted and ignored by the backend.
rootId: "string?",
rootLabel: "string?",
}).or({
// Legacy bulk snapshots remain accepted while older CLIs are in circulation.
roots: type({
rootId: "string",
label: "string",
File diff suppressed because one or more lines are too long
+2
View File
@@ -157,9 +157,11 @@ export const CliTelemetryInstallRequestSchema = type({
event: '"install"',
slug: "string",
version: "string?",
// Deprecated compatibility fields accepted and ignored by the backend.
rootId: "string?",
rootLabel: "string?",
}).or({
// Legacy bulk snapshots remain accepted while older CLIs are in circulation.
roots: type({
rootId: "string",
label: "string",
+3
View File
@@ -349,6 +349,7 @@ async function main() {
...process.env,
AUTH_GITHUB_ID: process.env.AUTH_GITHUB_ID ?? "local-dev",
AUTH_GITHUB_SECRET: process.env.AUTH_GITHUB_SECRET ?? "local-dev",
CLAWHUB_DISABLE_CRONS: "1",
CLAWHUB_EMAIL_CAPTURE_FILE: process.env.CLAWHUB_EMAIL_CAPTURE_FILE ?? emailCaptureFile,
CONVEX_AGENT_MODE: process.env.CONVEX_AGENT_MODE ?? "anonymous",
CONVEX_SITE_URL: convexSiteUrl,
@@ -373,6 +374,7 @@ async function main() {
[
`AUTH_GITHUB_ID=${e2eEnv.AUTH_GITHUB_ID}`,
`AUTH_GITHUB_SECRET=${e2eEnv.AUTH_GITHUB_SECRET}`,
"CLAWHUB_DISABLE_CRONS=1",
`CLAWHUB_EMAIL_CAPTURE_FILE=${e2eEnv.CLAWHUB_EMAIL_CAPTURE_FILE}`,
...(deployment ? [`CONVEX_DEPLOYMENT=${deployment}`] : []),
`CONVEX_SITE_URL=${convexSiteUrl}`,
@@ -420,6 +422,7 @@ async function main() {
await setLocalConvexEnv(convexUrl, [
{ name: "AUTH_GITHUB_ID", value: e2eEnv.AUTH_GITHUB_ID ?? "local-dev" },
{ name: "AUTH_GITHUB_SECRET", value: e2eEnv.AUTH_GITHUB_SECRET ?? "local-dev" },
{ name: "CLAWHUB_DISABLE_CRONS", value: "1" },
{ name: "CLAWHUB_EMAIL_CAPTURE_FILE", value: e2eEnv.CLAWHUB_EMAIL_CAPTURE_FILE ?? "" },
{ name: "DEV_AUTH_CONVEX_DEPLOYMENT", value: localAuthDeployment },
{ name: "DEV_AUTH_ENABLED", value: "1" },