{"version":3,"file":"fly-control-service.mjs","names":[],"sources":["../../../src/services/fly-control-service.ts"],"sourcesContent":["/**\n * Fly Control Service — Manages the Fly.io deployment from within the bot.\n *\n * Uses the Fly Machines REST API (https://api.machines.dev) to:\n * - List/set/delete app-level secrets\n * - Get machine status\n * - Restart the machine\n *\n * Auth: FLY_API_TOKEN env var (set as a Fly secret itself).\n * App name: FLY_APP_NAME env var (or auto-detected from FLY_APP_NAME on Fly).\n *\n * SECURITY: All callers must be authenticated (requireAuth on commands).\n * Secret values are write-only — Fly's API never returns plaintext values.\n */\n\nimport { guardedFetch } from './endpoint-allowlist.js';\nimport { getCredentialVault } from './credential-vault.js';\n\n// ─── Configuration ───────────────────────────────────────────────────────\n\nconst FLY_API_BASE = 'https://api.machines.dev/v1';\n\nfunction getFlyToken(): string | null {\n  return getCredentialVault().getSecret('deploy.fly.apiToken', 'fly-control');\n}\n\nfunction getFlyAppName(): string | null {\n  // FLY_APP_NAME is auto-set by Fly in the machine environment\n  return process.env.FLY_APP_NAME ?? null;\n}\n\nexport function isFlyControlAvailable(): boolean {\n  return !!(getFlyToken() && getFlyAppName());\n}\n\n// ─── Error Types ─────────────────────────────────────────────────────────\n\nexport class FlyControlError extends Error {\n  constructor(message: string, public statusCode?: number) {\n    super(message);\n    this.name = 'FlyControlError';\n  }\n}\n\nexport class FlyNotConfiguredError extends FlyControlError {\n  constructor() {\n    super(\n      'Fly control not configured. Set FLY_API_TOKEN as a Fly secret:\\n' +\n      '  fly secrets set FLY_API_TOKEN=\"$(fly tokens create deploy -a <your-app>)\" -a <your-app>'\n    );\n  }\n}\n\n// ─── HTTP Helpers ────────────────────────────────────────────────────────\n\nfunction requireConfig(): { token: string; appName: string } {\n  const token = getFlyToken();\n  const appName = getFlyAppName();\n  if (!token) throw new FlyNotConfiguredError();\n  if (!appName) throw new FlyControlError('FLY_APP_NAME not set. Are you running on Fly.io?');\n  return { token, appName };\n}\n\nasync function flyRequest(\n  method: string,\n  path: string,\n  body?: unknown,\n): Promise<any> {\n  const { token, appName } = requireConfig();\n  const url = `${FLY_API_BASE}/apps/${appName}${path}`;\n\n  const headers: Record<string, string> = {\n    'Authorization': `Bearer ${token}`,\n    'Content-Type': 'application/json',\n  };\n\n  // H10: Add request timeout to prevent hanging\n  const res = await guardedFetch(url, {\n    method,\n    headers,\n    ...(body !== undefined ? { body: JSON.stringify(body) } : {}),\n    signal: AbortSignal.timeout(30_000),\n  });\n\n  if (res.ok) {\n    const text = await res.text();\n    if (!text) return null;\n    try {\n      return JSON.parse(text);\n    } catch {\n      return text;\n    }\n  }\n\n  const errBody = await res.text().catch(() => '');\n\n  if (res.status === 401) {\n    throw new FlyControlError(\n      'Fly API auth failed. Your FLY_API_TOKEN may be expired.\\n' +\n      'Generate a new one: fly tokens create deploy -a <your-app>'\n    );\n  }\n\n  if (res.status === 404) {\n    throw new FlyControlError(`Not found: ${path}`);\n  }\n\n  throw new FlyControlError(\n    `Fly API error ${res.status}: ${errBody || 'Unknown error'}`,\n    res.status,\n  );\n}\n\n// ─── Secrets ─────────────────────────────────────────────────────────────\n\nexport interface FlySecret {\n  name: string;\n  digest: string;\n  createdAt: string;\n  version?: number;\n}\n\n/** List all secrets (names + digests only, never plaintext values). */\nexport async function listSecrets(): Promise<FlySecret[]> {\n  const data = await flyRequest('GET', '/secrets');\n  // API returns array of secret objects\n  return (Array.isArray(data) ? data : data?.secrets ?? []).map((s: any) => ({\n    name: s.name ?? s.Name,\n    digest: s.digest ?? s.Digest ?? '',\n    createdAt: s.created_at ?? s.CreatedAt ?? '',\n    version: s.version ?? s.Version,\n  }));\n}\n\n/**\n * Set one or more secrets. Values are encrypted by Fly and never returned.\n * Returns the new secrets version number — pass this to redeployMachine()\n * so the machine picks up the staged secrets.\n */\nexport async function setSecrets(\n  secrets: Record<string, string>,\n): Promise<number | undefined> {\n  // Bulk update endpoint: POST /apps/{app}/secrets\n  // Body: { values: { KEY: \"value\", KEY_TO_DELETE: null } }\n  const result = await flyRequest('POST', '/secrets', { values: secrets });\n  // The API returns the new version in the response\n  return result?.version ?? result?.Version ?? undefined;\n}\n\n/** Delete a single secret. */\nexport async function deleteSecret(name: string): Promise<void> {\n  await flyRequest('DELETE', `/secrets/${name}`);\n}\n\n// ─── Machines ────────────────────────────────────────────────────────────\n\nexport interface FlyMachineStatus {\n  id: string;\n  name: string;\n  state: string;\n  region: string;\n  instanceId: string;\n  privateIp: string;\n  createdAt: string;\n  updatedAt: string;\n  imageRef: string;\n  cpuKind: string;\n  cpus: number;\n  memoryMb: number;\n}\n\n/** List machines for this app. */\nexport async function listMachines(): Promise<FlyMachineStatus[]> {\n  const data = await flyRequest('GET', '/machines');\n  const machines = Array.isArray(data) ? data : [];\n  return machines.map((m: any) => ({\n    id: m.id,\n    name: m.name ?? '',\n    state: m.state ?? 'unknown',\n    region: m.region ?? '',\n    instanceId: m.instance_id ?? '',\n    privateIp: m.private_ip ?? '',\n    createdAt: m.created_at ?? '',\n    updatedAt: m.updated_at ?? '',\n    imageRef: m.config?.image ?? m.image_ref?.repository ?? '',\n    cpuKind: m.config?.guest?.cpu_kind ?? '',\n    cpus: m.config?.guest?.cpus ?? 0,\n    memoryMb: m.config?.guest?.memory_mb ?? 0,\n  }));\n}\n\n/** Restart a specific machine (simple restart, does NOT pick up new secrets). */\nexport async function restartMachine(machineId: string): Promise<void> {\n  await flyRequest('POST', `/machines/${machineId}/restart?timeout=30s`);\n}\n\n/**\n * Update a machine in-place (GET config → POST it back) with min_secrets_version.\n *\n * The Fly Machines API stages secrets on set but does NOT deploy them until\n * a machine update includes `min_secrets_version` matching the staged version.\n * This is what `fly secrets deploy` does under the hood.\n *\n * If no secretsVersion is passed, we first query the secrets list to get the\n * latest version automatically.\n */\nexport async function redeployMachine(\n  machineId: string,\n  secretsVersion?: number,\n): Promise<void> {\n  // GET the full machine object\n  const machine = await flyRequest('GET', `/machines/${machineId}`);\n  const config = machine?.config;\n  if (!config) {\n    throw new FlyControlError(`Machine ${machineId} has no config`);\n  }\n\n  // If no version supplied, query the current max secrets version\n  let minSecretsVersion = secretsVersion;\n  if (minSecretsVersion === undefined) {\n    const secrets = await listSecrets();\n    // Use the highest version number across all secrets\n    for (const s of secrets) {\n      if (s.version !== undefined && (minSecretsVersion === undefined || s.version > minSecretsVersion)) {\n        minSecretsVersion = s.version;\n      }\n    }\n  }\n\n  // POST the config back WITH min_secrets_version so Fly deploys staged secrets\n  const body: Record<string, unknown> = { config };\n  if (minSecretsVersion !== undefined) {\n    body.min_secrets_version = minSecretsVersion;\n  }\n  await flyRequest('POST', `/machines/${machineId}`, body);\n}\n\n/**\n * Redeploy all running machines (picks up staged secrets).\n * Uses update with min_secrets_version (not restart) to ensure new secrets are deployed.\n *\n * @param secretsVersion - If provided, passed to redeployMachine. Otherwise auto-detected.\n */\nexport async function restartAllMachines(secretsVersion?: number): Promise<string[]> {\n  const machines = await listMachines();\n  const running = machines.filter(m => m.state === 'started');\n  const redeployed: string[] = [];\n\n  // Query secrets version once for all machines (if not provided)\n  let version = secretsVersion;\n  if (version === undefined) {\n    const secrets = await listSecrets();\n    for (const s of secrets) {\n      if (s.version !== undefined && (version === undefined || s.version > version)) {\n        version = s.version;\n      }\n    }\n  }\n\n  for (const m of running) {\n    try {\n      await redeployMachine(m.id, version);\n      redeployed.push(m.id);\n    } catch (err) {\n      // Fallback: try simple restart if update fails\n      try {\n        await restartMachine(m.id);\n        redeployed.push(m.id);\n      } catch (err2) {\n        console.error(`Failed to redeploy/restart machine ${m.id}: ${err2}`);\n      }\n    }\n  }\n\n  return redeployed;\n}\n\n// ─── Provider Switching ──────────────────────────────────────────────────\n// High-level helper: switch LLM provider by updating the secret and\n// restarting. The entrypoint.sh picks up OPENCLAWNCH_LLM_PROVIDER on boot.\n\nexport type LlmProvider = 'anthropic' | 'bankr' | 'openrouter' | 'openai';\n\nconst VALID_PROVIDERS: LlmProvider[] = ['anthropic', 'bankr', 'openrouter', 'openai'];\n\nexport function isValidProvider(p: string): p is LlmProvider {\n  return VALID_PROVIDERS.includes(p as LlmProvider);\n}\n\n/**\n * Set the LLM provider secret (without restarting).\n * Returns the new secrets version — pass to scheduleRestart() so the\n * machine update includes min_secrets_version and actually deploys it.\n */\nexport async function setProvider(provider: LlmProvider): Promise<number | undefined> {\n  if (!isValidProvider(provider)) {\n    throw new FlyControlError(`Invalid provider: ${provider}. Valid: ${VALID_PROVIDERS.join(', ')}`);\n  }\n\n  return await setSecrets({ OPENCLAWNCH_LLM_PROVIDER: provider });\n}\n\n/**\n * Schedule a restart after a delay (default 2s).\n * This gives the command response time to be delivered before the process dies.\n * Fire-and-forget — errors are logged but don't propagate.\n *\n * @param delayMs - Delay before restart (default 2000ms)\n * @param secretsVersion - If provided, ensures the machine update includes\n *   min_secrets_version so staged secrets are deployed (not just staged).\n */\nexport function scheduleRestart(delayMs = 2000, secretsVersion?: number): void {\n  setTimeout(async () => {\n    try {\n      await restartAllMachines(secretsVersion);\n    } catch (err) {\n      console.error('[fly-control] Scheduled restart failed:', err);\n    }\n  }, delayMs);\n}\n\n// ─── Convenience: Current Provider ───────────────────────────────────────\n\nexport function getCurrentProvider(): LlmProvider {\n  const p = process.env.OPENCLAWNCH_LLM_PROVIDER;\n  if (p && isValidProvider(p)) return p;\n  return 'anthropic';\n}\n"],"mappings":"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"}