cynarAI 2c6b5c3d7b
chore: remove legacy orchestrator, trading and one-off leftovers (#109)
* chore: remove legacy orchestrator, trading and one-off script leftovers

The repository still carried the pre-evOo prototype: a Python orchestrator,
the old HTML "evoo-platform" cockpit (whose test ran in the root unit
suite), an apex-trading package, the hot-reload deploy.sh for it, a
permissions manifest, an architecture note with local file links, agent
handoff notes, a Steve Jobs persona template and about ninety one-off
scripts that probe a developer database with hard-coded local URLs. None
of it is used by the product, CI or the real deploy in ops/evo-deploy.

Delete those files, drop the evoo-platform test glob from the root vitest
config, the Cockpit section from .env.example and the dead biome and
gitignore entries, and update the docs that listed the removed tools.
scripts/start-tmux.sh no longer starts the removed sentinel and resolves
the checkout from its own location instead of a machine-specific path;
scripts/seed-ollama.ts requires DATABASE_URL instead of falling back to a
local URL with a username.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* fix(web): drop trading and persona prompts from the conversation shell

The web shell still showed leftovers from the trading prototype: composer
chips with German trading and "Steve Jobs" prompts, trading suggestion
chips under the empty conversation line, a German trading hero for the
empty transcript, and a German "data protection" status panel in the
sidebar. They suggested actions the product does not have and added
persistent chrome.

Remove the composer chips and their helper, the empty-state chips and the
hero (the empty conversation keeps its existing one-line start hint), the
sidebar trading indicator and the data trust panel. The new-bot E2E now
asserts the plain empty state; the sidebar spec no longer expects the
trading status, and the placeholder Steve Jobs spec with no assertions is
gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* refactor: remove the trading bot status integration

With the sidebar trading indicator gone, the only remaining consumer of
the trading bot status was a banner in General settings that reused the
same component. evOo is a personal assistant; a status proxy and poller
for one external trading service, configured through service-specific
TRADING_BOT_URL and VITE_TRADING_BOT_URL variables, does not belong in
the core product.

Remove the settings banner and its prop plumbing, the web poller, the
indicator component, the /api/trading-bot/status proxy, the shared core
contract, the env loading and both variables from .env.example, together
with their tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* chore: remove one-off database scripts and a stale review note

packages/db carried four hand-run scripts outside the package build that
query or seed a developer database with a hard-coded connection string,
one of them for a specific account. docs/VOICE-FIRST-REVIEW.md was a
one-time verification note for a workflow that no longer exists and
named private machines and local paths. Nothing references them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* chore: replace machine-specific paths and addresses with placeholders

ORIGIN.md pinned the intended checkout to an absolute path under a
personal home directory, docs/LOCAL-SETUP.md listed folders on one
developer's machine, and two tests used what looks like a real tailnet
address. Use a home-relative path, drop the machine-specific folder list,
and use 100.100.100.100 in the tests; it is still inside 100.64.0.0/10,
so both tests check the same thing as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* test(web): drop the trading status from the shell contrast test

main's contrast test now waits for the sidebar trading status before
measuring; this branch removes that status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-04 01:13:52 +02:00

1008 lines
35 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { rm } from "node:fs/promises";
import { ORPCError, onError } from "@orpc/server";
import { RPCHandler } from "@orpc/server/fetch";
import type {
AgentRuntime,
JobPublisher,
ManagedConnectorProvider,
MessagingSurface,
RealtimeFanout,
SandboxProvider,
TransactionalEmailProvider,
} from "@rakazo/adapter-kit";
import type {
ComposioProvider,
ConnectorRegistry,
DestinationEmulator,
RemoteConnectorDependencies,
} from "@rakazo/adapters";
import {
applyMessagingOutboundStatus,
ChatSdkMessagingSurface,
ComposioConnector,
createBackgroundJobHandlers,
createCloudAgentConnection,
createConnectorStack,
createJobReconciler,
createMessagingContextLoader,
createMessagingTeamChatSender,
createProfileConsolidationScheduler,
createRunExecutor,
createRunSandbox,
createRunSecretWriter,
createWebProvider,
destroyBot,
EmailEmulator,
EncryptedSecretStore,
ExpoPushProvider,
GraphileJobPublisher,
GraphileJobWorkerHost,
InMemoryJobQueue,
InMemoryRealtimeFanout,
InstalledConnectorProvider,
IntegrationProviderSettings,
isComposioEnabled,
isMessagingSurfaceEnabled,
isPipedreamEnabled,
LocalAgentHomeStore,
LocalArtifactStore,
McpConnector,
McpOAuthBroker,
messagingPlatformsFromEnv,
PiAgentRuntime,
PiOAuthLogins,
PipedreamConnector,
PostgresRealtimeFanout,
pipedreamConfigFromEnv,
piSessionsRoot,
pushTokenPath,
reconcileCloudAgents,
reconcileComputerUpdates,
reconcileDelegatedTasks,
removePiUserSessions,
ScriptedAgentRuntime,
SmtpEmailProvider,
SpaceMemoryProviderResolver,
toTeamChatInbound,
} from "@rakazo/adapters";
import { blockedAuthPaths, createAuth } from "@rakazo/auth";
import { signupPolicyFromEnv } from "@rakazo/core";
import type { Pool, PrismaClient } from "@rakazo/db";
import {
createDb,
createPool,
createThreadEvents,
parsePositiveInteger,
provisionMessagingIdentity,
requireMembership,
} from "@rakazo/db";
import type { Logger } from "@rakazo/logging";
import {
createServiceLogger,
enrichLogContext,
getLogger,
installLogger,
SERVICE_NAMES,
} from "@rakazo/logging";
import { requestLogging } from "@rakazo/logging/hono";
import { MarkdownMemoryStore } from "@rakazo/memory";
import { Hono } from "hono";
import { cors } from "hono/cors";
import type { AppEnv } from "./env.js";
import { loadEnv } from "./env.js";
import { mountLocalSettings } from "./local-settings.js";
import {
createMessagingInboundHandler,
teamChatSenderCanWakeMessageRoutines,
wakeMessageRoutines,
} from "./messaging-inbound.js";
import { mountMessagingWebhookRoutes } from "./messaging-webhook.js";
import { mountApiRequestBodyLimits } from "./request-body-limit.js";
import { createRouter } from "./router.js";
import { mountScreenTarget } from "./screen-proxy.js";
import { isDeferredReservationLost, TeamChatBridge } from "./team-chat-bridge.js";
import { ModelTeamChatEngagementJudge } from "./team-chat-judge.js";
import {
PendingTeamChatInbound,
prefersTeamChatSurface,
settleWithTimeout,
TEAM_CHAT_STARTUP_SHUTDOWN_MS,
} from "./team-chat-startup.js";
import { mountVoiceHttpRoutes } from "./voice.js";
import { mountWebhookHttpRoutes } from "./webhook.js";
export interface AppHandles {
app: Hono;
prisma: PrismaClient;
jobs: JobPublisher;
sandbox: SandboxProvider;
connector: DestinationEmulator;
composio?: ComposioProvider;
connectors: ConnectorRegistry;
messaging?: MessagingSurface;
email?: TransactionalEmailProvider;
executor: ReturnType<typeof createRunExecutor>;
runtime: AgentRuntime;
stop: () => Promise<void>;
}
export async function createApp(
overrides: Partial<AppEnv> & {
prisma?: PrismaClient;
realtime?: RealtimeFanout;
sandbox?: SandboxProvider;
composio?: ComposioProvider;
pipedream?: ManagedConnectorProvider;
messaging?: MessagingSurface;
email?: TransactionalEmailProvider;
remoteConnectors?: RemoteConnectorDependencies;
logger?: Logger;
} = {},
): Promise<AppHandles> {
const {
prisma: prismaOverride,
realtime: realtimeOverride,
sandbox: sandboxOverride,
composio: composioOverride,
pipedream: pipedreamOverride,
messaging: messagingOverride,
email: emailOverride,
remoteConnectors,
logger: loggerOverride,
...envOverrides
} = overrides;
const env = { ...loadEnv(process.env), ...envOverrides };
const logger = loggerOverride ?? createServiceLogger({ service: SERVICE_NAMES.api });
installLogger(logger);
const created = prismaOverride
? { prisma: prismaOverride, pool: undefined }
: createDb(env.databaseUrl, {
poolMax: parsePositiveInteger(process.env.DB_POOL_MAX, 4),
applicationName: "rakazo-api",
});
const { prisma } = created;
const realtime =
realtimeOverride ??
(created.pool
? new PostgresRealtimeFanout({
connectionString: env.realtimeDatabaseUrl,
publisher: created.pool,
})
: new InMemoryRealtimeFanout());
const secrets = new EncryptedSecretStore(env.encryptionKey);
const events = createThreadEvents(prisma, realtime, {
runSecretWriter: createRunSecretWriter(secrets),
});
const environmentSignupPolicy = signupPolicyFromEnv(env);
const deploymentSettings = await prisma.deploymentSettings.upsert({
where: { id: "default" },
create: {
id: "default",
signupsEnabled: environmentSignupPolicy.enabled,
signupAllowlist: environmentSignupPolicy.allowlist.join(","),
signupPolicyInitialized: true,
},
update: {},
});
if (!deploymentSettings.signupPolicyInitialized) {
// Older versions created this row with schema defaults even though auth
// still enforced the environment policy. Copy that effective policy once
// so upgrades preserve behavior before Settings becomes authoritative.
await prisma.deploymentSettings.updateMany({
where: { id: "default", signupPolicyInitialized: false },
data: {
signupsEnabled: environmentSignupPolicy.enabled,
signupAllowlist: environmentSignupPolicy.allowlist.join(","),
signupPolicyInitialized: true,
},
});
}
const jobKind = env.wakeupDriver;
const inMemoryJobs = jobKind === "memory" ? new InMemoryJobQueue() : undefined;
// prismaOverride skips createDb, so there is no shared pool. The previous
// GraphileJobPublisher(databaseUrl) path opened its own connections; keep a
// bounded pool for that override path instead of passing undefined.
let ownedJobPool: Pool | undefined;
if (!inMemoryJobs && !created.pool) {
ownedJobPool = createPool(env.databaseUrl, {
poolMax: parsePositiveInteger(process.env.DB_POOL_MAX, 4),
applicationName: "rakazo-api-jobs",
});
}
const jobPool = created.pool ?? ownedJobPool;
const jobs = inMemoryJobs
? inMemoryJobs
: new GraphileJobPublisher(
jobPool ??
(() => {
throw new Error("Graphile job publisher requires a PostgreSQL pool");
})(),
);
const sandbox: SandboxProvider =
sandboxOverride ??
createRunSandbox(env.sandboxProvider, {
supervisorUrl: env.sandboxSupervisorUrl,
supervisorToken: env.sandboxSupervisorToken,
e2bApiKey: env.e2bApiKey,
daytonaApiKey: env.daytonaApiKey,
daytonaApiUrl: env.daytonaApiUrl,
daytonaTarget: env.daytonaTarget,
boxApiKey: env.boxApiKey,
boxApiUrl: env.boxApiUrl,
dataDir: env.dataDir,
prisma,
});
const mcpOAuth = new McpOAuthBroker(prisma, secrets, remoteConnectors);
const memoryProviders = new SpaceMemoryProviderResolver(prisma, secrets);
const oauthLogins = new PiOAuthLogins();
const home = new LocalAgentHomeStore(env.dataDir);
const artifacts = new LocalArtifactStore(env.dataDir);
const memory = new MarkdownMemoryStore(prisma);
const mcp = new McpConnector(
prisma,
secrets,
{
stdioEnabled: env.mcpStdioEnabled,
allowedCommands: env.mcpStdioAllowedCommands,
network: remoteConnectors,
},
mcpOAuth,
);
const pipedreamConfig = pipedreamConfigFromEnv(env);
const pipedream =
pipedreamOverride ??
(isPipedreamEnabled(pipedreamConfig) ? new PipedreamConnector(pipedreamConfig) : undefined);
// This process registers the inbound sink (messaging.onInbound below),
// so it's the one that must hold Telegram's live getUpdates connection —
// see messagingPlatformsFromEnv's docstring for why a second poller
// elsewhere (e.g. the worker) would actively break this.
const messagingPlatforms = messagingPlatformsFromEnv(env, { pollInboundMessages: true });
const messaging =
messagingOverride ??
(isMessagingSurfaceEnabled(messagingPlatforms, {
deploymentModelKey: env.deploymentModelKey,
openSignup: env.messagingOpenSignup,
})
? new ChatSdkMessagingSurface(messagingPlatforms)
: undefined);
const localEmailEmulator =
!emailOverride && !env.smtpUrl && env.emailEmulator
? new EmailEmulator((message) => {
getLogger().info("email emulator captured message", {
"email.subject": message.subject,
});
})
: undefined;
if (localEmailEmulator && !isLoopbackHost(env.apiHost)) {
throw new Error("EMAIL_EMULATOR requires API_HOST to be a loopback host");
}
const email: TransactionalEmailProvider | undefined =
emailOverride ??
(env.smtpUrl
? new SmtpEmailProvider({ url: env.smtpUrl, from: env.emailFrom ?? "" })
: localEmailEmulator);
const installed = new InstalledConnectorProvider(prisma, secrets, remoteConnectors);
const integrationSettings = new IntegrationProviderSettings(prisma, secrets, env.encryptionKey, {
composio:
composioOverride ??
(isComposioEnabled(env.composioApiKey)
? new ComposioConnector(env.composioApiKey)
: undefined),
pipedream,
});
const stack = createConnectorStack(false, composioOverride, [
installed,
...integrationSettings
.providers()
.filter((provider) => !composioOverride || provider.describe().id !== "composio"),
mcp,
]);
const connector = stack.destination;
await connector.start();
integrationSettings.warmDirectories();
const runtime =
env.agentRuntime === "scripted"
? new ScriptedAgentRuntime()
: new PiAgentRuntime({
sessionRoot: env.piSessionRecording ? piSessionsRoot(env.dataDir) : undefined,
});
const notifications = new ExpoPushProvider(env.dataDir);
const auth = createAuth(prisma, {
secret: env.authSecret,
baseURL: env.authUrl,
webOrigin: env.webOrigin,
signupsEnabled: env.signupsEnabled,
signupAllowlist: env.signupAllowlist,
email,
onEmailError: (error) => getLogger().error("transactional email delivery failed", error),
extraOrigins: [
"rakazo://",
"exp://",
"exp://*",
"http://localhost:8081",
"http://127.0.0.1:8081",
"http://localhost:19006",
"http://127.0.0.1:19006",
"http://*:5173",
"http://*:3100",
"https://*:5173",
"https://*:3100",
"http://192.168.*:*",
"https://192.168.*:*",
"http://100.*:*",
"https://100.*:*",
"http://10.*:*",
"https://10.*:*",
"http://172.*:*",
"https://172.*:*",
"http://*.local:*",
"https://*.local:*",
...(process.env.EXTRA_TRUSTED_ORIGINS?.split(",").map((s) => s.trim()) ?? []),
],
beforeDeleteUser: async (userId) => {
const bots = await prisma.bot.findMany({
where: { userId },
select: { id: true, userId: true, spaceId: true, name: true, archivedAt: true },
});
await Promise.all(
bots.map((bot) =>
destroyBot(
{ prisma, sandbox, home, jobs, artifacts, dataDir: env.dataDir },
bot,
{
operationId: `account-delete:${userId}`,
traceId: `account-delete:${userId}`,
spaceId: bot.spaceId,
userId,
botId: bot.id,
signal: new AbortController().signal,
},
{ deleteMemories: true },
),
),
);
await removePiUserSessions(env.dataDir, userId);
await rm(pushTokenPath(env.dataDir, userId), { force: true }).catch(() => undefined);
},
});
// One provider instance so emulator launches and polls share the same Map.
const cloudAgent = createCloudAgentConnection({
CLOUD_AGENT_PROVIDER: env.cloudAgentProvider,
CURSOR_API_KEY: env.cursorApiKey,
CLOUD_AGENT_SPACE_ID: env.cloudAgentSpaceId,
});
const shutdown = new AbortController();
const executor = createRunExecutor({
prisma,
runtime,
sandbox,
memory,
memoryProviders,
home,
artifacts,
connector: stack.connector,
connectors: stack.connector,
listConnectedPluginSlugs: async (userId) => {
const provider = await integrationSettings.resolve("composio");
if (!provider) return [];
return provider.listConnectedExternalIds({
userId,
spaceId: "",
operationId: "connections.sync",
traceId: "connections.sync",
signal: AbortSignal.timeout(15_000),
});
},
secrets: [
env.deploymentModelKey ?? "",
env.composioApiKey ?? "",
env.cursorApiKey ?? "",
].filter(Boolean),
secretStore: secrets,
secretHttp: remoteConnectors,
deploymentModelKey: env.deploymentModelKey,
dataDir: env.dataDir,
notifications,
jobs,
events,
messaging: messaging ? createMessagingContextLoader(prisma) : undefined,
web: createWebProvider(),
cloudAgent,
shutdownSignal: shutdown.signal,
});
const jobHandlers = createBackgroundJobHandlers({
executor,
prisma,
sandbox,
home,
jobs,
events,
workerId: "api",
runtime,
secretStore: secrets,
memoryProviders,
deploymentModelKey: env.deploymentModelKey,
messaging,
cloudAgent,
});
if (inMemoryJobs) {
await inMemoryJobs.start(jobHandlers);
} else if (jobPool) {
const graphileWorkerHost = new GraphileJobWorkerHost(jobPool, {
concurrency: 4,
pollInterval: 500,
});
await graphileWorkerHost.start(jobHandlers);
}
const reconciler = inMemoryJobs
? createJobReconciler({
prisma,
jobs,
reconcileCloudAgents: () => reconcileCloudAgents({ prisma, jobs, cloudAgent }),
reconcileComputerUpdates: () => reconcileComputerUpdates({ prisma, jobs }),
reconcileProfileConsolidation: createProfileConsolidationScheduler({ prisma, jobs }),
reconcileDelegatedTasks: () => reconcileDelegatedTasks({ prisma, jobs, events }),
})
: undefined;
reconciler?.start();
const router = createRouter({
cloudAgent,
prisma,
events,
auth,
jobs,
sandbox,
memory,
memoryProviders,
home,
secrets,
oauthLogins,
integrationSettings,
mcpOAuth,
composio: stack.composio,
connectors: stack.connector,
remoteConnectors,
artifacts,
dataDir: env.dataDir,
messaging: {
enabled: Boolean(messaging),
providers: messaging?.platforms().map((platform) => platform.provider) ?? [],
openSignup: env.messagingOpenSignup,
},
env: {
agentRuntime: env.agentRuntime,
defaultProvider: env.defaultProvider,
defaultModel: env.defaultModel,
teamChatJudgeProvider: env.teamChatJudgeProvider,
teamChatJudgeModel: env.teamChatJudgeModel,
onboardingScanHome: env.onboardingScanHome,
deploymentModelKey: env.deploymentModelKey,
webOrigin: env.webOrigin,
privacyPolicyUrl: env.privacyPolicyUrl,
screenProxySecret: env.screenProxySecret,
sandboxProvider: env.sandboxProvider,
gitSha: env.gitSha,
updaterUrl: env.updaterUrl,
updaterToken: env.updaterToken,
imageTag: env.imageTag,
integrationsCatalogUrl: env.integrationsCatalogUrl,
},
});
const rpc = new RPCHandler(router, {
clientInterceptors: [onError((error, { path }) => logUnexpectedRpcError(error, path))],
});
const app = new Hono();
app.use("*", requestLogging(logger));
app.use(
"*",
cors({
origin: (origin) => {
if (!origin) return env.webOrigin;
return isTrustedOrigin(origin, env) ? origin : "";
},
credentials: true,
}),
);
// Security headers
app.use("*", async (c, next) => {
await next();
c.res.headers.set(
"Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ws:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
);
c.res.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
c.res.headers.set("X-Frame-Options", "DENY");
c.res.headers.set("X-Content-Type-Options", "nosniff");
c.res.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
c.res.headers.set(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()",
);
});
app.get("/api/auth/capabilities", (c) =>
c.json({
passwordReset: Boolean(email),
resetUrl: email ? new URL("/reset-password", env.webOrigin).href : null,
}),
);
if (localEmailEmulator && env.nodeEnv === "development") {
app.get(
"/api/dev/emails",
() =>
new Response(JSON.stringify(localEmailEmulator.sent), {
headers: { "cache-control": "no-store", "content-type": "application/json" },
}),
);
}
mountApiRequestBodyLimits(app);
mountScreenTarget(app, prisma, env.screenProxySecret);
app.on(["GET", "POST"], "/api/auth/*", async (c) => {
const path = new URL(c.req.url).pathname.replace("/api/auth", "");
if (blockedAuthPaths.some((blocked) => path.startsWith(blocked))) {
return c.json({ error: "Not available in version 1" }, 404);
}
return auth.handler(c.req.raw);
});
mountLocalSettings(app, { token: env.desktopStackToken, prisma, rpc });
app.use("/rpc/*", async (c, next) => {
const session = await auth.api.getSession({ headers: sessionHeaders(c.req.raw) });
const requestedSpaceId = c.req.header("x-rakazo-space-id");
const actor = session?.user
? await requireMembership(prisma, session.user.id, requestedSpaceId).catch(() => null)
: null;
if (actor) {
enrichLogContext({ "user.id": actor.userId, "space.id": actor.spaceId });
}
const { matched, response } = await rpc.handle(c.req.raw, {
prefix: "/rpc",
context: { actor, signal: c.req.raw.signal },
});
if (matched) return c.newResponse(response.body, response);
await next();
});
mountVoiceHttpRoutes(app, { prisma, secrets }, async (c) => {
const session = await auth.api.getSession({ headers: sessionHeaders(c.req.raw) });
if (!session?.user) return null;
const actor = await requireMembership(
prisma,
session.user.id,
c.req.header("x-rakazo-space-id"),
).catch(() => null);
if (actor) enrichLogContext({ "user.id": actor.userId, "space.id": actor.spaceId });
return actor;
});
mountWebhookHttpRoutes(app, { prisma, secrets, events, jobs });
// Shared with stop so a shutdown during retry delays does not restart polling.
let messagingStopped = false;
let clearMessagingRetryDelay: (() => void) | undefined;
let messagingInitTask: Promise<void> | undefined;
let clearTeamChatRetryDelay: (() => void) | undefined;
let teamChatInitTask: Promise<void> | undefined;
// Messaging webhooks only exist when the surface is enabled.
let teamChatBridge: TeamChatBridge | undefined;
/** Constructed even before start() succeeds so stop() can cancel in-flight startup. */
let teamChatBridgeInstance: TeamChatBridge | undefined;
const pendingTeamChatInbound = new PendingTeamChatInbound();
if (messaging) {
const inboundDeps = {
prisma,
events,
jobs,
provision: (request, policyEnv) => provisionMessagingIdentity(prisma, request, policyEnv),
openSignup: env.messagingOpenSignup,
signupPolicy: {
signupsEnabled: env.signupsEnabled,
signupAllowlist: env.signupAllowlist,
},
typing: (threadId) => {
// Keep conversation addresses out of trace ids — those reach logs
// and telemetry, a different trust boundary than the database.
const operationId = `messaging.typing:${randomUUID()}`;
return messaging.sendTyping(threadId, {
operationId,
traceId: operationId,
spaceId: "",
userId: "",
// Cosmetic side call: the wait is bounded so a stalled vendor
// response never holds our callback chain (the Chat SDK adapter
// API cannot cancel the underlying request itself).
signal: AbortSignal.timeout(2000),
});
},
} satisfies Parameters<typeof createMessagingInboundHandler>[0];
const inbound = createMessagingInboundHandler(inboundDeps);
const handleTeamChatInbound = async (
bridge: TeamChatBridge,
event: Parameters<typeof wakeMessageRoutines>[2],
) => {
const mapped = toTeamChatInbound(event);
if (!mapped) {
await inbound(event);
return;
}
const canWake = await teamChatSenderCanWakeMessageRoutines(inboundDeps, event);
if (!canWake) {
await bridge.receive(mapped);
return;
}
// Persist a non-reconcilable row until routine routing owns or releases
// the message, so the timer cannot start a second TeamChat run.
const target = await bridge.receive(mapped, { queueAgent: false });
if (!target.deferred) return;
const leaseHeartbeat = await bridge.startDeferredReservationHeartbeat(
target.externalMessageId,
);
let woken = false;
bridge.markRoutineWakeInFlight(target.externalMessageId);
try {
const wakePromise = wakeMessageRoutines(inboundDeps, target, event, {
// Must match TeamChatBridge ExternalConversation / recovery provider.
deliveryProvider: bridge.providerId,
externalMessageId: target.externalMessageId,
});
try {
woken = await Promise.race([wakePromise, leaseHeartbeat.lost]);
} catch (error) {
if (isDeferredReservationLost(error)) {
// Lease loss must not start a fallback agent beside an in-flight
// wake: re-hold exclusive ownership while awaiting that wake, then
// resolve from its settled result.
let hold: { stop: () => void } | undefined;
try {
hold = await bridge.startDeferredReservationHeartbeat(target.externalMessageId);
} catch {
// Row already left deferred; wake CAS / resolve decide the winner.
}
try {
try {
woken = await wakePromise;
} catch (wakeError) {
const released = await bridge.resolveDeferredMessage(
target.externalMessageId,
"agent",
mapped.kind,
);
if (!released) {
throw new Error("Team chat deferred message ownership conflict", {
cause: wakeError,
});
}
await bridge.reconcileOnce();
getLogger().error(
"team chat routine wake failed after deferred lease loss",
wakeError,
);
return;
}
} finally {
hold?.stop();
}
} else {
await bridge.resolveDeferredMessage(target.externalMessageId, "agent", mapped.kind);
await bridge.reconcileOnce();
throw error;
}
}
const resolved = await bridge.resolveDeferredMessage(
target.externalMessageId,
woken ? "routine" : "agent",
mapped.kind,
);
if (!resolved) {
throw new Error("Team chat deferred message ownership conflict");
}
if (!woken) await bridge.reconcileOnce();
} finally {
bridge.clearRoutineWakeInFlight(target.externalMessageId);
leaseHeartbeat.stop();
}
};
const flushPendingTeamChatInbound = (bridge: TeamChatBridge) => {
pendingTeamChatInbound.flush((event) => handleTeamChatInbound(bridge, event));
};
if (env.teamChatBotId) {
const judge =
env.teamChatJudgeProvider && env.teamChatJudgeModel
? new ModelTeamChatEngagementJudge({
prisma,
runtime,
secrets,
deploymentProvider: env.defaultProvider,
deploymentModel: env.defaultModel,
deploymentModelKey: env.deploymentModelKey,
providerOverride: env.teamChatJudgeProvider,
modelOverride: env.teamChatJudgeModel,
})
: new ModelTeamChatEngagementJudge({
prisma,
runtime,
secrets,
deploymentProvider: env.defaultProvider,
deploymentModel: env.defaultModel,
deploymentModelKey: env.deploymentModelKey,
});
const bridge = new TeamChatBridge({
prisma,
events,
jobs,
send: createMessagingTeamChatSender(messaging),
providerId: "slack",
botId: env.teamChatBotId,
judge,
});
teamChatBridgeInstance = bridge;
try {
await bridge.start();
teamChatBridge = bridge;
} catch (error) {
getLogger().error("team chat bridge failed to start; retrying", error);
teamChatInitTask = (async () => {
let delayMs = 2_000;
while (!messagingStopped) {
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, delayMs);
clearTeamChatRetryDelay = () => {
clearTimeout(timer);
clearTeamChatRetryDelay = undefined;
resolve();
};
});
clearTeamChatRetryDelay = undefined;
if (messagingStopped) return;
try {
await bridge.start();
if (messagingStopped) {
await bridge.stop();
return;
}
teamChatBridge = bridge;
flushPendingTeamChatInbound(bridge);
return;
} catch (retryError) {
if (
retryError instanceof Error &&
retryError.message === "Team chat bridge start cancelled"
) {
return;
}
getLogger().error("team chat bridge failed to start; retrying", retryError);
delayMs = Math.min(delayMs * 5, 30_000);
}
}
})();
}
}
messaging.onInbound(async (event) => {
if (event.type !== "message") {
await applyMessagingOutboundStatus(prisma, event);
return;
}
if (prefersTeamChatSurface(event, env.teamChatBotId)) {
const bridge = teamChatBridge;
if (bridge) {
await handleTeamChatInbound(bridge, event);
return;
}
// Bridge is still starting (or retrying). Do not fall through to the
// personal-line inbound path — that bypasses externalMessage ownership
// and can wake routines for unlinked TeamChat senders.
if (teamChatInitTask) {
const pending = pendingTeamChatInbound.enqueue(event);
if (!pending) {
throw new Error("Team chat inbound buffer is full");
}
await pending;
return;
}
throw new Error("Team chat bridge is unavailable");
}
await inbound(event);
});
mountMessagingWebhookRoutes(app, { messaging });
// Start polling-mode adapters (e.g. Telegram with no public webhook URL
// registered) immediately rather than waiting for the first webhook
// POST or outbound send to lazily trigger it. This is the process that
// owns the inbound sink registered just above, so it must be the one
// holding the live connection — a second poller elsewhere (e.g. the
// worker) would only fight this one for Telegram's single getUpdates
// slot without ever seeing the messages itself.
// Bounded retries cover transient Telegram startup failures; polling-only
// bots otherwise stay dark until an unrelated outbound send re-inits.
messagingInitTask = (async () => {
const delayMs = [0, 2_000, 10_000];
for (let attempt = 0; attempt < delayMs.length; attempt += 1) {
if (messagingStopped) return;
if (delayMs[attempt]! > 0) {
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, delayMs[attempt]);
clearMessagingRetryDelay = () => {
clearTimeout(timer);
clearMessagingRetryDelay = undefined;
resolve();
};
});
clearMessagingRetryDelay = undefined;
}
if (messagingStopped) return;
try {
await messaging.initialize?.();
return;
} catch (error) {
getLogger().error(
attempt === delayMs.length - 1
? "messaging surface initialize failed"
: "messaging surface initialize failed; retrying",
error,
);
}
}
})();
}
// Data export endpoint — user downloads all their data as JSON
app.get("/api/data/export", async (c) => {
const session = await auth.api.getSession({ headers: sessionHeaders(c.req.raw) });
if (!session?.user) return c.json({ error: "Unauthorized" }, 401);
const userId = session.user.id;
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
name: true,
email: true,
emailVerified: true,
createdAt: true,
updatedAt: true,
},
});
const bots = await prisma.bot.findMany({
where: { userId },
select: { id: true, name: true, createdAt: true, archivedAt: true },
});
const botIds = bots.map((b) => b.id);
const runs = await prisma.run.findMany({
where: { botId: { in: botIds } },
select: { id: true, status: true, createdAt: true, updatedAt: true },
});
const consents = await prisma.aiDataConsent.findMany({
where: { userId },
select: { recipientKey: true, version: true, grantedAt: true },
});
const exportData = {
exportedAt: new Date().toISOString(),
user,
bots,
runs,
aiDataConsents: consents,
note: "Alle Daten werden lokal auf Ihrem Server gespeichert. Keine externen Dienste.",
};
const json = JSON.stringify(exportData, null, 2);
const filename = `evo-data-export-${new Date().toISOString().split("T")[0]}.json`;
return c.newResponse(json, 200, {
"Content-Type": "application/json",
"Content-Disposition": `attachment; filename="${filename}"`,
});
});
app.get("/health", (c) =>
c.json({
ok: true,
runtime: env.agentRuntime,
sandbox: env.sandboxProvider,
composio: Boolean(stack.composio),
pipedream: Boolean(pipedream),
messaging: Boolean(messaging),
email: email?.describe().id ?? null,
jobs: jobKind,
realtime: realtime.describe().id,
revision: env.gitSha ?? null,
}),
);
return {
app,
prisma,
jobs,
sandbox,
connector,
composio: stack.composio,
connectors: stack.connector,
messaging,
email,
executor,
runtime,
stop: async () => {
// Abort in-flight continueRun boot waits before draining jobs so stop() cannot sit
// on waitForComputerReady for the full boot-wait window during shared Postgres journeys.
shutdown.abort();
oauthLogins.abortAll();
messagingStopped = true;
clearMessagingRetryDelay?.();
clearTeamChatRetryDelay?.();
pendingTeamChatInbound.reject(new Error("Team chat bridge stopped before startup"));
// Cancel in-flight start() before awaiting the retry task so stop() cannot
// sit on DB/reconcile work that bridge.start() is still running.
await settleWithTimeout(
teamChatBridgeInstance ? teamChatBridgeInstance.stop().catch(() => undefined) : undefined,
TEAM_CHAT_STARTUP_SHUTDOWN_MS,
);
await messagingInitTask?.catch(() => undefined);
await settleWithTimeout(teamChatInitTask, TEAM_CHAT_STARTUP_SHUTDOWN_MS);
await messaging?.shutdown?.();
await teamChatBridge?.stop();
await email?.drain?.();
await reconciler?.stop();
await jobs.close();
await realtime.close();
await connector.stop();
await mcp.close();
await prisma.$disconnect().catch(() => undefined);
await created.pool?.end().catch(() => undefined);
await ownedJobPool?.end().catch(() => undefined);
await logger.flush({ timeoutMs: 2_000 });
},
};
}
function isTrustedOrigin(origin: string, env: AppEnv) {
if (!origin) return true;
if (origin === env.webOrigin || origin === env.apiUrl || origin === env.authUrl) return true;
if (origin.startsWith("rakazo://") || origin.startsWith("exp://")) return true;
try {
const host = new URL(origin).hostname;
return isPrivateOrLocalHost(host);
} catch {
return false;
}
}
function isPrivateOrLocalHost(host: string): boolean {
if (isLoopbackHost(host)) return true;
if (host.endsWith(".local")) return true;
if (/^192\.168\.\d{1,3}\.\d{1,3}$/.test(host)) return true;
if (/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) return true;
if (/^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/.test(host)) return true;
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d{1,3}\.\d{1,3}$/.test(host)) return true;
return false;
}
function isLoopbackHost(host: string): boolean {
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
}
function sessionHeaders(request: Request) {
const headers = new Headers(request.headers);
const authz = headers.get("authorization");
if (authz?.toLowerCase().startsWith("bearer ") && !headers.get("cookie")) {
headers.set("cookie", `better-auth.session_token=${authz.slice(7).trim()}`);
}
return headers;
}
/**
* An ORPCError is a decision the router made (BAD_REQUEST, UNAUTHORIZED, ...) and reaches the
* caller intact. Everything else is flattened into an opaque "Internal server error", so
* unless it is logged here the only record of what actually broke is gone.
*
* The cause chain matters as much as the message: undici and most SDKs report a bare
* "fetch failed" and keep the host and errno one level down.
*/
export function logUnexpectedRpcError(error: unknown, path: readonly string[]): void {
if (error instanceof ORPCError) return;
const where = `rpc ${path.join("/")} failed`;
getLogger().error(where, error);
}