mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
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:
Vendored
+2
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Vendored
+2
@@ -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",
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user