167 lines
5.6 KiB
TypeScript
167 lines
5.6 KiB
TypeScript
import { serve } from "@hono/node-server";
|
|
import { Hono } from "hono";
|
|
import { cors } from "hono/cors";
|
|
import { HTTPException } from "hono/http-exception";
|
|
import { config } from "./config.js";
|
|
import { log } from "./log.js";
|
|
import { registry } from "./actors/registry.js";
|
|
import { actorRoutes } from "./routes/actors.js";
|
|
import { opencodeRoutes } from "./routes/opencode.js";
|
|
import { gitRoutes } from "./routes/git.js";
|
|
import { userRoutes } from "./routes/users.js";
|
|
import { agentRoutes } from "./routes/agents.js";
|
|
import { workflowRoutes, workflowRunRoutes } from "./routes/workflows.js";
|
|
import { chatRoutes } from "./routes/chat.js";
|
|
import { serviceRoutes } from "./routes/services.js";
|
|
import { conversationRoutes } from "./routes/conversations.js";
|
|
import { growRoutes } from "./routes/grow.js";
|
|
import { missionRoutes } from "./routes/missions.js";
|
|
import { eventRoutes } from "./routes/events.js";
|
|
import { homeRoutes } from "./routes/home.js";
|
|
import { startGrowEventsRedisConsumer } from "./events/redis-consumer.js";
|
|
import { db } from "./db/client.js";
|
|
import { hydratePortAllocator, reconcileOnBoot, ensureCentralGiteaReady } from "./docker/manager.js";
|
|
import { initCatalog } from "./agents/catalog.js";
|
|
|
|
async function main() {
|
|
// Boot-time DB sanity + reconcile + central Gitea readiness.
|
|
await db.execute("select 1");
|
|
await hydratePortAllocator();
|
|
|
|
// Ensure central Gitea is reachable before accepting traffic (changes.md §2A).
|
|
try {
|
|
await ensureCentralGiteaReady();
|
|
} catch (err) {
|
|
log.warn({ err }, "central Gitea not ready at boot — will retry on first provision");
|
|
}
|
|
|
|
// Load prompts & agent modules from disk (changes.md §3: prompts/ + agents/).
|
|
// After this, buildUnifiedSystemPrompt() returns the full assembled prompt.
|
|
await initCatalog();
|
|
|
|
await reconcileOnBoot();
|
|
startGrowEventsRedisConsumer().catch((err) => log.error({ err }, "failed to start grow events redis consumer"));
|
|
|
|
const app = new Hono();
|
|
|
|
app.use(
|
|
"*",
|
|
cors({
|
|
origin: config.frontendOrigin.split(",").map((s) => s.trim()),
|
|
credentials: true,
|
|
allowHeaders: ["authorization", "content-type", "x-growqr-user"],
|
|
}),
|
|
);
|
|
|
|
app.onError((err, c) => {
|
|
if (err instanceof HTTPException) {
|
|
return err.getResponse();
|
|
}
|
|
log.error({ err }, "unhandled error");
|
|
return c.json({ error: "internal" }, 500);
|
|
});
|
|
|
|
app.get("/", (c) =>
|
|
c.json({ name: "growqr-backend", status: "ok", env: config.nodeEnv }),
|
|
);
|
|
|
|
app.get("/healthz", async (c) => {
|
|
try {
|
|
await db.execute("select 1");
|
|
return c.json({ ok: true });
|
|
} catch (err) {
|
|
return c.json(
|
|
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
503,
|
|
);
|
|
}
|
|
});
|
|
|
|
// HTTP control plane (auth-gated).
|
|
app.route("/users", userRoutes());
|
|
app.route("/agents", agentRoutes());
|
|
app.route("/workflows", workflowRoutes());
|
|
app.route("/workflow-runs", workflowRunRoutes());
|
|
app.route("/actors", actorRoutes());
|
|
app.route("/grow", growRoutes());
|
|
app.route("/missions", missionRoutes());
|
|
app.route("/events", eventRoutes());
|
|
app.route("/home", homeRoutes());
|
|
app.route("/conversations", conversationRoutes());
|
|
app.route("/opencode", opencodeRoutes());
|
|
app.route("/git", gitRoutes());
|
|
app.route("/api/chat", chatRoutes());
|
|
app.route("/services", serviceRoutes());
|
|
|
|
if (process.env.RIVET_ENDPOINT) {
|
|
// Self-hosted: embedded engine runs at localhost:6420.
|
|
// Proxy frontend Rivet traffic to the engine instead of using registry.handler()
|
|
// (handler conflicts with startRunner — they're mutually exclusive).
|
|
const proxyRivet = async (c: any) => {
|
|
const url = new URL(c.req.url);
|
|
const target = new URL(config.rivetEndpoint);
|
|
url.protocol = target.protocol;
|
|
url.hostname = target.hostname;
|
|
url.port = target.port;
|
|
url.pathname = url.pathname.replace("/api/rivet", "");
|
|
|
|
// Forward headers, stripping hop-by-hop ones
|
|
const fwdHeaders = new Headers();
|
|
for (const [k, v] of c.req.raw.headers.entries()) {
|
|
if (k.toLowerCase() === "host") continue;
|
|
if (k.toLowerCase() === "transfer-encoding") continue;
|
|
fwdHeaders.set(k, v);
|
|
}
|
|
fwdHeaders.set("Host", target.host);
|
|
|
|
// For POST/PUT/PATCH, clone the body stream (Hono may have consumed it)
|
|
const method = c.req.method.toUpperCase();
|
|
const bodyMethods = ["POST", "PUT", "PATCH", "DELETE"];
|
|
|
|
try {
|
|
const rawBody = bodyMethods.includes(method)
|
|
? await c.req.raw.clone().arrayBuffer()
|
|
: undefined;
|
|
|
|
const res = await fetch(url.toString(), {
|
|
method,
|
|
headers: fwdHeaders,
|
|
body: rawBody && rawBody.byteLength > 0 ? new Uint8Array(rawBody) : undefined,
|
|
});
|
|
|
|
return new Response(res.body, {
|
|
status: res.status,
|
|
headers: res.headers,
|
|
});
|
|
} catch (err) {
|
|
log.error({ err, url: url.toString() }, "rivet proxy error");
|
|
return c.json({ error: "proxy_error" }, 502);
|
|
}
|
|
};
|
|
app.all("/api/rivet", proxyRivet);
|
|
app.all("/api/rivet/*", proxyRivet);
|
|
registry.startRunner();
|
|
} else {
|
|
// Serverless: use registry.handler() for incoming actor traffic.
|
|
app.all("/api/rivet/*", (c) => registry.handler(c.req.raw));
|
|
}
|
|
|
|
serve({ fetch: app.fetch, port: config.port }, (info) => {
|
|
log.info(
|
|
{
|
|
port: info.port,
|
|
rivet: config.rivetEndpoint,
|
|
giteaPublic: config.giteaPublicUrl,
|
|
giteaInternal: config.giteaInternalUrl,
|
|
env: config.nodeEnv,
|
|
},
|
|
"growqr-backend listening",
|
|
);
|
|
});
|
|
}
|
|
|
|
main().catch((err) => {
|
|
log.error({ err }, "fatal startup error");
|
|
process.exit(1);
|
|
});
|