diff --git a/tanstack-migrator/server/db/catalog.ts b/tanstack-migrator/server/db/catalog.ts index 7fb57eb0..1f67e976 100644 --- a/tanstack-migrator/server/db/catalog.ts +++ b/tanstack-migrator/server/db/catalog.ts @@ -1,11 +1,13 @@ /** - * Read-only client for the decocms platform `sites` catalog (a DIFFERENT - * Supabase project than the MCP's own sitemig_* DB). + * Client for the decocms platform `sites` catalog (a DIFFERENT Supabase project + * than the MCP's own sitemig_* DB). * - * Powers the register modal's repo autocomplete: search by site name and - * pre-fill both the GitHub repo and the production URL — no GitHub API calls, - * so no rate limit. Opt-in: only active when DECOCMS_SUPABASE_URL + - * DECOCMS_SUPABASE_KEY are set. This module ONLY ever runs SELECTs. + * Mostly reads (register-modal repo autocomplete: search by site name to + * pre-fill the GitHub repo + production URL, no GitHub API calls / no rate + * limit). The ONE write is setSitePlatform — it flags the migrated -tanstack + * repo as a Cloudflare Workers Builds site so the Fresh/Deno k8s deployer stops + * watching it. Opt-in: active only when DECOCMS_SUPABASE_URL + + * DECOCMS_SUPABASE_KEY (a service-role key) are set. */ import { createClient, type SupabaseClient } from "@supabase/supabase-js"; @@ -150,3 +152,58 @@ export async function catalogByNames( } return map; } + +export const CFWORKERS_BUILDS_PLATFORM = "cfworkers-builds"; + +/** + * Flag a repo's catalog row with `metadata.platform` (default + * "cfworkers-builds") so the deco Fresh/Deno k8s deployer stops watching the + * migrated -tanstack repo (it keeps trying to deploy it and leaves a mess). + * + * Read-merge-write on the single row matched by github_repo_url — never clobbers + * other metadata keys. Fully guarded + best-effort: returns {ok:false, reason} + * instead of throwing (catalog not configured / row not indexed yet / RLS), so + * it can never block a migration. Idempotent. + */ +export async function setSitePlatform( + repoFull: string, + platform: string = CFWORKERS_BUILDS_PLATFORM, +): Promise<{ ok: boolean; reason: string }> { + const client = getCatalogClient(); + if (!client) return { ok: false, reason: "catalog not configured" }; + const repo = repoFull.trim().replace(/\.git$/, ""); + if (!repo.includes("/")) + return { ok: false, reason: `invalid repo ${repoFull}` }; + + try { + const { data, error } = await client + .from("sites") + .select("github_repo_url, metadata") + .ilike("github_repo_url", `%${repo}%`) + .limit(5); + if (error) return { ok: false, reason: error.message }; + const rows = (data ?? []) as Array<{ + github_repo_url: string | null; + metadata: Record | null; + }>; + // exact owner/repo match (ilike is fuzzy — avoid touching a similarly-named repo) + const row = rows.find((r) => toOwnerRepo(r.github_repo_url) === repo); + if (!row) return { ok: false, reason: "repo not in catalog yet" }; + + const meta = + row.metadata && typeof row.metadata === "object" ? row.metadata : {}; + if (meta.platform === platform) return { ok: true, reason: "already set" }; + + const { error: upErr } = await client + .from("sites") + .update({ metadata: { ...meta, platform } }) + .eq("github_repo_url", row.github_repo_url); + if (upErr) return { ok: false, reason: upErr.message }; + return { ok: true, reason: "updated" }; + } catch (err) { + return { + ok: false, + reason: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/tanstack-migrator/server/engine/phases/creating-repo.ts b/tanstack-migrator/server/engine/phases/creating-repo.ts index cad82b80..aca4f0a7 100644 --- a/tanstack-migrator/server/engine/phases/creating-repo.ts +++ b/tanstack-migrator/server/engine/phases/creating-repo.ts @@ -3,6 +3,7 @@ * durable GitHub grant used by the sandbox for pushes. */ +import { setSitePlatform } from "../../db/catalog.ts"; import { addEvent } from "../../db/events.ts"; import type { SiteRow } from "../../db/types.ts"; import { updateSite } from "../../db/sites.ts"; @@ -59,6 +60,14 @@ export async function creatingRepo( } await addEvent(site.id, `Repo ${targetRepo} ensured on GitHub`); + // Flag it as a CF Workers Builds site so the Fresh/Deno k8s deployer ignores + // it. Best-effort + early — the catalog row often isn't indexed yet at this + // point (the deploy phase re-asserts it once it is). + const plat = await setSitePlatform(targetRepo); + if (plat.ok && plat.reason === "updated") { + await addEvent(site.id, `Catalog: ${targetRepo} marked cfworkers-builds`); + } + // main must exist (repos are created empty) — it's the PR base — and the // work branch must exist BEFORE SANDBOX_START: sandbox clone + decopilot // threads are pinned to it. diff --git a/tanstack-migrator/server/engine/phases/deploying-cf.ts b/tanstack-migrator/server/engine/phases/deploying-cf.ts index 88b8e5bc..33c17a15 100644 --- a/tanstack-migrator/server/engine/phases/deploying-cf.ts +++ b/tanstack-migrator/server/engine/phases/deploying-cf.ts @@ -16,6 +16,7 @@ import { createRun, finishRun } from "../../db/runs.ts"; import { getSite, updateSite } from "../../db/sites.ts"; import type { SiteRow } from "../../db/types.ts"; import { manualCfInstructions } from "../../lib/cloudflare.ts"; +import { setSitePlatform } from "../../db/catalog.ts"; import { parseRepo } from "../../lib/github.ts"; import type { WorkerCtx } from "../../lib/mesh.ts"; import { previewRendersRealHtml } from "../../lib/preview.ts"; @@ -150,6 +151,27 @@ export async function deployingCf( ); } + // Now that the repo is pushed + indexed, flag it as CF Workers Builds so + // the Fresh/Deno k8s deployer stops watching it (best-effort, never blocks). + const plat = await setSitePlatform(current.target_repo!); + if (plat.ok && plat.reason === "updated") { + await addEvent( + site.id, + `Catalog: ${current.target_repo} marked cfworkers-builds — Fresh/Deno bot will ignore it`, + ); + } else if ( + !plat.ok && + !["catalog not configured", "repo not in catalog yet"].includes( + plat.reason, + ) + ) { + await addEvent( + site.id, + `Catalog platform flag not set (${plat.reason.slice(0, 120)})`, + "warn", + ); + } + await advanceAfterDeploy(current, { cf_project_name: workerName, cf_deploy_url: deployUrl, diff --git a/tanstack-migrator/server/tools/index.ts b/tanstack-migrator/server/tools/index.ts index ed386067..34d0c5bf 100644 --- a/tanstack-migrator/server/tools/index.ts +++ b/tanstack-migrator/server/tools/index.ts @@ -25,6 +25,7 @@ import { createSiteResumeTool, createSiteResetTool, createSiteRetryTool, + createSiteSetPlatformTool, createSiteTerminalTool, } from "./sites.ts"; import { createSyncDecofileInstallTool } from "./sync.ts"; @@ -41,6 +42,7 @@ export const tools = [ createSiteResumeTool, createSiteRetryTool, createSiteResetTool, + createSiteSetPlatformTool, createSiteEnqueueTool, createSiteReorderTool, createSiteMarkDoneTool, diff --git a/tanstack-migrator/server/tools/sites.ts b/tanstack-migrator/server/tools/sites.ts index 948c804f..1c7a2b24 100644 --- a/tanstack-migrator/server/tools/sites.ts +++ b/tanstack-migrator/server/tools/sites.ts @@ -4,8 +4,10 @@ import { createTool } from "@decocms/runtime/tools"; import { z } from "zod"; import { catalogByNames, + CFWORKERS_BUILDS_PLATFORM, isCatalogConfigured, searchSiteCatalog, + setSitePlatform, } from "../db/catalog.ts"; import { loadConnection, saveConnection } from "../db/connections.ts"; import { getCostSnapshot, refreshCostSnapshotIfStale } from "../db/cost.ts"; @@ -438,6 +440,58 @@ export const createSiteResetTool = (env: Env) => }, }); +export const createSiteSetPlatformTool = (env: Env) => + createTool({ + id: "SITE_SET_PLATFORM", + description: + "Flag the migrated -tanstack repo in the decocms catalog with metadata.platform (default 'cfworkers-builds') so the deco Fresh/Deno k8s deployer stops watching it (it keeps trying to deploy the -tanstack repo and leaves a broken deployment). Use it on existing -tanstack sites that the bot is fighting. New migrations set this automatically at repo creation + deploy. Pass a siteId (uses its -tanstack repo) or repo (owner/name) directly.", + inputSchema: z.object({ + siteId: z + .string() + .optional() + .describe("Migration site id (uses its -tanstack repo)."), + repo: z + .string() + .optional() + .describe('Target -tanstack repo "owner/name" (overrides the site).'), + platform: z + .string() + .optional() + .describe(`Platform value (default ${CFWORKERS_BUILDS_PLATFORM}).`), + }), + outputSchema: z.object({ + ok: z.boolean(), + repo: z.string(), + platform: z.string(), + reason: z.string(), + }), + execute: async ({ context }) => { + requireConnectionId(env); + if (!isCatalogConfigured()) { + throw new Error( + "Catalog not configured (DECOCMS_SUPABASE_URL/KEY missing) — cannot set the platform flag.", + ); + } + const site = context.siteId ? await getSite(context.siteId) : null; + if (context.siteId && !site) throw new Error("Site not found"); + const repo = (context.repo ?? site?.target_repo ?? "") + .trim() + .replace(/^https?:\/\/github\.com\//, "") + .replace(/\.git$/, ""); + if (!repo) { + throw new Error( + "No target repo — pass repo, or a siteId whose migration has a -tanstack repo.", + ); + } + const platform = context.platform ?? CFWORKERS_BUILDS_PLATFORM; + const result = await setSitePlatform(repo, platform); + if (site && result.ok && result.reason === "updated") { + await addEvent(site.id, `Catalog: ${repo} marked ${platform}`); + } + return { ok: result.ok, repo, platform, reason: result.reason }; + }, + }); + export const createSiteMarkDoneTool = (env: Env) => createTool({ id: "SITE_MARK_DONE",