From 5f40db1ff3152f6a18d8b9e20b4596473cd494f5 Mon Sep 17 00:00:00 2001 From: Igor Beylin Date: Sun, 19 Jul 2026 01:03:48 +0000 Subject: [PATCH] feat(site-deployment): multi-tenant per-client deploy targets (MT) Threads a per-client `site_deployment` transport config end-to-end so each client's autonomous surpass-plan edits write to THAT client's repo/deploy hook instead of the single shared WEBSITE_BOT_REPO. Unblocks the multi-tenant serp:execute-surpass-plans fan-out (enable-flip stays a separate operational change; enabled:false untouched). - types: ClientSiteDeploymentConfig (all fields optional, JSONB reality) + optional site_deployment on ClientConfig (no migration). - site-deployment: siteConfigFromClient(Partial) with the same dry-run safety invariant as siteConfigFromEnv (absent/blank token or repo, or the NODE_ENV/SITE_DEPLOY_DRY_RUN kills, force dryRun). readFile now short-circuits in dry-run (no outbound GET), so an unconfigured tenant is a true no-op. Optional config? on all 7 action fns. - plan-executor: ActionDispatcher gains a 4th siteConfig param; executor resolves siteConfigFromClient(job.data.clientConfig) and threads it through every dispatch + the final triggerVercelDeploy. faq_content_update stays a no-op. - add-client: prompts for + stores config.site_deployment; process.stdout/ stderr.write for new output (AGENTS.md no-console.log). - tests: siteConfigFromClient full/missing/blank(G6)/NODE_ENV; explicit config overrides env; dry-run makes no GET/PUT; plan-executor per-client routing, dry-run fallback, faq no-write. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VqWfuXWTn5jo8f5fnkSozx --- scripts/add-client.ts | 24 +++++ src/services/plan-executor.ts | 39 +++++-- src/services/site-deployment.ts | 65 ++++++++++-- src/types/index.ts | 16 +++ tests/services/plan-executor.test.ts | 126 ++++++++++++++++++++--- tests/services/site-deployment.test.ts | 134 ++++++++++++++++++++++++- 6 files changed, 366 insertions(+), 38 deletions(-) diff --git a/scripts/add-client.ts b/scripts/add-client.ts index 57643b9..d8c0d22 100644 --- a/scripts/add-client.ts +++ b/scripts/add-client.ts @@ -53,6 +53,28 @@ async function main() { services.push(svc); } + // Per-client site-deployment target (multi-tenant). Leave any field blank to + // operate this client in dry-run — the executor logs mutations but writes + // nothing until both a GitHub token and target repo are present. + // Per AGENTS.md ("do not use console.log"), new output in this script writes + // directly to the TTY rather than adding to the existing console.log footprint. + process.stdout.write('\nSite deployment target (press Enter on any field to keep this client dry-run):\n'); + const sdGithubToken = await ask(' GitHub token (repo:write on the site repo): '); + const sdWebsiteBotRepo = await ask(' Website repo (e.g. Quantum-L9/Website-Bot): '); + const sdVercelDeployHook = await ask(' Vercel deploy hook URL: '); + const sdSourceBranch = (await ask(' Source branch [main]: ')) || 'main'; + + const siteDeployment = { + githubToken: sdGithubToken, + websiteBotRepo: sdWebsiteBotRepo, + vercelDeployHook: sdVercelDeployHook, + sourceBranch: sdSourceBranch, + }; + const siteDeployDryRun = !sdGithubToken || !sdWebsiteBotRepo; + if (siteDeployDryRun) { + process.stderr.write(' ⚠️ site_deployment incomplete — this client operates in DRY-RUN (no live writes).\n'); + } + // Insert client const [client] = await db.insert(schema.clients).values({ name, @@ -68,6 +90,7 @@ async function main() { industry, city, state, + site_deployment: siteDeployment, }, }).returning(); @@ -76,6 +99,7 @@ async function main() { console.log(` Domain: ${client.domain}`); console.log(` Keywords: ${keywords.length}`); console.log(` Services: ${services.length}`); + process.stdout.write(` Site deployment: ${siteDeployDryRun ? 'DRY-RUN (incomplete)' : sdWebsiteBotRepo}\n`); console.log(`\nThe Bot will begin monitoring on the next scheduled cycle.`); rl.close(); diff --git a/src/services/plan-executor.ts b/src/services/plan-executor.ts index 00859c9..34db72c 100644 --- a/src/services/plan-executor.ts +++ b/src/services/plan-executor.ts @@ -33,6 +33,8 @@ import { injectSchema, updateHeading, triggerVercelDeploy, + siteConfigFromClient, + type SiteDeploymentConfig, } from './site-deployment.js'; import type { SurpassAction } from '../types/index.js'; @@ -40,34 +42,42 @@ const logger = createModuleLogger('plan-executor'); // Maps action strings from surpass plan to deployment functions. // Covers the most common autonomous actions from execution-policy taxonomy. -type ActionDispatcher = (action: SurpassAction, clientDomain: string, clientUrl: string | null) => Promise; +// 4th arg `siteConfig` carries the per-client transport target (multi-tenant), +// so each client's edits write to THAT client's repo/hook. Dispatchers that +// perform no write ignore it (`_siteConfig`). +type ActionDispatcher = ( + action: SurpassAction, + clientDomain: string, + clientUrl: string | null, + siteConfig: SiteDeploymentConfig, +) => Promise; const ACTION_DISPATCH_MAP: Record = { - meta_title_update: async (action, clientDomain, clientUrl) => { + meta_title_update: async (action, clientDomain, clientUrl, siteConfig) => { if (!clientUrl) return; const filePath = urlToFilePath(clientUrl); const newTitle = extractValue(action.action, 'title'); - if (filePath && newTitle) await updateMetaTitle(filePath, newTitle, clientDomain); + if (filePath && newTitle) await updateMetaTitle(filePath, newTitle, clientDomain, siteConfig); }, - meta_description_update: async (action, clientDomain, clientUrl) => { + meta_description_update: async (action, clientDomain, clientUrl, siteConfig) => { if (!clientUrl) return; const filePath = urlToFilePath(clientUrl); const newDesc = extractValue(action.action, 'description'); - if (filePath && newDesc) await updateMetaDescription(filePath, newDesc, clientDomain); + if (filePath && newDesc) await updateMetaDescription(filePath, newDesc, clientDomain, siteConfig); }, - heading_optimization: async (action, clientDomain, clientUrl) => { + heading_optimization: async (action, clientDomain, clientUrl, siteConfig) => { if (!clientUrl) return; const filePath = urlToFilePath(clientUrl); const newHeading = extractValue(action.action, 'heading'); - if (filePath && newHeading) await updateHeading(filePath, newHeading, clientDomain); + if (filePath && newHeading) await updateHeading(filePath, newHeading, clientDomain, siteConfig); }, - faq_content_update: async (_action, clientDomain, _clientUrl) => { + faq_content_update: async (_action, clientDomain, _clientUrl, _siteConfig) => { // SurpassAction carries no FAQ payload (no metadata field), and deploying an // empty FAQPage is a no-op at best / overwrites real schema at worst. Skip // until FAQ content is threaded in (aeo-geo owns FAQ generation). logger.warn({ clientDomain }, 'faq_content_update skipped — no FAQ payload on SurpassAction'); }, - schema_markup_injection: async (_action, clientDomain, clientUrl) => { + schema_markup_injection: async (_action, clientDomain, clientUrl, siteConfig) => { if (!clientUrl) return; const filePath = urlToFilePath(clientUrl); if (!filePath) return; @@ -78,6 +88,7 @@ const ACTION_DISPATCH_MAP: Record = { 'LocalBusiness', { '@context': 'https://schema.org', '@type': 'LocalBusiness', name: clientDomain, url: clientUrl }, clientDomain, + siteConfig, ); }, }; @@ -118,6 +129,12 @@ export async function executeSurpassPlans(job: Job): Promise { const { clientId, clientDomain } = job.data; if (!clientId) return; + // Resolve this client's per-tenant deploy target. The scheduler fan-out + // attaches the full `clients.config` jsonb as `job.data.clientConfig`; never + // assume it's present. `siteConfigFromClient` forces dry-run when the target + // repo/token is absent or blank, so an unconfigured client never writes live. + const siteConfig = siteConfigFromClient(job.data.clientConfig); + const db = getDb(); const plannedGaps = await db.select() @@ -180,7 +197,7 @@ export async function executeSurpassPlans(job: Job): Promise { dispatchAttempts += 1; try { - await dispatcher(action, clientDomain, gap.clientUrl); + await dispatcher(action, clientDomain, gap.clientUrl, siteConfig); dispatchSuccesses += 1; anyDeployed = true; logger.info({ actionType, keyword: gap.keyword }, 'Action dispatched to site-deployment'); @@ -206,7 +223,7 @@ export async function executeSurpassPlans(job: Job): Promise { } if (anyDeployed) { - await triggerVercelDeploy(); + await triggerVercelDeploy(siteConfig); logger.info({ clientDomain }, 'Vercel deploy triggered after surpass plan execution'); } } diff --git a/src/services/site-deployment.ts b/src/services/site-deployment.ts index 981e9d2..f19ea41 100644 --- a/src/services/site-deployment.ts +++ b/src/services/site-deployment.ts @@ -30,6 +30,7 @@ import axios from 'axios'; import { createModuleLogger } from '../core/logger.js'; +import type { ClientConfig } from '../types/index.js'; const logger = createModuleLogger('site-deployment'); @@ -82,6 +83,16 @@ class GitHubContentClient { } async readFile(filePath: string): Promise<{ content: string; sha: string }> { + // Dry-run (test env, kill-switch, or an unconfigured/blank tenant) must make + // NO outbound call. Without this, a dry-run client would still hit the GitHub + // API with an empty token and 401 on every mutation — noisy and pointless for + // the many multi-tenant clients that run in dry-run until configured. Returning + // an empty sentinel lets the mutation run harmlessly and writeFile (already + // dry-run guarded) return the dry-run result. + if (this.config.dryRun) { + logger.info({ filePath }, '[DRY-RUN] Would read file'); + return { content: '', sha: '' }; + } const url = `${this.baseUrl}/repos/${this.config.websiteBotRepo}/contents/${filePath}?ref=${this.config.sourceBranch}`; const response = await axios.get(url, { headers: this.headers, timeout: REQUEST_TIMEOUT_MS }); const content = Buffer.from(response.data.content, 'base64').toString('utf-8'); @@ -148,6 +159,38 @@ export function siteConfigFromEnv(): SiteDeploymentConfig { }; } +/** + * Build a per-client transport config from a client's stored `clients.config` + * (multi-tenant). Each client's autonomous edits then write to THAT client's + * repo / deploy hook instead of the single shared `WEBSITE_BOT_REPO`. + * + * Safety invariant (mirrors `siteConfigFromEnv`): force `dryRun: true` whenever + * the github token OR the target repo is missing OR blank — an EMPTY STRING is + * treated the same as an absent key, so a half-populated `site_deployment` + * never performs a live write. Also honors the global test / dry-run env kills. + */ +export function siteConfigFromClient(clientConfig?: Partial): SiteDeploymentConfig { + const sd = clientConfig?.site_deployment; + const githubToken = sd?.githubToken ?? ''; + const websiteBotRepo = sd?.websiteBotRepo ?? ''; + if (!githubToken || !websiteBotRepo) { + logger.warn( + 'client site_deployment missing githubToken or websiteBotRepo — forced to dry-run', + ); + } + return { + githubToken, + vercelDeployHook: sd?.vercelDeployHook ?? '', + websiteBotRepo, + sourceBranch: sd?.sourceBranch || 'main', + dryRun: + process.env.NODE_ENV === 'test' || + process.env.SITE_DEPLOY_DRY_RUN === 'true' || + !githubToken || + !websiteBotRepo, + }; +} + /** * Get a GitHub content client. * @@ -174,8 +217,9 @@ export async function updateMetaTitle( filePath: string, newTitle: string, clientDomain: string, + config?: SiteDeploymentConfig, ): Promise { - const client = getSiteDeploymentService(); + const client = getSiteDeploymentService(config); const { content, sha } = await client.readFile(filePath); const safeTitle = yamlDoubleQuoted(newTitle); @@ -198,8 +242,9 @@ export async function updateMetaDescription( filePath: string, newDescription: string, clientDomain: string, + config?: SiteDeploymentConfig, ): Promise { - const client = getSiteDeploymentService(); + const client = getSiteDeploymentService(config); const { content, sha } = await client.readFile(filePath); const updated = content @@ -221,8 +266,9 @@ export async function injectSchema( schemaType: string, schemaJson: Record, clientDomain: string, + config?: SiteDeploymentConfig, ): Promise { - const client = getSiteDeploymentService(); + const client = getSiteDeploymentService(config); const { content, sha } = await client.readFile(filePath); const scriptBlock = `