cyrus-ai
Version:
AI-powered Linear issue automation using Claude
246 lines • 11.2 kB
JavaScript
import { getCyrusAppUrl } from "cyrus-cloudflare-tunnel-client";
import { EdgeWorker } from "cyrus-edge-worker";
import { SlackEventTransport } from "cyrus-slack-event-transport";
import { DEFAULT_SERVER_PORT, parsePort } from "../config/constants.js";
/**
* Service responsible for EdgeWorker and Cloudflare tunnel management
*/
export class WorkerService {
configService;
gitService;
cyrusHome;
logger;
version;
edgeWorker = null;
setupWaitingServer = null; // SharedApplicationServer instance during setup waiting mode
isShuttingDown = false;
constructor(configService, gitService, cyrusHome, logger, version) {
this.configService = configService;
this.gitService = gitService;
this.cyrusHome = cyrusHome;
this.logger = logger;
this.version = version;
}
/**
* Get the EdgeWorker instance
*/
getEdgeWorker() {
return this.edgeWorker;
}
/**
* Get the server port from EdgeWorker
*/
getServerPort() {
return this.edgeWorker?.getServerPort() || DEFAULT_SERVER_PORT;
}
/**
* Start setup waiting mode - server infrastructure only, no EdgeWorker
* Used after initial authentication while waiting for server configuration
*/
async startSetupWaitingMode() {
await this.startPreWorkerServer({
headerLine: "⏳ Waiting for configuration from server...",
footerLines: (appUrl) => [
"Your Cyrus instance is ready to receive configuration.",
`Complete setup at: ${appUrl}/onboarding`,
],
});
}
/**
* Start idle mode - server infrastructure only, no EdgeWorker
* Used after onboarding when no repositories are configured
*/
async startIdleMode() {
await this.startPreWorkerServer({
headerLine: "⏸️ No repositories configured",
footerLines: (appUrl) => process.env.LINEAR_CLIENT_ID
? ["Add a repository with: cyrus self-add-repo <git-url>"]
: [
`Waiting for repository configuration from ${appUrl}`,
`Add repositories at: ${appUrl}/repos`,
],
});
}
/**
* Shared infrastructure for modes that run the config-update server without
* an EdgeWorker (setup-waiting, idle). Only the banner text differs between
* modes, so callers supply just the mode-specific lines.
*/
async startPreWorkerServer(banner) {
const { SharedApplicationServer } = await import("cyrus-edge-worker");
const { ConfigUpdater } = await import("cyrus-config-updater");
const isExternalHost = process.env.CYRUS_HOST_EXTERNAL?.toLowerCase().trim() === "true";
const serverPort = parsePort(process.env.CYRUS_SERVER_PORT, DEFAULT_SERVER_PORT);
const serverHost = isExternalHost ? "0.0.0.0" : "localhost";
this.setupWaitingServer = new SharedApplicationServer(serverPort, serverHost);
this.setupWaitingServer.initializeFastify();
const configUpdater = new ConfigUpdater(this.setupWaitingServer.getFastifyInstance(), this.cyrusHome, () => process.env.CYRUS_API_KEY || "");
configUpdater.register();
this.logger.info("✅ Config updater registered");
this.logger.info(" Routes: /api/update/cyrus-config, /api/update/cyrus-env,");
this.logger.info(" /api/update/repository, /api/update/test-mcp, /api/update/configure-mcp");
this.registerWebhookTransports(this.setupWaitingServer);
// Starts Cloudflare tunnel too, if CLOUDFLARE_TOKEN is set.
await this.setupWaitingServer.start();
this.logger.raw("");
this.logger.divider(70);
this.logger.info(banner.headerLine);
this.logger.info(`🔗 Server running on port ${serverPort}`);
if (process.env.CLOUDFLARE_TOKEN) {
this.logger.info("🌩️ Cloudflare tunnel: Active");
}
this.logger.info("📡 Config updater: Ready");
this.logger.raw("");
for (const line of banner.footerLines(getCyrusAppUrl())) {
this.logger.info(line);
}
this.logger.divider(70);
}
/**
* Register webhook endpoints that don't require repositories.
* Called from both idle and setup-waiting modes so that external services
* (e.g. Slack URL verification) can reach Cyrus during onboarding.
*/
registerWebhookTransports(server) {
const isExternalHost = process.env.CYRUS_HOST_EXTERNAL?.toLowerCase().trim() === "true";
const slackSigningSecret = process.env.SLACK_SIGNING_SECRET;
const hasSlackSigningSecret = slackSigningSecret != null && slackSigningSecret !== "";
if (isExternalHost && hasSlackSigningSecret) {
const slackTransport = new SlackEventTransport({
fastifyServer: server.getFastifyInstance(),
verificationMode: "direct",
secret: slackSigningSecret,
});
slackTransport.register();
this.logger.info("✅ Slack webhook registered");
}
}
/**
* Stop the setup waiting mode or idle mode server
* Must be called before starting EdgeWorker to avoid port conflicts
*/
async stopWaitingServer() {
if (this.setupWaitingServer) {
this.logger.info("🛑 Stopping waiting server...");
await this.setupWaitingServer.stop();
this.setupWaitingServer = null;
this.logger.info("✅ Waiting server stopped");
}
}
/**
* Start the EdgeWorker with given configuration
*/
async startEdgeWorker(params) {
const { repositories, ngrokAuthToken, onOAuthCallback } = params;
// Determine if using external host
const isExternalHost = process.env.CYRUS_HOST_EXTERNAL?.toLowerCase().trim() === "true";
// Load config once for model defaults
const edgeConfig = this.configService.load();
// Create EdgeWorker configuration
const config = {
version: this.version,
repositories,
cyrusHome: this.cyrusHome,
linearAllowedTools: process.env.LINEAR_ALLOWED_TOOLS?.split(",").map((t) => t.trim()) ||
edgeConfig.linearAllowedTools ||
[],
slackAllowedTools: edgeConfig.slackAllowedTools,
githubAllowedTools: edgeConfig.githubAllowedTools,
slackMcpConfigs: edgeConfig.slackMcpConfigs,
linearMcpConfigs: edgeConfig.linearMcpConfigs,
githubMcpConfigs: edgeConfig.githubMcpConfigs,
defaultDisallowedTools: process.env.DISALLOWED_TOOLS?.split(",").map((t) => t.trim()) ||
edgeConfig.defaultDisallowedTools ||
undefined,
// Model configuration: environment variables take precedence over config file.
// Legacy env vars/keys are still accepted for backwards compatibility.
claudeDefaultModel: process.env.CYRUS_CLAUDE_DEFAULT_MODEL ||
process.env.CYRUS_DEFAULT_MODEL ||
edgeConfig.claudeDefaultModel ||
edgeConfig.defaultModel,
claudeDefaultFallbackModel: process.env.CYRUS_CLAUDE_DEFAULT_FALLBACK_MODEL ||
process.env.CYRUS_DEFAULT_FALLBACK_MODEL ||
edgeConfig.claudeDefaultFallbackModel ||
edgeConfig.defaultFallbackModel,
geminiDefaultModel: process.env.CYRUS_GEMINI_DEFAULT_MODEL || edgeConfig.geminiDefaultModel,
codexDefaultModel: process.env.CYRUS_CODEX_DEFAULT_MODEL || edgeConfig.codexDefaultModel,
defaultRunner: process.env.CYRUS_DEFAULT_RUNNER || edgeConfig.defaultRunner,
issueUpdateTrigger: edgeConfig.issueUpdateTrigger,
prReviewTrigger: edgeConfig.prReviewTrigger,
promptDefaults: edgeConfig.promptDefaults,
linearWorkspaces: edgeConfig.linearWorkspaces,
webhookBaseUrl: process.env.CYRUS_BASE_URL,
serverPort: parsePort(process.env.CYRUS_SERVER_PORT, DEFAULT_SERVER_PORT),
serverHost: isExternalHost ? "0.0.0.0" : "localhost",
ngrokAuthToken,
// User access control configuration
userAccessControl: edgeConfig.userAccessControl,
sandbox: edgeConfig.sandbox,
handlers: {
createWorkspace: async (issue, repositories, options) => {
return this.gitService.createGitWorktree(issue, repositories, {
globalSetupScript: edgeConfig.global_setup_script,
baseBranchOverrides: options?.baseBranchOverrides,
onRepoSetupHookEvent: options?.onRepoSetupHookEvent,
});
},
onOAuthCallback,
},
};
// Create and start EdgeWorker
this.edgeWorker = new EdgeWorker(config);
// Set config path for dynamic reloading
const configPath = this.configService.getConfigPath();
this.edgeWorker.setConfigPath(configPath);
// Set up event handlers
this.setupEventHandlers();
// Start the worker
await this.edgeWorker.start();
this.logger.success("Edge worker started successfully");
}
/**
* Set up event handlers for EdgeWorker
*/
setupEventHandlers() {
if (!this.edgeWorker)
return;
// Session events
this.edgeWorker.on("session:started", (issueId, _issue, repositoryId) => {
this.logger.info(`Started session for issue ${issueId} in repository ${repositoryId}`);
});
this.edgeWorker.on("session:ended", (issueId, exitCode, repositoryId) => {
this.logger.info(`Session for issue ${issueId} ended with exit code ${exitCode} in repository ${repositoryId}`);
});
// Connection events
this.edgeWorker.on("connected", (token) => {
this.logger.success(`Connected to proxy with token ending in ...${token.slice(-4)}`);
});
this.edgeWorker.on("disconnected", (token, reason) => {
this.logger.error(`Disconnected from proxy (token ...${token.slice(-4)}): ${reason || "Unknown reason"}`);
});
// Error events
this.edgeWorker.on("error", (error) => {
this.logger.error(`EdgeWorker error: ${error.message}`);
});
}
/**
* Stop the EdgeWorker
*/
async stop() {
if (this.isShuttingDown)
return;
this.isShuttingDown = true;
this.logger.info("\nShutting down edge worker...");
// Stop setup waiting mode server if still running
if (this.setupWaitingServer) {
await this.setupWaitingServer.stop();
this.setupWaitingServer = null;
}
// Stop edge worker (includes stopping shared application server and Cloudflare tunnel)
if (this.edgeWorker) {
await this.edgeWorker.stop();
}
this.logger.info("Shutdown complete");
}
}
//# sourceMappingURL=WorkerService.js.map