mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 13:56:51 +00:00
222 lines
6.9 KiB
TypeScript
222 lines
6.9 KiB
TypeScript
// Helper para chamar a Railway GraphQL API (Public API).
|
|
// Docs: https://docs.railway.com/reference/public-api
|
|
const RAILWAY_GRAPHQL = "https://backboard.railway.app/graphql/v2";
|
|
|
|
export interface RailwayError {
|
|
message: string;
|
|
path?: string[];
|
|
extensions?: Record<string, unknown>;
|
|
}
|
|
|
|
export async function railwayQuery<T>(
|
|
query: string,
|
|
variables: Record<string, unknown>,
|
|
token: string,
|
|
): Promise<{ data?: T; errors?: RailwayError[] }> {
|
|
const res = await fetch(RAILWAY_GRAPHQL, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({ query, variables }),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
console.error(`Railway HTTP ${res.status} for query:`, query.trim().split("\n")[1]?.trim(), "vars:", JSON.stringify(variables), "body:", text);
|
|
throw new Error(`Railway HTTP ${res.status}: ${text}`);
|
|
}
|
|
|
|
const json = await res.json();
|
|
if (json.errors?.length) {
|
|
console.error("Railway GraphQL errors:", JSON.stringify(json.errors), "for vars:", JSON.stringify(variables));
|
|
}
|
|
return json;
|
|
}
|
|
|
|
export async function createRailwayService(opts: {
|
|
token: string;
|
|
projectId: string;
|
|
name: string;
|
|
}): Promise<string> {
|
|
const mutation = `
|
|
mutation ServiceCreate($input: ServiceCreateInput!) {
|
|
serviceCreate(input: $input) { id name }
|
|
}
|
|
`;
|
|
const res = await railwayQuery<{ serviceCreate: { id: string; name: string } }>(
|
|
mutation,
|
|
{ input: { projectId: opts.projectId, name: opts.name } },
|
|
opts.token,
|
|
);
|
|
if (res.errors?.length) {
|
|
throw new Error(`serviceCreate failed: ${JSON.stringify(res.errors)}`);
|
|
}
|
|
if (!res.data?.serviceCreate?.id) {
|
|
throw new Error("serviceCreate returned no id");
|
|
}
|
|
return res.data.serviceCreate.id;
|
|
}
|
|
|
|
export async function configureRailwayService(opts: {
|
|
token: string;
|
|
serviceId: string;
|
|
environmentId: string;
|
|
image: string;
|
|
variables: Record<string, string>;
|
|
startCommand?: string;
|
|
}): Promise<void> {
|
|
// O Railway expõe variáveis via variableUpsert (uma por vez) e fonte/imagem via serviceInstanceUpdate.
|
|
// Setamos a imagem (e startCommand opcional) primeiro.
|
|
const updateSource = `
|
|
mutation ServiceInstanceUpdate($serviceId: String!, $environmentId: String!, $input: ServiceInstanceUpdateInput!) {
|
|
serviceInstanceUpdate(serviceId: $serviceId, environmentId: $environmentId, input: $input)
|
|
}
|
|
`;
|
|
const sourceInput: Record<string, unknown> = { source: { image: opts.image } };
|
|
if (opts.startCommand) {
|
|
sourceInput.startCommand = opts.startCommand;
|
|
}
|
|
const sourceRes = await railwayQuery(
|
|
updateSource,
|
|
{
|
|
serviceId: opts.serviceId,
|
|
environmentId: opts.environmentId,
|
|
input: sourceInput,
|
|
},
|
|
opts.token,
|
|
);
|
|
if (sourceRes.errors?.length) {
|
|
throw new Error(`serviceInstanceUpdate (source) failed: ${JSON.stringify(sourceRes.errors)}`);
|
|
}
|
|
|
|
// Agora as variáveis. Railway recomenda variableUpsert por chave.
|
|
const variableUpsert = `
|
|
mutation VariableUpsert($input: VariableUpsertInput!) {
|
|
variableUpsert(input: $input)
|
|
}
|
|
`;
|
|
for (const [name, value] of Object.entries(opts.variables)) {
|
|
const r = await railwayQuery(
|
|
variableUpsert,
|
|
{
|
|
input: {
|
|
projectId: undefined, // será inferido pelo serviceId+environmentId
|
|
environmentId: opts.environmentId,
|
|
serviceId: opts.serviceId,
|
|
name,
|
|
value,
|
|
},
|
|
},
|
|
opts.token,
|
|
);
|
|
if (r.errors?.length) {
|
|
throw new Error(`variableUpsert(${name}) failed: ${JSON.stringify(r.errors)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function deployRailwayService(opts: {
|
|
token: string;
|
|
serviceId: string;
|
|
environmentId: string;
|
|
}): Promise<void> {
|
|
const mutation = `
|
|
mutation ServiceInstanceRedeploy($serviceId: String!, $environmentId: String!) {
|
|
serviceInstanceRedeploy(serviceId: $serviceId, environmentId: $environmentId)
|
|
}
|
|
`;
|
|
const res = await railwayQuery(
|
|
mutation,
|
|
{ serviceId: opts.serviceId, environmentId: opts.environmentId },
|
|
opts.token,
|
|
);
|
|
if (res.errors?.length) {
|
|
throw new Error(`serviceInstanceRedeploy failed: ${JSON.stringify(res.errors)}`);
|
|
}
|
|
}
|
|
|
|
export async function setRailwayReplicas(opts: {
|
|
token: string;
|
|
serviceId: string;
|
|
environmentId: string;
|
|
replicas: number;
|
|
}): Promise<void> {
|
|
const mutation = `
|
|
mutation ServiceInstanceUpdate($serviceId: String!, $environmentId: String!, $input: ServiceInstanceUpdateInput!) {
|
|
serviceInstanceUpdate(serviceId: $serviceId, environmentId: $environmentId, input: $input)
|
|
}
|
|
`;
|
|
// Tenta numReplicas primeiro (formato atual da Railway API v2)
|
|
const tryWithKey = async (key: "numReplicas" | "replicas") => {
|
|
return await railwayQuery(
|
|
mutation,
|
|
{
|
|
serviceId: opts.serviceId,
|
|
environmentId: opts.environmentId,
|
|
input: { [key]: opts.replicas },
|
|
},
|
|
opts.token,
|
|
);
|
|
};
|
|
|
|
let res = await tryWithKey("numReplicas");
|
|
if (res.errors?.length) {
|
|
const msg = JSON.stringify(res.errors);
|
|
// Fallback para `replicas` se o schema reclamar do campo
|
|
if (/numReplicas/i.test(msg) && /(unknown|not.*found|invalid)/i.test(msg)) {
|
|
console.warn("setRailwayReplicas: numReplicas rejected, retrying with `replicas`");
|
|
res = await tryWithKey("replicas");
|
|
}
|
|
}
|
|
if (res.errors?.length) {
|
|
throw new Error(`setRailwayReplicas failed: ${JSON.stringify(res.errors)}`);
|
|
}
|
|
|
|
// Após escalar, força redeploy para aplicar a mudança imediatamente
|
|
try {
|
|
await deployRailwayService({
|
|
token: opts.token,
|
|
serviceId: opts.serviceId,
|
|
environmentId: opts.environmentId,
|
|
});
|
|
} catch (e) {
|
|
console.warn("setRailwayReplicas: redeploy after scale failed (non-fatal):", e);
|
|
}
|
|
}
|
|
|
|
/** Busca o environmentId do primeiro deployment de um serviço. Útil quando vps_pool_id está null. */
|
|
export async function getServiceEnvironmentId(opts: {
|
|
token: string;
|
|
serviceId: string;
|
|
}): Promise<string | null> {
|
|
const query = `
|
|
query Service($id: String!) {
|
|
service(id: $id) {
|
|
projectId
|
|
deployments(first: 1) {
|
|
edges { node { environmentId } }
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
const res = await railwayQuery<{
|
|
service: { projectId: string; deployments: { edges: { node: { environmentId: string } }[] } };
|
|
}>(query, { id: opts.serviceId }, opts.token);
|
|
if (res.errors?.length) {
|
|
console.error("getServiceEnvironmentId errors:", JSON.stringify(res.errors));
|
|
return null;
|
|
}
|
|
return res.data?.service?.deployments?.edges?.[0]?.node?.environmentId ?? null;
|
|
}
|
|
|
|
/** Apaga o webhook do Telegram para que o Hermes assuma via polling. */
|
|
export async function deleteTelegramWebhook(botToken: string): Promise<void> {
|
|
const url = `https://api.telegram.org/bot${botToken}/deleteWebhook?drop_pending_updates=false`;
|
|
const res = await fetch(url, { method: "POST" });
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(`deleteWebhook failed: ${res.status} ${text}`);
|
|
}
|
|
}
|