diff --git a/package.json b/package.json index 58ffa103077..7697e1d8005 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "type": "module", "scripts": { + "preflight:workspace-links": "pnpm exec tsx scripts/ensure-workspace-package-links.ts", "dev": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts watch", "dev:watch": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts watch", "dev:once": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts dev", @@ -10,10 +11,10 @@ "dev:stop": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-service.ts stop", "dev:server": "pnpm --filter @paperclipai/server dev", "dev:ui": "pnpm --filter @paperclipai/ui dev", - "build": "pnpm -r build", - "typecheck": "pnpm -r typecheck", - "test": "vitest", - "test:run": "vitest run", + "build": "pnpm run preflight:workspace-links && pnpm -r build", + "typecheck": "pnpm run preflight:workspace-links && pnpm -r typecheck", + "test": "pnpm run preflight:workspace-links && vitest", + "test:run": "pnpm run preflight:workspace-links && vitest run", "db:generate": "pnpm --filter @paperclipai/db generate", "db:migrate": "pnpm --filter @paperclipai/db migrate", "secrets:migrate-inline-env": "tsx scripts/migrate-inline-env-secrets.ts", diff --git a/packages/shared/src/telemetry/client.ts b/packages/shared/src/telemetry/client.ts index a8d6aefbfe5..9c4181cd3a1 100644 --- a/packages/shared/src/telemetry/client.ts +++ b/packages/shared/src/telemetry/client.ts @@ -6,7 +6,10 @@ import type { TelemetryState, } from "./types.js"; -const DEFAULT_ENDPOINT = "https://telemetry.paperclip.ing/ingest"; +const DEFAULT_ENDPOINTS = [ + "https://telemetry.paperclip.ing/ingest", + "https://rusqrrg391.execute-api.us-east-1.amazonaws.com/ingest", +] as const; const BATCH_SIZE = 50; const SEND_TIMEOUT_MS = 5_000; @@ -44,29 +47,35 @@ export class TelemetryClient { const events = this.queue.splice(0); const state = this.getState(); - const endpoint = this.config.endpoint ?? DEFAULT_ENDPOINT; + const endpoints = this.resolveEndpoints(); const app = this.config.app ?? "paperclip"; const schemaVersion = this.config.schemaVersion ?? "1"; + const body = JSON.stringify({ + app, + schemaVersion, + installId: state.installId, + version: this.version, + events, + }); - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS); - try { - await fetch(endpoint, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - app, - schemaVersion, - installId: state.installId, - version: this.version, - events, - }), - signal: controller.signal, - }); - } catch { - // Fire-and-forget: silent failure, no retries - } finally { - clearTimeout(timer); + for (const endpoint of endpoints) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS); + try { + const response = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + signal: controller.signal, + }); + if (response.ok) { + return; + } + } catch { + // Try the next built-in endpoint before dropping the batch. + } finally { + clearTimeout(timer); + } } } @@ -102,4 +111,9 @@ export class TelemetryClient { } return this.state; } + + private resolveEndpoints(): readonly string[] { + const configured = this.config.endpoint?.trim(); + return configured ? [configured] : DEFAULT_ENDPOINTS; + } } diff --git a/scripts/ensure-workspace-package-links.ts b/scripts/ensure-workspace-package-links.ts new file mode 100644 index 00000000000..17a99909458 --- /dev/null +++ b/scripts/ensure-workspace-package-links.ts @@ -0,0 +1,126 @@ +#!/usr/bin/env -S node --import tsx +import fs from "node:fs/promises"; +import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs"; +import path from "node:path"; +import { repoRoot } from "./dev-service-profile.ts"; + +type WorkspaceLinkMismatch = { + workspaceDir: string; + packageName: string; + expectedPath: string; + actualPath: string | null; +}; + +function readJsonFile(filePath: string): Record { + return JSON.parse(readFileSync(filePath, "utf8")) as Record; +} + +function discoverWorkspacePackagePaths(rootDir: string): Map { + const packagePaths = new Map(); + const ignoredDirNames = new Set([".git", ".paperclip", "dist", "node_modules"]); + + function visit(dirPath: string) { + const packageJsonPath = path.join(dirPath, "package.json"); + if (existsSync(packageJsonPath)) { + const packageJson = readJsonFile(packageJsonPath); + if (typeof packageJson.name === "string" && packageJson.name.length > 0) { + packagePaths.set(packageJson.name, dirPath); + } + } + + for (const entry of readdirSync(dirPath, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (ignoredDirNames.has(entry.name)) continue; + visit(path.join(dirPath, entry.name)); + } + } + + visit(path.join(rootDir, "packages")); + visit(path.join(rootDir, "server")); + visit(path.join(rootDir, "ui")); + visit(path.join(rootDir, "cli")); + + return packagePaths; +} + +function isLinkedGitWorktreeCheckout(rootDir: string) { + const gitMetadataPath = path.join(rootDir, ".git"); + if (!existsSync(gitMetadataPath)) return false; + + const stat = lstatSync(gitMetadataPath); + if (!stat.isFile()) return false; + + return readFileSync(gitMetadataPath, "utf8").trimStart().startsWith("gitdir:"); +} + +if (!isLinkedGitWorktreeCheckout(repoRoot)) { + process.exit(0); +} + +const workspacePackagePaths = discoverWorkspacePackagePaths(repoRoot); +const workspaceDirs = Array.from( + new Set( + Array.from(workspacePackagePaths.values()) + .map((packagePath) => path.relative(repoRoot, packagePath)) + .filter((workspaceDir) => workspaceDir.length > 0), + ), +).sort(); + +function findWorkspaceLinkMismatches(workspaceDir: string): WorkspaceLinkMismatch[] { + const packageJson = readJsonFile(path.join(repoRoot, workspaceDir, "package.json")); + const dependencies = { + ...(packageJson.dependencies as Record | undefined), + ...(packageJson.devDependencies as Record | undefined), + }; + const mismatches: WorkspaceLinkMismatch[] = []; + + for (const [packageName, version] of Object.entries(dependencies)) { + if (typeof version !== "string" || !version.startsWith("workspace:")) continue; + + const expectedPath = workspacePackagePaths.get(packageName); + if (!expectedPath) continue; + + const linkPath = path.join(repoRoot, workspaceDir, "node_modules", ...packageName.split("/")); + const actualPath = existsSync(linkPath) ? path.resolve(realpathSync(linkPath)) : null; + if (actualPath === path.resolve(expectedPath)) continue; + + mismatches.push({ + workspaceDir, + packageName, + expectedPath: path.resolve(expectedPath), + actualPath, + }); + } + + return mismatches; +} + +async function ensureWorkspaceLinksCurrent(workspaceDir: string) { + const mismatches = findWorkspaceLinkMismatches(workspaceDir); + if (mismatches.length === 0) return; + + console.log(`[paperclip] detected stale workspace package links for ${workspaceDir}; relinking dependencies...`); + for (const mismatch of mismatches) { + console.log( + `[paperclip] ${mismatch.packageName}: ${mismatch.actualPath ?? "missing"} -> ${mismatch.expectedPath}`, + ); + } + + for (const mismatch of mismatches) { + const linkPath = path.join(repoRoot, mismatch.workspaceDir, "node_modules", ...mismatch.packageName.split("/")); + await fs.mkdir(path.dirname(linkPath), { recursive: true }); + await fs.rm(linkPath, { recursive: true, force: true }); + await fs.symlink(mismatch.expectedPath, linkPath); + } + + const remainingMismatches = findWorkspaceLinkMismatches(workspaceDir); + if (remainingMismatches.length === 0) return; + + throw new Error( + `Workspace relink did not repair all ${workspaceDir} package links: ${remainingMismatches.map((item) => item.packageName).join(", ")}`, + ); +} + +for (const workspaceDir of workspaceDirs) { + await ensureWorkspaceLinksCurrent(workspaceDir); +} diff --git a/server/src/__tests__/telemetry-client-flush.test.ts b/server/src/__tests__/telemetry-client-flush.test.ts index 2264638c2fc..85826e443c4 100644 --- a/server/src/__tests__/telemetry-client-flush.test.ts +++ b/server/src/__tests__/telemetry-client-flush.test.ts @@ -76,6 +76,21 @@ describe("TelemetryClient periodic flush", () => { expect(fetch).not.toHaveBeenCalled(); }); + it("falls back to the api gateway ingest url when the default hostname fails", async () => { + vi.mocked(fetch) + .mockRejectedValueOnce(new TypeError("getaddrinfo ENOTFOUND telemetry.paperclip.ing")) + .mockResolvedValueOnce({ ok: true }); + + const client = makeClient({ endpoint: undefined }); + client.track("install.started"); + + await client.flush(); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(vi.mocked(fetch).mock.calls[0]?.[0]).toBe("https://telemetry.paperclip.ing/ingest"); + expect(vi.mocked(fetch).mock.calls[1]?.[0]).toBe("https://rusqrrg391.execute-api.us-east-1.amazonaws.com/ingest"); + }); + it("startPeriodicFlush is idempotent", () => { const client = makeClient(); client.startPeriodicFlush(1000);