Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
145 lines
6.5 KiB
TypeScript
145 lines
6.5 KiB
TypeScript
import { app } from "electron";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import type { DatabaseSync } from "node:sqlite";
|
|
import { DatabaseConnection } from "./database/connection";
|
|
import { appPageUrl, createMainWindow } from "./app/create-window";
|
|
import { registerIpcHandlers } from "./app/ipc-handlers";
|
|
import { builtinSkillsSourceOverride, importStagedSkills, stageBuiltinSkillsLibrary } from "./services/builtin-skills";
|
|
import { ensureCeoDirectSpace } from "./services/ceo-direct-space";
|
|
import { SkillService } from "./services/skill-service";
|
|
import { startProviderBackgroundWorker } from "./app/provider-background-worker";
|
|
import { recoverInterruptedWork } from "./app/startup-recovery";
|
|
import { startTaskQueueWorker } from "./app/task-queue-worker";
|
|
import { stopAllRunsAndWait } from "./services/run-control";
|
|
|
|
const QUIT_GRACE_MS = 10_000;
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
// A losing second instance must never touch the database: quit is
|
|
// asynchronous, so all startup work stays behind the lock check.
|
|
if (!app.requestSingleInstanceLock()) {
|
|
app.quit();
|
|
} else {
|
|
const profileDir = process.env.AGENTCOMPANY_NEW_PROFILE_DIR ?? join(app.getPath("userData"), "profile");
|
|
// The database opens before ready: Chromium's UI locale (form-control
|
|
// strings, date formatting) is only settable pre-ready, and it follows the
|
|
// stored language preference instead of the OS locale.
|
|
const connection = new DatabaseConnection(join(profileDir, "agentcompany.db"));
|
|
const language = storedLanguage(connection.db);
|
|
app.commandLine.appendSwitch("lang", language === "zh" ? "zh-CN" : "en-US");
|
|
|
|
app.whenReady().then(() => {
|
|
connection.transaction(() => recoverInterruptedWork(connection.db));
|
|
const win = createMainWindow({
|
|
preloadPath: join(__dirname, "../preload/index.js"),
|
|
rendererDist: join(__dirname, "../renderer"),
|
|
devServerUrl: process.env.ELECTRON_RENDERER_URL,
|
|
language,
|
|
});
|
|
|
|
const builtinSkillsSource = resolveBuiltinSkillsSource();
|
|
const builtinSkillsLibrary = join(profileDir, "builtin-skills");
|
|
registerIpcHandlers({
|
|
connection,
|
|
mainWindow: win,
|
|
appPage: appPageUrl({ devServerUrl: process.env.ELECTRON_RENDERER_URL, rendererDist: join(__dirname, "../renderer") }),
|
|
artifactRoot: join(profileDir, "artifacts"),
|
|
builtinSkillsSource,
|
|
builtinSkillsLibrary,
|
|
});
|
|
syncAllOrganizationSkills(connection, join(profileDir, "artifacts"), builtinSkillsSource, builtinSkillsLibrary);
|
|
ensureAllCeoConversations(connection);
|
|
const stopProviderBackgroundWorker = startProviderBackgroundWorker({
|
|
db: connection.db,
|
|
mainWindow: win,
|
|
});
|
|
const autonomousExecution = process.env.AGENTCOMPANY_NEW_AUTORUN !== "0";
|
|
const stopTaskQueueWorker = autonomousExecution
|
|
? startTaskQueueWorker({ connection, mainWindow: win })
|
|
: () => {};
|
|
|
|
app.on("second-instance", () => {
|
|
if (win.isMinimized()) win.restore();
|
|
win.focus();
|
|
});
|
|
|
|
let shutdown: "running" | "draining" | "drained" = "running";
|
|
app.on("before-quit", (event) => {
|
|
if (shutdown === "drained") return;
|
|
// Hold every exit request until running sessions are stopped and have recorded how they ended.
|
|
event.preventDefault();
|
|
if (shutdown === "draining") return;
|
|
shutdown = "draining";
|
|
stopProviderBackgroundWorker();
|
|
stopTaskQueueWorker();
|
|
void stopAllRunsAndWait("The app was closing.", QUIT_GRACE_MS).then((unsettled) => {
|
|
if (unsettled > 0) console.warn(`[quit] ${unsettled} run(s) did not settle within ${QUIT_GRACE_MS}ms; startup recovery will re-queue them.`);
|
|
connection.close();
|
|
shutdown = "drained";
|
|
app.quit();
|
|
});
|
|
});
|
|
});
|
|
|
|
app.on("window-all-closed", () => {
|
|
if (process.platform !== "darwin") app.quit();
|
|
});
|
|
}
|
|
|
|
function storedLanguage(db: DatabaseSync): string {
|
|
const row = db.prepare("select language from app_state where id = 1").get() as { language: string } | undefined;
|
|
return row?.language ?? "en";
|
|
}
|
|
|
|
/**
|
|
* The bundled skill catalogue: the repo folder during development, the
|
|
* extraResources copy in a packaged build, or an explicit override for
|
|
* hermetic tests (same seam pattern as AGENTCOMPANY_NEW_PROFILE_DIR).
|
|
*/
|
|
function resolveBuiltinSkillsSource(): string {
|
|
const override = builtinSkillsSourceOverride();
|
|
if (override) return override;
|
|
if (app.isPackaged) return join(process.resourcesPath, "built-in-skills");
|
|
return join(app.getAppPath(), "built-in skills");
|
|
}
|
|
|
|
/**
|
|
* Mirrors the bundled skill catalogue into the profile library and imports it
|
|
* for every organization at startup. Per-skill failures are reported inside
|
|
* the sync; a broken bundle is loud in the log but must not stop the app.
|
|
*/
|
|
function syncAllOrganizationSkills(connection: DatabaseConnection, artifactRoot: string, sourceRoot: string, libraryRoot: string): void {
|
|
try {
|
|
const staged = stageBuiltinSkillsLibrary(sourceRoot, libraryRoot);
|
|
const skills = new SkillService(connection.db, artifactRoot);
|
|
const orgs = connection.db.prepare("select id from organizations").all() as Array<{ id: string }>;
|
|
for (const org of orgs) {
|
|
const report = connection.transaction(() => importStagedSkills(skills, org.id, staged));
|
|
if (report.imported > 0 || report.upgraded > 0 || report.restored > 0 || report.retired > 0 || report.failures.length > 0) {
|
|
console.info(`[skills] org ${org.id}: imported ${report.imported}, upgraded ${report.upgraded}, restored ${report.restored}, skipped ${report.skipped}, retired ${report.retired}, failures ${report.failures.length}`);
|
|
for (const failure of report.failures.slice(0, 5)) console.warn(`[skills] ${failure}`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`[skills] built-in skill sync failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Repairs the founder⇄CEO conversation invariant for every organization at
|
|
* startup — orgs created before the pinned CEO chat existed get theirs here.
|
|
* A broken org is loud in the log but must not stop the app.
|
|
*/
|
|
function ensureAllCeoConversations(connection: DatabaseConnection): void {
|
|
const orgs = connection.db.prepare("select id from organizations").all() as Array<{ id: string }>;
|
|
for (const org of orgs) {
|
|
try {
|
|
connection.transaction(() => ensureCeoDirectSpace(connection.db, org.id));
|
|
} catch (error) {
|
|
console.error(`[chat] ensuring the CEO conversation for org ${org.id} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
}
|