This commit is contained in:
ZacharyZhang-NY committed 2026-04-17 14:03:56 -04:00
1 parent 806200b3d1
commit 5547c29491
216 files changed
+5043 -3352

No files matched your search

@@ -0,0 +1,95 @@
import type { AppDatabase } from "../database";
import { BrowserManager } from "../browser-manager";
import type { DesktopEvent } from "@shared/types";
import { createAutomationService } from "../automation-service";
import { createBrowserActionService } from "../browser-action-service";
import { createOnboardingService } from "../onboarding-service";
import { createUpdaterService } from "../updater-service";
import type { createOrchestrationService } from "../orchestration-service";
import type { createSkillService } from "../skill-service";
import type { NotificationOptions } from "../notification-service";
export interface BackgroundServicesDeps {
db: AppDatabase;
logger: {
debug: (...args: unknown[]) => void;
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
emitEvent: (event: DesktopEvent) => void;
publishDomainChanged: () => void;
notify: (options: NotificationOptions) => void;
isQuittingRef: { current: boolean };
browserManagerRef: { current: BrowserManager | null };
skillService: ReturnType<typeof createSkillService>;
orchestrationService: ReturnType<typeof createOrchestrationService>;
seedDefaultAutomationRules: (companyId: string) => void;
}
export interface BackgroundServices {
automationService: ReturnType<typeof createAutomationService>;
browserActionService: ReturnType<typeof createBrowserActionService>;
onboardingService: ReturnType<typeof createOnboardingService>;
updaterService: ReturnType<typeof createUpdaterService>;
}
/**
* Build background services that run off the main dispatch loop: automation
* triggers, browser-action dispatch, onboarding hire flow, and the updater.
* Orchestration is fully assembled at this point, so all dependencies are
* concrete references rather than lazy slots.
*/
export function createBackgroundServices({
db,
logger,
emitEvent,
publishDomainChanged,
notify,
isQuittingRef,
browserManagerRef,
skillService,
orchestrationService,
seedDefaultAutomationRules,
}: BackgroundServicesDeps): BackgroundServices {
const browserActionService = createBrowserActionService({
db,
browserManagerRef,
publishDomainChanged,
});
browserActionService.resetBrowserManager();
const dispatchBrowserAction = async (actionId: string, companyId: string) =>
browserActionService.dispatchBrowserAction(actionId, companyId);
const automationService = createAutomationService({
db,
logger,
emitEvent,
publishDomainChanged,
notify,
wakeAgentIfPossible: (agentId, companyId, trigger) =>
orchestrationService.wakeAgentIfPossible(agentId, companyId, trigger),
dispatchBrowserAction,
isQuitting: () => isQuittingRef.current,
});
const onboardingService = createOnboardingService({
db,
seedDefaultAutomationRules,
queueTaskRun: (taskId, trigger, actor) =>
orchestrationService.queueTaskRun(taskId, trigger, actor),
publishDomainChanged,
scanAndAssignSkills: async (companyId: string, _agentId: string) => {
// All company skills are visible to every agent via progressive disclosure.
// Seed the builtin catalog + any workspace-local SKILL.md bundles.
await skillService.seedBuiltinSkills(companyId);
await skillService.scanWorkspaceSkills(companyId);
},
});
const updaterService = createUpdaterService({ db, logger, notify });
updaterService.attachListeners();
return { automationService, browserActionService, onboardingService, updaterService };
}
@@ -0,0 +1,85 @@
import type { IpcMainInvokeEvent } from "electron";
import type { AppDatabase } from "../database";
import type { DesktopEvent } from "@shared/types";
import type { DesktopResult } from "@shared/contracts";
import { createDesktopShellService } from "../desktop-shell-service";
import type { NotificationOptions } from "../notification-service";
import type { LateBinding } from "./late-binding";
import type { createUpdaterService } from "../updater-service";
export interface DesktopShellDeps {
db: AppDatabase;
preloadPath: string;
rendererDist: string;
iconPath: string;
isDev: boolean;
devServerUrl?: string;
windowStatePath: string;
isQuittingRef: { current: boolean };
emitEvent: (event: DesktopEvent) => void;
notify: (options: NotificationOptions) => void;
registerHandle: (
channel: string,
handler: (event: IpcMainInvokeEvent, ...args: unknown[]) => Promise<DesktopResult<unknown>> | DesktopResult<unknown>,
) => void;
updaterRef: LateBinding<ReturnType<typeof createUpdaterService>>;
logger: { warn: (...args: unknown[]) => void };
}
/**
* Build the desktop shell service. `updaterRef` is late-bound because the
* updater is constructed after the shell; the menu's "Check for updates"
* handler resolves it at call time.
*/
export function createDesktopShell({
db,
preloadPath,
rendererDist,
iconPath,
isDev,
devServerUrl,
windowStatePath,
isQuittingRef,
emitEvent,
notify,
registerHandle,
updaterRef,
logger,
}: DesktopShellDeps) {
const desktopShellService = createDesktopShellService({
preloadPath,
rendererDist,
iconPath,
isDev,
devServerUrl,
windowStatePath,
registerHandle,
shouldQuit: () => isQuittingRef.current,
onRequestQuit: () => {
isQuittingRef.current = true;
},
onCheckForUpdates: async () => {
await updaterRef.get().checkForUpdates();
},
onBackupProfile: async () => {
const target = await import("electron").then(({ dialog }) => dialog.showOpenDialog({
properties: ["openDirectory", "createDirectory"],
}));
if (target.canceled || target.filePaths.length === 0) return;
await db.exportBackup(`${target.filePaths[0]!}/agentcompany-backup-${Date.now()}`);
},
onShortcut: (action) => emitEvent({ type: "shortcut", action }),
notify,
getActiveRunCount: () => {
try {
const snapshot = db.listSnapshot();
return snapshot.runs.filter((r) => r.status === "running" || r.status === "queued").length;
} catch (error) {
logger.warn("[service-graph] getActiveRunCount failed:", error);
return 0;
}
},
});
desktopShellService.initialize();
return desktopShellService;
}
@@ -0,0 +1,46 @@
import type { AppDatabase } from "../database";
import { createBudgetEnforcementService } from "../budget-enforcement-service";
import { createSessionManagementService } from "../session-management-service";
import { createSkillService } from "../skill-service";
import type { NotificationOptions } from "../notification-service";
export interface LeafServicesDeps {
db: AppDatabase;
logger: {
debug: (...args: unknown[]) => void;
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
notify: (options: NotificationOptions) => void;
publishDomainChanged: () => void;
}
export interface LeafServices {
skillService: ReturnType<typeof createSkillService>;
budgetEnforcementService: ReturnType<typeof createBudgetEnforcementService>;
sessionManagementService: ReturnType<typeof createSessionManagementService>;
}
/**
* Build leaf services that no other service depends on except through this
* bundle. Constructed first so downstream factories can receive concrete
* references without null-checks.
*/
export function createLeafServices({
db,
logger,
notify,
publishDomainChanged,
}: LeafServicesDeps): LeafServices {
const skillService = createSkillService({ db, profileDir: db.profileDir, logger });
const budgetEnforcementService = createBudgetEnforcementService({
db,
logger,
notify,
publishDomainChanged,
});
const sessionManagementService = createSessionManagementService({ db, logger });
return { skillService, budgetEnforcementService, sessionManagementService };
}
+145
View File
@@ -0,0 +1,145 @@
import { app } from "electron";
import type { AgentApiServer } from "../api-server";
import type { AppDatabase } from "../database";
import { BrowserManager } from "../browser-manager";
import { createAppLifecycleService } from "../app-lifecycle-service";
import type { createAutomationService } from "../automation-service";
import type { createDesktopShellService } from "../desktop-shell-service";
import type { createOrchestrationService } from "../orchestration-service";
import type { createWorkerService } from "../worker-service";
import type { PiRuntimeServices } from "../service-graph-pi-runtime";
import type { LateBinding } from "./late-binding";
/** Overall budget for awaited shutdown drain (worker exit + API close). */
const SHUTDOWN_BUDGET_MS = 5_000;
export interface LifecycleDeps {
db: AppDatabase;
logger: {
debug: (...args: unknown[]) => void;
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
isQuittingRef: { current: boolean };
apiServerRef: { current: AgentApiServer | null };
browserManagerRef: { current: BrowserManager | null };
piRuntimeRef: { current: PiRuntimeServices | null };
orchestrationService: ReturnType<typeof createOrchestrationService>;
automationService: ReturnType<typeof createAutomationService>;
workerService: ReturnType<typeof createWorkerService>;
desktopShellService: ReturnType<typeof createDesktopShellService>;
/**
* Clears graph-owned service refs (so getters return null) after the drain
* completes. Mirrors the original synchronous null-out at the tail of the
* old onBeforeQuit.
*/
clearServiceRefs: () => void;
/**
* Late bindings owned by the graph; cleared after drain so any stray
* post-quit access fails loudly rather than returning stale data.
*/
lateBindings: Array<LateBinding<unknown>>;
}
/**
* Build the app lifecycle service and wire a deterministic shutdown drain
* that awaits the utility-process worker's exit (and API server close)
* before calling app.exit(). On 5s timeout we log the stuck run ids and
* exit(1) so operators see an unclean shutdown rather than a silent
* force-kill.
*/
export function createLifecycle(deps: LifecycleDeps) {
const {
db,
logger,
isQuittingRef,
apiServerRef,
browserManagerRef,
piRuntimeRef,
orchestrationService,
automationService,
workerService,
desktopShellService,
clearServiceRefs,
lateBindings,
} = deps;
let drainPromise: Promise<number> | null = null;
function listActiveRunIds(): string[] {
try {
return db.listSnapshot().runs
.filter((r) => r.status === "running" || r.status === "queued")
.map((r) => r.id);
} catch {
return [];
}
}
async function drain(): Promise<number> {
// Stop accepting new scheduled work first; cancel in-flight runs in the
// worker via the utility-process shutdown postMessage inside destroy().
try { orchestrationService.stopAllSchedulers(); } catch (err) { logger.warn("[lifecycle] stopAllSchedulers failed:", err); }
try { automationService.destroy(); } catch (err) { logger.warn("[lifecycle] automationService.destroy failed:", err); }
try { apiServerRef.current?.stop(); } catch (err) { logger.warn("[lifecycle] apiServer.stop failed:", err); }
apiServerRef.current = null;
try { browserManagerRef.current?.shutdown(); } catch (err) { logger.warn("[lifecycle] browserManager.shutdown failed:", err); }
browserManagerRef.current = null;
// workerService.destroy() sends "shutdown" to the utility process and has
// its own 2s SIGKILL safety net. Awaiting the worker's exit event closes
// the remaining event-loop holder (the utilityProcess handle).
const workerExit = new Promise<void>((resolve) => {
const worker = workerService.getWorker();
if (!worker) { resolve(); return; }
worker.once("exit", () => resolve());
});
workerService.destroy();
const timeout = new Promise<"timeout">((resolve) => {
const handle = setTimeout(() => resolve("timeout"), SHUTDOWN_BUDGET_MS);
if (typeof handle.unref === "function") handle.unref();
});
const outcome = await Promise.race([
workerExit.then(() => "drained" as const),
timeout,
]);
try { desktopShellService.destroy(); } catch (err) { logger.warn("[lifecycle] desktopShell.destroy failed:", err); }
try { piRuntimeRef.current?.release(); } catch (err) { logger.warn("[lifecycle] piRuntime.release failed:", err); }
piRuntimeRef.current = null;
try { db.close(); } catch (err) { logger.warn("[lifecycle] db.close failed:", err); }
clearServiceRefs();
for (const binding of lateBindings) binding.clear();
if (outcome === "timeout") {
const stuck = listActiveRunIds();
logger.error(
`[lifecycle] Drain exceeded ${SHUTDOWN_BUDGET_MS}ms; stuck runs: ${stuck.length ? stuck.join(", ") : "<none>"}`,
);
return 1;
}
return 0;
}
const appLifecycleService = createAppLifecycleService({
getMainWindow: () => desktopShellService.getMainWindow(),
ensureWindow: async () => desktopShellService.ensureWindow(),
shouldQuit: () => isQuittingRef.current,
onBeforeQuit: () => {
if (drainPromise) return;
isQuittingRef.current = true;
drainPromise = drain().catch((err) => {
logger.error("[lifecycle] drain threw unexpectedly:", err);
return 1;
});
drainPromise.then((code) => {
app.exit(code);
});
},
});
return appLifecycleService;
}
@@ -0,0 +1,111 @@
import type { AppDatabase } from "../database";
import type { DesktopEvent } from "@shared/types";
import { createOrchestrationService } from "../orchestration-service";
import type { createBudgetEnforcementService } from "../budget-enforcement-service";
import type { createSessionManagementService } from "../session-management-service";
import type { createSkillService } from "../skill-service";
import type { createWorkerService } from "../worker-service";
import type { createAutomationService, AutomationEventContext } from "../automation-service";
import type { createBrowserActionService } from "../browser-action-service";
import type { NotificationOptions } from "../notification-service";
import type { LateBinding } from "./late-binding";
export interface OrchestrationServiceDeps {
db: AppDatabase;
logger: {
debug: (...args: unknown[]) => void;
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
publishDomainChanged: () => void;
emitEvent: (event: DesktopEvent) => void;
notify: (options: NotificationOptions) => void;
createRunToken: (agentId: string, companyId: string, runId: string) => string;
getApiPort: () => number;
isQuittingRef: { current: boolean };
skillService: ReturnType<typeof createSkillService>;
budgetService: ReturnType<typeof createBudgetEnforcementService>;
sessionService: ReturnType<typeof createSessionManagementService>;
workerService: ReturnType<typeof createWorkerService>;
automationRef: LateBinding<ReturnType<typeof createAutomationService>>;
browserActionRef: LateBinding<ReturnType<typeof createBrowserActionService>>;
checkpointEngine?: { shouldPauseHeartbeat: (companyId: string, agentId: string) => boolean };
}
/**
* Build the orchestration service with resolved dependencies. Late bindings
* for automation and browser-action are required because orchestration's
* dispatchAutomation closure is invoked long after assembly — the slots are
* filled in the same init() pass, before any scheduler tick.
*/
export function buildOrchestrationService({
db,
logger,
publishDomainChanged,
emitEvent,
notify,
createRunToken,
getApiPort,
isQuittingRef,
skillService,
budgetService,
sessionService,
workerService,
automationRef,
checkpointEngine,
}: OrchestrationServiceDeps) {
const dispatchAutomation = (trigger: string, ctx: AutomationEventContext) => {
automationRef.get().dispatchAutomation(trigger, ctx);
};
return createOrchestrationService({
db,
logger,
publishDomainChanged,
emitEvent,
notify,
createRunToken,
getApiPort,
dispatchAutomation,
hasWorkerReady: () => Boolean(workerService.getWorker()),
enqueueWorkerRun: (payload) => {
workerService.getWorker()?.postMessage({ type: "enqueue-run", payload });
},
isQuitting: () => isQuittingRef.current,
listRuntimeSkillEntries: (companyId, agentId, connectorId) => {
try {
return skillService.listRuntimeSkillEntries(companyId, agentId, connectorId);
} catch (error) {
logger.warn(`[service-graph] listRuntimeSkillEntries failed for agent ${agentId}:`, error);
return [];
}
},
budgetService,
checkpointEngine,
getSessionHandoff: (taskId) => {
try {
return sessionService.getHandoffContext(taskId);
} catch (error) {
logger.warn(`[service-graph] getSessionHandoff failed for task ${taskId}:`, error);
return null;
}
},
evaluatePreRunSessionRotation: (taskId, connectorId) => {
try {
const activeSession = db.getActiveTaskSession(taskId);
if (!activeSession) return { rotated: false, handoff: null };
const result = sessionService.evaluateRotation(activeSession, connectorId);
if (result.shouldRotate) {
sessionService.rotateSession(activeSession);
const handoff = sessionService.getHandoffContext(taskId);
return { rotated: true, handoff };
}
return { rotated: false, handoff: null };
} catch (error) {
logger.warn(`[service-graph] evaluatePreRunSessionRotation failed for task ${taskId}:`, error);
return { rotated: false, handoff: null };
}
},
});
}
@@ -0,0 +1,87 @@
import type { AppDatabase } from "../database";
import type { DesktopEvent, RunRecord } from "@shared/types";
import { createWorkerService } from "../worker-service";
import type { createOrchestrationService } from "../orchestration-service";
import type { createAutomationService } from "../automation-service";
import type { createBudgetEnforcementService } from "../budget-enforcement-service";
import type { createSessionManagementService } from "../session-management-service";
import type { PiRuntimeServices } from "../service-graph-pi-runtime";
import type { NotificationOptions } from "../notification-service";
import type { LateBinding } from "./late-binding";
export interface WorkerServiceDeps {
db: AppDatabase;
logger: {
debug: (...args: unknown[]) => void;
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
workerPath: string;
isQuittingRef: { current: boolean };
publishDomainChanged: () => void;
emitEvent: (event: DesktopEvent) => void;
orchestrationRef: LateBinding<ReturnType<typeof createOrchestrationService>>;
automationRef: LateBinding<ReturnType<typeof createAutomationService>>;
onRunStarted?: (runId: string, taskId: string, agentId: string, companyId: string) => void;
onRunCompleted?: (runId: string, taskId: string, agentId: string, companyId: string, status: string, summary: string | null, errorMessage: string | null) => void;
notify: (options: NotificationOptions) => void;
budgetService: ReturnType<typeof createBudgetEnforcementService>;
sessionService: ReturnType<typeof createSessionManagementService>;
piRuntimeRef: { current: PiRuntimeServices | null };
}
/**
* Build the worker service. Orchestration and automation are late-bound via
* LateBinding slots because they are constructed after the worker but close
* over the worker's dispatch functions.
*/
export function buildWorkerService(deps: WorkerServiceDeps) {
const {
db,
logger,
workerPath,
isQuittingRef,
publishDomainChanged,
emitEvent,
orchestrationRef,
automationRef,
onRunStarted,
onRunCompleted,
notify,
budgetService,
sessionService,
piRuntimeRef,
} = deps;
const wakeAgentIfPossible = (agentId: string | null | undefined, companyId: string, trigger: string) =>
orchestrationRef.get().wakeAgentIfPossible(agentId, companyId, trigger);
const dispatchRunToWorker: Parameters<typeof createWorkerService>[0]["dispatchRunToWorker"] = (params) =>
orchestrationRef.get().dispatchRunToWorker(params);
const processRunCompletion = (
run: RunRecord,
event: Parameters<ReturnType<typeof createAutomationService>["processRunCompletion"]>[1],
) => {
automationRef.get().processRunCompletion(run, event);
};
return createWorkerService({
db,
logger,
workerPath,
isQuitting: () => isQuittingRef.current,
publishDomainChanged,
emitEvent,
wakeAgentIfPossible,
dispatchRunToWorker,
processRunCompletion,
onRunStarted,
onRunCompleted,
notify,
budgetService,
sessionService,
getRuntimeBridge: () => piRuntimeRef.current?.runtimeBridge ?? null,
});
}
+33
View File
@@ -0,0 +1,33 @@
/**
* One-time settable slot for services that participate in a cycle during
* assembly. `set()` may be called exactly once; `get()` throws until the
* slot has been set. Eliminates the lazy-null `let` pattern that service-graph
* previously used to wire `workerService.dispatchRunToWorker` against
* `orchestrationService` before the latter exists.
*/
export class LateBinding<T> {
private value: T | null = null;
private assigned = false;
constructor(private readonly label: string) {}
set(value: T): void {
if (this.assigned) {
throw new Error(`LateBinding<${this.label}> is already assigned.`);
}
this.value = value;
this.assigned = true;
}
get(): T {
if (!this.assigned || this.value === null) {
throw new Error(`LateBinding<${this.label}> accessed before assignment.`);
}
return this.value;
}
clear(): void {
this.value = null;
this.assigned = false;
}
}