Files
AgentCompany/src/main/connector-health-service.ts
T
ZacharyZhang-NY b772e1c758 Build the current workspace snapshot on native CI runners
This branch snapshots the in-progress desktop workspace so GitHub-hosted Windows and Linux runners can package the same source tree that already passes local verification on macOS.

Constraint: Local Apple Silicon Docker/Wine packaging crashes before Windows artifacts are produced
Rejected: Publish from the incomplete base repo state | remote typecheck failed because current source additions were missing
Confidence: medium
Scope-risk: broad
Directive: Treat this branch as a build snapshot only; do not merge it without reviewing the unrelated workspace changes it contains
Tested: local typecheck, lint, workspace typecheck, unit tests, integration tests, e2e smoke, macOS signed release upload
Not-tested: Native Windows/Linux artifacts before this CI run
2026-04-04 03:28:14 -04:00

189 lines
6.6 KiB
TypeScript

import { spawn } from "node:child_process";
import { homedir } from "node:os";
import { normalizeCommandValue } from "@agentcompany/adapter-utils/server-utils";
import type { AppDatabase } from "./database";
import { getConnectorDefinition } from "./connectors";
import { buildSanitizedRuntimeEnv } from "./runtime-env";
import type { ConnectorId } from "@shared/types";
interface ConnectorHealthDependencies {
db: AppDatabase;
logger: {
info: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
publishDomainChanged: () => void;
}
export function createConnectorHealthService({
db,
logger: _logger,
publishDomainChanged,
}: ConnectorHealthDependencies) {
function randomFallbackCompanyId() {
const snapshot = db.listSnapshot();
return snapshot.companies[0]?.id ?? "00000000-0000-0000-0000-000000000000";
}
function resolveProbeCwd() {
const snapshot = db.listSnapshot();
const currentCompanyId = db.getCurrentCompanyId();
const preferredWorkspace = snapshot.workspaces.find((workspace) => workspace.companyId === currentCompanyId)
?? snapshot.workspaces[0]
?? null;
return preferredWorkspace?.localPath ?? process.env.USERPROFILE ?? process.env.HOME ?? homedir() ?? process.cwd();
}
function parseVersion(command: string): Promise<string | null> {
return new Promise((resolve) => {
let resolved = false;
const normalizedCommand = normalizeCommandValue(command);
const child = spawn(normalizedCommand, ["--version"], {
env: buildSanitizedRuntimeEnv(process.env, {}),
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
let buffer = "";
child.stdout.on("data", (chunk) => {
buffer += String(chunk);
});
child.stderr.on("data", (chunk) => {
buffer += String(chunk);
});
child.on("error", () => {
if (!resolved) { resolved = true; resolve(null); }
});
child.on("close", () => {
if (!resolved) {
resolved = true;
const versionLine = buffer.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? null;
resolve(versionLine);
}
});
// Kill the child after 8 seconds to avoid blocking startup.
setTimeout(() => {
if (!resolved) {
resolved = true;
try { child.kill("SIGKILL"); } catch { /* process may have already exited */ }
resolve(null);
}
}, 8_000);
});
}
async function checkConnector(id: ConnectorId) {
const connector = db.getConnector(id);
const definition = getConnectorDefinition(id);
const companyId = db.getCurrentCompanyId() ?? randomFallbackCompanyId();
const probeCwd = resolveProbeCwd();
const runtimeOverrides = db.resolveSecretEnv(connector.envBindingText, companyId);
db.updateConnectorHealth({
id,
status: "detected",
authState: connector.authState,
version: connector.version,
lastError: null,
});
publishDomainChanged();
try {
const testPromise = definition.module.testEnvironment({
companyId,
adapterType: id,
config: {
command: connector.command,
model: connector.model,
cwd: probeCwd,
env: buildSanitizedRuntimeEnv(process.env, runtimeOverrides),
approvalMode: "default",
sandbox: true,
},
});
const result = await Promise.race([
testPromise,
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Connector health check timed out after 45 seconds")), 45_000)),
]);
// Check if the CLI command itself is missing before inspecting auth status.
// Without this, info-level hints like "run `gemini auth login`" would wrongly
// trigger auth_required even when the CLI binary doesn't exist.
const commandMissing = result.checks.some((check) =>
/command_unresolvable|command_not_found/i.test(check.code) && check.level === "error",
);
// Only consider warn/error-level checks for auth detection.
// Info-level checks (like gemini_api_key_missing) carry advisory hints
// that match auth patterns but do NOT indicate actual auth failure.
const authRequired = !commandMissing && result.checks.some((check) =>
check.level !== "info" && (
/auth_required|login_required/i.test(check.code) ||
/login is required|authentication is not ready|auth(?:entication)? required/i.test(check.message) ||
/run .*login|use \/auth|configure .*api key/i.test(check.hint ?? "")
),
);
let authState: "ok" | "required" | "error" = "ok";
if (commandMissing) {
authState = "error";
} else if (authRequired) {
authState = "required";
} else if (result.status === "fail") {
authState = "error";
}
const version = commandMissing ? null : await parseVersion(connector.command);
// claude_compatible requires explicit env bindings (API key + base URL) to be
// considered ready. Without them, it just uses the official claude CLI auth
// which is not what the user intends for a compatible provider.
const needsCompatConfig = id.startsWith("claude_compat")
&& !connector.envBindingText.includes("ANTHROPIC_BASE_URL");
db.updateConnectorHealth({
id,
status:
commandMissing
? "not_installed"
: needsCompatConfig
? "auth_required"
: authRequired
? "auth_required"
: result.status === "pass" || result.status === "warn"
? "ready"
: "error",
authState,
version,
lastError:
result.status === "fail" || result.status === "warn"
? result.checks.map((check) => check.message).join(" | ")
: null,
});
db.addActivity({
companyId,
actor: "system",
action: "connector.tested",
entityType: "connector",
entityId: id,
detail: result.status,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
_logger.error(`[connector] Health check failed for ${id}: ${errorMessage}`);
const isNotInstalled = /command.*not found|not executable|ENOENT/i.test(errorMessage);
const status = isNotInstalled ? "not_installed" : "error";
db.updateConnectorHealth({
id,
status,
authState: isNotInstalled ? "error" : "unknown",
version: null,
lastError: errorMessage,
});
}
publishDomainChanged();
}
return {
checkConnector,
randomFallbackCompanyId,
};
}