update
This commit is contained in:
1 parent
7a3db27ffa
commit
1ba3147ed2
96 files changed
+9400
-6269
No files matched your search
+35
-3
@@ -1,6 +1,8 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const SIGNING_KEY = randomBytes(32);
|
||||
let signingKey: Buffer | null = null;
|
||||
const TOKEN_TTL_MS = 48 * 60 * 60 * 1000; // 48 hours
|
||||
|
||||
interface TokenPayload {
|
||||
@@ -12,20 +14,22 @@ interface TokenPayload {
|
||||
}
|
||||
|
||||
function sign(payload: TokenPayload): string {
|
||||
const key = getSigningKey();
|
||||
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
const signature = createHmac("sha256", SIGNING_KEY)
|
||||
const signature = createHmac("sha256", key)
|
||||
.update(`${header}.${body}`)
|
||||
.digest("base64url");
|
||||
return `${header}.${body}.${signature}`;
|
||||
}
|
||||
|
||||
function verify(token: string): TokenPayload | null {
|
||||
const key = getSigningKey();
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
const [header, body, signature] = parts as [string, string, string];
|
||||
|
||||
const expectedSignature = createHmac("sha256", SIGNING_KEY)
|
||||
const expectedSignature = createHmac("sha256", key)
|
||||
.update(`${header}.${body}`)
|
||||
.digest("base64url");
|
||||
|
||||
@@ -44,6 +48,34 @@ function verify(token: string): TokenPayload | null {
|
||||
}
|
||||
}
|
||||
|
||||
function getSigningKey() {
|
||||
if (!signingKey) {
|
||||
throw new Error("Agent token signing key was used before initialization.");
|
||||
}
|
||||
return signingKey;
|
||||
}
|
||||
|
||||
export function initializeAgentTokenSigningKey(profileDir: string) {
|
||||
const keyPath = join(profileDir, "agent-api-signing.key");
|
||||
mkdirSync(profileDir, { recursive: true });
|
||||
try {
|
||||
const nextKey = randomBytes(32);
|
||||
writeFileSync(keyPath, nextKey, { flag: "wx", mode: 0o600 });
|
||||
signingKey = nextKey;
|
||||
return;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== "EEXIST") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const persistedKey = readFileSync(keyPath);
|
||||
if (persistedKey.length !== 32) {
|
||||
throw new Error(`Invalid agent API signing key length at ${keyPath}. Expected 32 bytes, received ${persistedKey.length}.`);
|
||||
}
|
||||
signingKey = persistedKey;
|
||||
}
|
||||
|
||||
export function createAgentToken(agentId: string, companyId: string, runId: string): string {
|
||||
const now = Date.now();
|
||||
return sign({ agentId, companyId, runId, iat: now, exp: now + TOKEN_TTL_MS });
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { AppDatabase } from "./database";
|
||||
import type { TaskRecord } from "@shared/types";
|
||||
|
||||
function safeGetAgent(db: AppDatabase, agentId: string) {
|
||||
try {
|
||||
return db.getAgent(agentId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function managesAgent(db: AppDatabase, managerId: string, targetAgentId: string): boolean {
|
||||
if (managerId === targetAgentId) {
|
||||
return true;
|
||||
}
|
||||
return db.getChainOfCommand(targetAgentId).some((agent) => agent.id === managerId);
|
||||
}
|
||||
|
||||
export function canManageOrganization(db: AppDatabase, companyId: string, agentId: string): boolean {
|
||||
const agent = safeGetAgent(db, agentId);
|
||||
if (!agent || agent.companyId !== companyId) {
|
||||
return false;
|
||||
}
|
||||
if (!agent.reportsTo) {
|
||||
return true;
|
||||
}
|
||||
return db.getDirectReports(agentId, companyId).length > 0 || agent.department === "hr";
|
||||
}
|
||||
|
||||
export function canAssignTask(db: AppDatabase, companyId: string, actorAgentId: string, targetAgentId: string | null): boolean {
|
||||
if (!targetAgentId || targetAgentId === actorAgentId) {
|
||||
return true;
|
||||
}
|
||||
if (!db.belongsToCompany("agents", targetAgentId, companyId)) {
|
||||
return false;
|
||||
}
|
||||
if (canManageOrganization(db, companyId, actorAgentId)) {
|
||||
return true;
|
||||
}
|
||||
return managesAgent(db, actorAgentId, targetAgentId);
|
||||
}
|
||||
|
||||
export function canUpdateTask(db: AppDatabase, companyId: string, actorAgentId: string, task: TaskRecord): boolean {
|
||||
if (task.companyId !== companyId) {
|
||||
return false;
|
||||
}
|
||||
if (task.assigneeAgentId === actorAgentId) {
|
||||
return true;
|
||||
}
|
||||
if (!task.assigneeAgentId) {
|
||||
return canManageOrganization(db, companyId, actorAgentId);
|
||||
}
|
||||
if (canManageOrganization(db, companyId, actorAgentId)) {
|
||||
return true;
|
||||
}
|
||||
return managesAgent(db, actorAgentId, task.assigneeAgentId);
|
||||
}
|
||||
|
||||
export function canManageGoal(db: AppDatabase, companyId: string, actorAgentId: string, ownerAgentId: string | null): boolean {
|
||||
if (canManageOrganization(db, companyId, actorAgentId)) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(ownerAgentId && ownerAgentId === actorAgentId);
|
||||
}
|
||||
|
||||
export function canManageProject(db: AppDatabase, companyId: string, actorAgentId: string, leadAgentId: string | null): boolean {
|
||||
if (canManageOrganization(db, companyId, actorAgentId)) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(leadAgentId && leadAgentId === actorAgentId);
|
||||
}
|
||||
+104
-54
@@ -2,6 +2,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht
|
||||
import log from "electron-log/main.js";
|
||||
import { z } from "zod";
|
||||
import { validateAgentToken, type TokenPayload } from "./agent-auth";
|
||||
import { canAssignTask, canManageGoal, canManageOrganization, canManageProject, canUpdateTask } from "./agent-permissions";
|
||||
import type { AppDatabase } from "./database";
|
||||
import { agentInputSchema, approvalInputSchema, goalInputSchema, projectInputSchema, taskInputSchema } from "@shared/contracts";
|
||||
|
||||
@@ -58,12 +59,20 @@ export class AgentApiServer {
|
||||
private onDomainChanged: () => void;
|
||||
private onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null;
|
||||
private callbacks: ApiServerEventCallbacks;
|
||||
private allowedOrigins: Set<string>;
|
||||
private onBrowserAction: ((actionId: string, companyId: string, socialAccountId: string, actionType: string, payload: Record<string, unknown>) => void) | null = null;
|
||||
|
||||
constructor(db: AppDatabase, onDomainChanged: () => void, onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null, callbacks?: Partial<ApiServerEventCallbacks>) {
|
||||
constructor(
|
||||
db: AppDatabase,
|
||||
onDomainChanged: () => void,
|
||||
onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null,
|
||||
callbacks?: Partial<ApiServerEventCallbacks>,
|
||||
allowedOrigins: Iterable<string> = ["app://-"],
|
||||
) {
|
||||
this.db = db;
|
||||
this.onDomainChanged = onDomainChanged;
|
||||
this.onWakeAgent = onWakeAgent;
|
||||
this.allowedOrigins = new Set(Array.from(allowedOrigins).filter(Boolean));
|
||||
this.callbacks = {
|
||||
onDomainChanged,
|
||||
onWakeAgent,
|
||||
@@ -115,11 +124,7 @@ export class AgentApiServer {
|
||||
private async handleRequest(req: IncomingMessage, res: ServerResponse) {
|
||||
const origin = req.headers.origin;
|
||||
if (origin) {
|
||||
const allowedOrigin =
|
||||
origin === "app://-" ||
|
||||
origin.startsWith("http://127.0.0.1:") ||
|
||||
origin.startsWith("http://localhost:");
|
||||
if (!allowedOrigin) {
|
||||
if (!this.allowedOrigins.has(origin)) {
|
||||
this.json(res, 403, { error: "Origin not allowed" });
|
||||
return;
|
||||
}
|
||||
@@ -150,11 +155,24 @@ export class AgentApiServer {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.db.belongsToCompany("runs", token.runId, token.companyId)) {
|
||||
const runHeader = req.headers["x-agent-company-run-id"];
|
||||
const claimedRunId = Array.isArray(runHeader) ? runHeader[0] : runHeader;
|
||||
if (typeof claimedRunId !== "string" || claimedRunId.trim() !== token.runId) {
|
||||
this.json(res, 401, { error: "Missing or mismatched X-Agent-Company-Run-Id header" });
|
||||
return;
|
||||
}
|
||||
|
||||
const runState = this.db.getRunAuthState(token.runId);
|
||||
if (!runState || runState.companyId !== token.companyId || runState.agentId !== token.agentId) {
|
||||
this.json(res, 403, { error: "Token references an unknown or mismatched run" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!["queued", "running"].includes(runState.status)) {
|
||||
this.json(res, 401, { error: "Run token is no longer active" });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const route of this.routes) {
|
||||
if (route.method !== method) continue;
|
||||
const match = pathname.match(route.pattern);
|
||||
@@ -294,17 +312,19 @@ export class AgentApiServer {
|
||||
this.json(res, 404, { error: "Task not found" });
|
||||
return;
|
||||
}
|
||||
if (task.assigneeAgentId && !canUpdateTask(this.db, token.companyId, token.agentId, task)) {
|
||||
this.json(res, 403, { error: "Current agent is not allowed to checkout this task" });
|
||||
return;
|
||||
}
|
||||
const previousStatus = task.status;
|
||||
const success = this.db.checkoutTask(params.issueId!, agentId);
|
||||
if (!success) {
|
||||
this.json(res, 409, { error: "Task cannot be checked out — already assigned or not in valid status" });
|
||||
return;
|
||||
}
|
||||
this.onDomainChanged();
|
||||
// Checkout changes status from todo/backlog to in_progress
|
||||
const checkedOutTask = (() => { try { return this.db.getTask(params.issueId!); } catch { return null; } })();
|
||||
if (checkedOutTask) {
|
||||
this.callbacks.onTaskStatusChanged(checkedOutTask.id, checkedOutTask.companyId, "todo", "in_progress");
|
||||
}
|
||||
// Checkout changes status from todo/backlog to in_progress — use actual previous status
|
||||
this.callbacks.onTaskStatusChanged(task.id, task.companyId, previousStatus, "in_progress");
|
||||
this.json(res, 200, { ok: true });
|
||||
});
|
||||
|
||||
@@ -322,8 +342,16 @@ export class AgentApiServer {
|
||||
this.json(res, 404, { error: "Task not found" });
|
||||
return;
|
||||
}
|
||||
if (!canUpdateTask(this.db, token.companyId, token.agentId, task)) {
|
||||
this.json(res, 403, { error: "Current agent is not allowed to update this task" });
|
||||
return;
|
||||
}
|
||||
|
||||
const newAssignee = update.assigneeAgentId !== undefined ? update.assigneeAgentId : task.assigneeAgentId;
|
||||
if (!canAssignTask(this.db, token.companyId, token.agentId, newAssignee ?? null)) {
|
||||
this.json(res, 403, { error: "Current agent is not allowed to assign this task to the selected agent" });
|
||||
return;
|
||||
}
|
||||
const input: Record<string, unknown> = {
|
||||
id: task.id,
|
||||
companyId: task.companyId,
|
||||
@@ -450,6 +478,10 @@ export class AgentApiServer {
|
||||
])) {
|
||||
return;
|
||||
}
|
||||
if (!canAssignTask(this.db, companyId, token.agentId, input.assigneeAgentId ?? null)) {
|
||||
this.json(res, 403, { error: "Current agent is not allowed to assign this task to the selected agent" });
|
||||
return;
|
||||
}
|
||||
|
||||
let id: string;
|
||||
try {
|
||||
@@ -521,6 +553,10 @@ export class AgentApiServer {
|
||||
])) {
|
||||
return;
|
||||
}
|
||||
if (!canManageProject(this.db, companyId, token.agentId, input.leadAgentId ?? null)) {
|
||||
this.json(res, 403, { error: "Current agent is not allowed to create or assign this project" });
|
||||
return;
|
||||
}
|
||||
|
||||
const id = this.db.saveProject({
|
||||
companyId,
|
||||
@@ -572,6 +608,11 @@ export class AgentApiServer {
|
||||
this.json(res, 404, { error: "Project not found" });
|
||||
return;
|
||||
}
|
||||
const nextLeadAgentId = parsed.data.leadAgentId ?? existing.leadAgentId ?? null;
|
||||
if (!canManageProject(this.db, token.companyId, token.agentId, nextLeadAgentId)) {
|
||||
this.json(res, 403, { error: "Current agent is not allowed to update this project" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.db.saveProject({
|
||||
@@ -580,7 +621,7 @@ export class AgentApiServer {
|
||||
name: parsed.data.name ?? existing.name,
|
||||
description: parsed.data.description ?? existing.description,
|
||||
status: parsed.data.status ?? existing.status,
|
||||
leadAgentId: parsed.data.leadAgentId ?? existing.leadAgentId ?? null,
|
||||
leadAgentId: nextLeadAgentId,
|
||||
goalId: parsed.data.goalId !== undefined ? parsed.data.goalId : (existing.goalId ?? null),
|
||||
targetDate: parsed.data.targetDate !== undefined ? parsed.data.targetDate : (existing.targetDate ?? null),
|
||||
});
|
||||
@@ -612,6 +653,10 @@ export class AgentApiServer {
|
||||
])) {
|
||||
return;
|
||||
}
|
||||
if (!canManageGoal(this.db, companyId, token.agentId, input.ownerAgentId ?? null)) {
|
||||
this.json(res, 403, { error: "Current agent is not allowed to create this goal" });
|
||||
return;
|
||||
}
|
||||
|
||||
const id = this.db.saveGoal(input);
|
||||
this.db.addActivity({
|
||||
@@ -651,6 +696,11 @@ export class AgentApiServer {
|
||||
this.json(res, 404, { error: "Goal not found" });
|
||||
return;
|
||||
}
|
||||
const nextOwnerAgentId = parsed.data.ownerAgentId ?? existing.ownerAgentId ?? null;
|
||||
if (!canManageGoal(this.db, token.companyId, token.agentId, nextOwnerAgentId)) {
|
||||
this.json(res, 403, { error: "Current agent is not allowed to update this goal" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.db.saveGoal({
|
||||
@@ -659,7 +709,7 @@ export class AgentApiServer {
|
||||
title: parsed.data.title ?? existing.title,
|
||||
description: parsed.data.description ?? existing.description,
|
||||
status: parsed.data.status ?? existing.status,
|
||||
ownerAgentId: parsed.data.ownerAgentId ?? existing.ownerAgentId ?? null,
|
||||
ownerAgentId: nextOwnerAgentId,
|
||||
});
|
||||
this.onDomainChanged();
|
||||
this.json(res, 200, { ok: true });
|
||||
@@ -674,6 +724,10 @@ export class AgentApiServer {
|
||||
this.json(res, 403, { error: "Company mismatch" });
|
||||
return;
|
||||
}
|
||||
if (!canManageOrganization(this.db, companyId, token.agentId)) {
|
||||
this.json(res, 403, { error: "Current agent is not allowed to request hires" });
|
||||
return;
|
||||
}
|
||||
const parsed = agentInputSchema.safeParse({
|
||||
companyId,
|
||||
status: "pending_approval",
|
||||
@@ -748,6 +802,24 @@ export class AgentApiServer {
|
||||
entityId: agentId,
|
||||
detail: input.name,
|
||||
});
|
||||
|
||||
// Auto-approve hire if company policy allows it
|
||||
const snapshot = this.db.listSnapshot();
|
||||
const company = snapshot.companies.find((c) => c.id === companyId);
|
||||
if (company?.autoApproveHires) {
|
||||
this.db.decideApproval({ companyId, approvalId, state: "approved", decisionNote: "Auto-approved by company policy" });
|
||||
this.callbacks.onApprovalResolved(approvalId, companyId, "approved");
|
||||
this.db.addActivity({
|
||||
companyId,
|
||||
actor: "board",
|
||||
action: "approval.approved",
|
||||
entityType: "approval",
|
||||
entityId: approvalId,
|
||||
detail: `Auto-approved hire: ${input.name}`,
|
||||
});
|
||||
log.info(`[auto-approve] Auto-approved hire of ${input.name} via agent API`);
|
||||
}
|
||||
|
||||
this.onDomainChanged();
|
||||
this.json(res, 201, { agentId, approvalId });
|
||||
});
|
||||
@@ -912,46 +984,8 @@ export class AgentApiServer {
|
||||
this.json(res, 200, approval);
|
||||
});
|
||||
|
||||
this.addRoute("PATCH", "/api/approvals/:approvalId", (_req, res, params, token, body) => {
|
||||
const approvalId = params.approvalId!;
|
||||
|
||||
const decisionSchema = z.object({
|
||||
state: z.enum(["approved", "rejected", "revision_requested"]),
|
||||
decisionNote: z.string().max(400).default(""),
|
||||
});
|
||||
|
||||
const parsed = decisionSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
this.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the approval exists and belongs to this company
|
||||
const snapshot = this.db.listSnapshot();
|
||||
const approval = snapshot.approvals.find(a => a.id === approvalId && a.companyId === token.companyId);
|
||||
if (!approval) {
|
||||
this.json(res, 404, { error: "Approval not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (approval.state !== "pending" && approval.state !== "revision_requested") {
|
||||
this.json(res, 409, { error: "Approval already decided" });
|
||||
return;
|
||||
}
|
||||
|
||||
this.db.decideApproval({
|
||||
companyId: token.companyId,
|
||||
approvalId,
|
||||
state: parsed.data.state,
|
||||
decisionNote: parsed.data.decisionNote,
|
||||
});
|
||||
|
||||
this.onDomainChanged();
|
||||
|
||||
// Fire the full approval resolved event chain (automation rules, hire activation, etc.)
|
||||
this.callbacks.onApprovalResolved(approvalId, token.companyId, parsed.data.state);
|
||||
|
||||
this.json(res, 200, { ok: true, state: parsed.data.state });
|
||||
this.addRoute("PATCH", "/api/approvals/:approvalId", (_req, res) => {
|
||||
this.json(res, 403, { error: "Approvals must be decided from the desktop board, not from an agent run token" });
|
||||
});
|
||||
|
||||
this.addRoute("GET", "/api/companies/:companyId/agents", (_req, res, params, token) => {
|
||||
@@ -1556,6 +1590,22 @@ export class AgentApiServer {
|
||||
}
|
||||
});
|
||||
|
||||
this.addRoute("GET", "/api/agents/:agentId/metrics", (_req, res, params, token) => {
|
||||
const snapshot = this.db.listSnapshot();
|
||||
const agent = snapshot.agents.find((entry) => entry.id === params.agentId && entry.companyId === token.companyId);
|
||||
if (!agent) {
|
||||
this.json(res, 404, { error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
const companyMetrics = this.db.getCompanyMetrics(token.companyId, 30);
|
||||
const agentMetric = companyMetrics.agentMetrics.find((metric) => metric.agentId === params.agentId);
|
||||
if (!agentMetric) {
|
||||
this.json(res, 404, { error: "Metrics not found" });
|
||||
return;
|
||||
}
|
||||
this.json(res, 200, agentMetric);
|
||||
});
|
||||
|
||||
this.addRoute("GET", "/api/agents/me/metrics", (_req, res, _params, token) => {
|
||||
const companyMetrics = this.db.getCompanyMetrics(token.companyId, 30);
|
||||
const agentMetric = companyMetrics.agentMetrics.find(m => m.agentId === token.agentId);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { app } from "electron";
|
||||
|
||||
interface AppLifecycleDependencies {
|
||||
getMainWindow: () => Electron.BrowserWindow | null;
|
||||
ensureWindow: () => Promise<Electron.BrowserWindow | null>;
|
||||
shouldQuit: () => boolean;
|
||||
onBeforeQuit: () => void;
|
||||
}
|
||||
|
||||
export function createAppLifecycleService({
|
||||
getMainWindow,
|
||||
ensureWindow,
|
||||
shouldQuit: _shouldQuit,
|
||||
onBeforeQuit,
|
||||
}: AppLifecycleDependencies) {
|
||||
function register(hasSingleInstanceLock: boolean) {
|
||||
app.on("activate", async () => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
if (!mainWindow.isVisible()) {
|
||||
mainWindow.show();
|
||||
}
|
||||
mainWindow.focus();
|
||||
return;
|
||||
}
|
||||
await ensureWindow();
|
||||
});
|
||||
|
||||
app.on("second-instance", () => {
|
||||
if (!hasSingleInstanceLock) {
|
||||
return;
|
||||
}
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) {
|
||||
return;
|
||||
}
|
||||
if (!mainWindow.isVisible()) {
|
||||
mainWindow.show();
|
||||
}
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore();
|
||||
}
|
||||
mainWindow.focus();
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
// On macOS, keep the app running when all windows are closed (standard behavior).
|
||||
// On Windows and Linux, quit the app.
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
onBeforeQuit();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
register,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,208 @@
|
||||
import { app } from "electron";
|
||||
import log from "electron-log/main.js";
|
||||
import type { AgentApiServer } from "./api-server";
|
||||
import type { AppDatabase } from "./database";
|
||||
import type { BrowserManager } from "./browser-manager";
|
||||
import { prepareHostEnvironment } from "./host-environment-service";
|
||||
import { createServiceGraph } from "./service-graph";
|
||||
import { createIpcSupport } from "./ipc-support";
|
||||
import type { DesktopEvent } from "@shared/types";
|
||||
|
||||
interface BootstrapApplicationDependencies {
|
||||
hasSingleInstanceLock: boolean;
|
||||
isDev: boolean;
|
||||
devServerOrigin: string | null;
|
||||
profileDir: string;
|
||||
appDatabaseFactory: (profileDir: string) => AppDatabase;
|
||||
initSigningKey: (profileDir: string) => void;
|
||||
windowStatePath: string;
|
||||
preloadPath: string;
|
||||
rendererDist: string;
|
||||
iconPath: string;
|
||||
devServerUrl?: string;
|
||||
workerPath: string;
|
||||
createApiServer: (db: AppDatabase, callbacks: {
|
||||
onDomainChanged: () => void;
|
||||
onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null;
|
||||
onTaskCreated: (taskId: string, companyId: string) => void;
|
||||
onTaskStatusChanged: (taskId: string, companyId: string, prev: string, next: string) => void;
|
||||
onCommentPosted: (taskId: string, companyId: string, authorAgentId: string | null) => void;
|
||||
onApprovalResolved: (approvalId: string, companyId: string, decision: string) => void;
|
||||
onDocumentCreated: (companyId: string, authorAgentId: string | null, projectId: string | null) => void;
|
||||
onAutoAssignTask: (taskId: string, companyId: string) => string | null;
|
||||
}, allowedOrigins: string[]) => AgentApiServer;
|
||||
createRunToken: (agentId: string, companyId: string, runId: string) => string;
|
||||
seedDefaultAutomationRules: (companyId: string) => void;
|
||||
isQuittingRef: { current: boolean };
|
||||
onRuntimeReady?: (state: {
|
||||
db: AppDatabase;
|
||||
apiServer: AgentApiServer;
|
||||
serviceGraph: ReturnType<typeof createServiceGraph>;
|
||||
ipcSupport: ReturnType<typeof createIpcSupport>;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export async function bootstrapApplication({
|
||||
hasSingleInstanceLock,
|
||||
isDev,
|
||||
devServerOrigin,
|
||||
profileDir,
|
||||
appDatabaseFactory,
|
||||
initSigningKey,
|
||||
windowStatePath,
|
||||
preloadPath,
|
||||
rendererDist,
|
||||
iconPath,
|
||||
devServerUrl,
|
||||
workerPath,
|
||||
createApiServer,
|
||||
createRunToken,
|
||||
seedDefaultAutomationRules,
|
||||
isQuittingRef,
|
||||
onRuntimeReady,
|
||||
}: BootstrapApplicationDependencies) {
|
||||
await app.whenReady();
|
||||
log.initialize();
|
||||
await prepareHostEnvironment({ iconPath, logger: log });
|
||||
|
||||
const db = appDatabaseFactory(profileDir);
|
||||
db.init();
|
||||
initSigningKey(db.profileDir);
|
||||
|
||||
const apiServerRef = { current: null as AgentApiServer | null };
|
||||
const browserManagerRef = { current: null as BrowserManager | null };
|
||||
let ipcSupport: ReturnType<typeof createIpcSupport> | null = null;
|
||||
const notify = (title: string, body: string) => {
|
||||
if (!ipcSupport) {
|
||||
throw new Error("IPC support is not initialized.");
|
||||
}
|
||||
ipcSupport.notify(title, body);
|
||||
};
|
||||
const emitEvent = (event: DesktopEvent) => {
|
||||
ipcSupport?.emitEvent(event);
|
||||
};
|
||||
const publishDomainChanged = () => {
|
||||
ipcSupport?.publishDomainChanged();
|
||||
};
|
||||
|
||||
const serviceGraph = createServiceGraph({
|
||||
db,
|
||||
apiServerRef,
|
||||
browserManagerRef,
|
||||
isQuittingRef,
|
||||
preloadPath,
|
||||
rendererDist,
|
||||
iconPath,
|
||||
isDev,
|
||||
devServerUrl,
|
||||
windowStatePath,
|
||||
workerPath,
|
||||
logger: log,
|
||||
emitEvent,
|
||||
publishDomainChanged,
|
||||
notify,
|
||||
createRunToken,
|
||||
seedDefaultAutomationRules,
|
||||
});
|
||||
ipcSupport = createIpcSupport({
|
||||
isDev,
|
||||
devServerOrigin,
|
||||
logger: log,
|
||||
getMainWindow: () => serviceGraph.getDesktopShellService()?.getMainWindow() ?? null,
|
||||
getDb: () => ({
|
||||
hasCompany: (companyId: string) => db.hasCompany(companyId),
|
||||
belongsToCompany: (table: string, id: string, companyId: string) => db.belongsToCompany(table as any, id, companyId),
|
||||
}),
|
||||
});
|
||||
serviceGraph.init();
|
||||
|
||||
const wakeAgentIfPossible = (agentId: string | null | undefined, companyId: string, trigger: string) => {
|
||||
const orchestration = serviceGraph.getOrchestrationService();
|
||||
if (!orchestration) throw new Error("Orchestration service is not initialized.");
|
||||
return orchestration.wakeAgentIfPossible(agentId, companyId, trigger);
|
||||
};
|
||||
const handleTaskCreated = (taskId: string, companyId: string) => {
|
||||
const automation = serviceGraph.getAutomationService();
|
||||
if (!automation) throw new Error("Automation service is not initialized.");
|
||||
automation.handleTaskCreated(taskId, companyId);
|
||||
};
|
||||
const handleTaskStatusChange = (taskId: string, companyId: string, prev: string, next: string) => {
|
||||
const automation = serviceGraph.getAutomationService();
|
||||
if (!automation) throw new Error("Automation service is not initialized.");
|
||||
automation.handleTaskStatusChange(taskId, companyId, prev, next);
|
||||
};
|
||||
const handleApprovalResolved = (approvalId: string, companyId: string, decision: string) => {
|
||||
const automation = serviceGraph.getAutomationService();
|
||||
if (!automation) throw new Error("Automation service is not initialized.");
|
||||
automation.handleApprovalResolved(approvalId, companyId, decision);
|
||||
};
|
||||
const dispatchAutomation = (trigger: string, ctx: Record<string, unknown>) => {
|
||||
const automation = serviceGraph.getAutomationService();
|
||||
if (!automation) throw new Error("Automation service is not initialized.");
|
||||
automation.dispatchAutomation(trigger, ctx as any);
|
||||
};
|
||||
|
||||
const apiServer = createApiServer(db, {
|
||||
onDomainChanged: publishDomainChanged,
|
||||
onWakeAgent: (agentId, companyId, trigger) => wakeAgentIfPossible(agentId, companyId, trigger),
|
||||
onTaskCreated: handleTaskCreated,
|
||||
onTaskStatusChanged: handleTaskStatusChange,
|
||||
onCommentPosted: (taskId, companyId, authorAgentId) => {
|
||||
dispatchAutomation("comment_posted", { companyId, taskId, agentId: authorAgentId ?? undefined });
|
||||
},
|
||||
onApprovalResolved: handleApprovalResolved,
|
||||
onDocumentCreated: (companyId, authorAgentId, projectId) => {
|
||||
dispatchAutomation("document_created", { companyId, agentId: authorAgentId ?? undefined, projectId: projectId ?? undefined });
|
||||
},
|
||||
onAutoAssignTask: (taskId, companyId) => db.autoAssignTask(taskId, companyId),
|
||||
}, [isDev && devServerUrl ? new URL(devServerUrl).origin : null, "app://-"].filter((origin): origin is string => Boolean(origin)));
|
||||
|
||||
apiServerRef.current = apiServer;
|
||||
await apiServer.start();
|
||||
onRuntimeReady?.({
|
||||
db,
|
||||
apiServer,
|
||||
serviceGraph,
|
||||
ipcSupport,
|
||||
});
|
||||
|
||||
if (apiServer && serviceGraph.getBrowserActionService()) {
|
||||
apiServer.setBrowserActionHandler(async (actionId, companyId) => {
|
||||
const browserActions = serviceGraph.getBrowserActionService();
|
||||
if (!browserActions) throw new Error("Browser action service is not initialized");
|
||||
await browserActions.dispatchBrowserAction(actionId, companyId);
|
||||
});
|
||||
}
|
||||
|
||||
// Show the window immediately so the user sees the app launch without delay.
|
||||
await serviceGraph.getDesktopShellService()?.ensureWindow();
|
||||
serviceGraph.getAppLifecycleService()?.register(hasSingleInstanceLock);
|
||||
|
||||
// Run connector health checks AFTER the window is visible.
|
||||
// This ensures connectors have a known status before any agent run is attempted,
|
||||
// but doesn't block the startup experience.
|
||||
const connectorHealth = serviceGraph.getConnectorHealthService();
|
||||
if (connectorHealth) {
|
||||
const connectors = db.listSnapshot().connectors;
|
||||
const healthCheckResults = await Promise.allSettled(
|
||||
connectors.map((connector) => connectorHealth.checkConnector(connector.id)),
|
||||
);
|
||||
for (let i = 0; i < healthCheckResults.length; i++) {
|
||||
const result = healthCheckResults[i]!;
|
||||
if (result.status === "rejected") {
|
||||
log.warn(`[bootstrap] Connector health check failed for ${connectors[i]?.id}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`);
|
||||
}
|
||||
}
|
||||
log.info(`[bootstrap] Connector health checks completed (${healthCheckResults.filter((r) => r.status === "fulfilled").length}/${connectors.length} succeeded)`);
|
||||
}
|
||||
|
||||
serviceGraph.getOrchestrationService()?.startHeartbeatScheduler();
|
||||
serviceGraph.getOrchestrationService()?.resumeQueuedRuns();
|
||||
|
||||
return {
|
||||
db,
|
||||
apiServer,
|
||||
serviceGraph,
|
||||
ipcSupport,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { join } from "node:path";
|
||||
import type { AppDatabase } from "./database";
|
||||
import { BrowserManager } from "./browser-manager";
|
||||
|
||||
interface BrowserActionDependencies {
|
||||
db: AppDatabase;
|
||||
browserManagerRef: { current: BrowserManager | null };
|
||||
publishDomainChanged: () => void;
|
||||
}
|
||||
|
||||
export function createBrowserActionService({
|
||||
db,
|
||||
browserManagerRef,
|
||||
publishDomainChanged,
|
||||
}: BrowserActionDependencies) {
|
||||
function requireBrowserManager() {
|
||||
if (!browserManagerRef.current) {
|
||||
throw new Error("Browser manager is not initialized.");
|
||||
}
|
||||
return browserManagerRef.current;
|
||||
}
|
||||
|
||||
async function dispatchBrowserAction(actionId: string, companyId: string) {
|
||||
const action = db.getBrowserAction(actionId, companyId);
|
||||
if (!action) {
|
||||
throw new Error(`Browser action not found: ${actionId}`);
|
||||
}
|
||||
|
||||
const account = db.getSocialAccount(action.socialAccountId, companyId);
|
||||
if (!account) {
|
||||
db.updateBrowserAction(actionId, {
|
||||
status: "failed",
|
||||
errorMessage: "Social account no longer exists.",
|
||||
finishedAt: new Date().toISOString(),
|
||||
});
|
||||
publishDomainChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
payload = JSON.parse(action.payloadJson) as Record<string, unknown>;
|
||||
} catch {
|
||||
db.updateBrowserAction(actionId, {
|
||||
status: "failed",
|
||||
errorMessage: "Browser action payload is not valid JSON.",
|
||||
finishedAt: new Date().toISOString(),
|
||||
});
|
||||
publishDomainChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
db.updateBrowserAction(actionId, {
|
||||
status: "running",
|
||||
startedAt: new Date().toISOString(),
|
||||
errorMessage: null,
|
||||
});
|
||||
publishDomainChanged();
|
||||
|
||||
try {
|
||||
const result = await requireBrowserManager().executeAction({
|
||||
id: actionId,
|
||||
socialAccountId: account.id,
|
||||
platform: account.platform,
|
||||
actionType: action.actionType,
|
||||
payload,
|
||||
});
|
||||
|
||||
db.updateBrowserAction(actionId, {
|
||||
status: result.cancelled ? "cancelled" : result.success ? "succeeded" : "failed",
|
||||
resultSummary: result.summary,
|
||||
screenshotPath: result.screenshotPath,
|
||||
errorMessage: result.error,
|
||||
finishedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
db.updateBrowserAction(actionId, {
|
||||
status: "failed",
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
finishedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
publishDomainChanged();
|
||||
}
|
||||
|
||||
async function clearSocialAccountData(socialAccountId: string, screenshotPaths: string[] = []) {
|
||||
if (!browserManagerRef.current) {
|
||||
return;
|
||||
}
|
||||
await browserManagerRef.current.clearSocialAccountData(socialAccountId, screenshotPaths);
|
||||
}
|
||||
|
||||
function cancelAction(actionId: string) {
|
||||
return browserManagerRef.current?.cancelAction(actionId) ?? false;
|
||||
}
|
||||
|
||||
async function launchLoginBrowser(socialAccountId: string, platform: string, loginUrl?: string) {
|
||||
return requireBrowserManager().launchLoginBrowser(socialAccountId, platform, loginUrl);
|
||||
}
|
||||
|
||||
function resetBrowserManager() {
|
||||
browserManagerRef.current?.shutdown();
|
||||
browserManagerRef.current = new BrowserManager(join(db.artifactsDir, "browser"));
|
||||
}
|
||||
|
||||
return {
|
||||
dispatchBrowserAction,
|
||||
clearSocialAccountData,
|
||||
cancelAction,
|
||||
launchLoginBrowser,
|
||||
resetBrowserManager,
|
||||
};
|
||||
}
|
||||
+145
-5
@@ -1,6 +1,7 @@
|
||||
import { BrowserWindow, session, type NativeImage } from "electron";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join, resolve, sep } from "node:path";
|
||||
import log from "electron-log/main.js";
|
||||
|
||||
export interface BrowserAction {
|
||||
@@ -13,6 +14,7 @@ export interface BrowserAction {
|
||||
|
||||
export interface BrowserActionResult {
|
||||
success: boolean;
|
||||
cancelled: boolean;
|
||||
summary: string;
|
||||
screenshotPath: string | null;
|
||||
error: string | null;
|
||||
@@ -104,6 +106,8 @@ export class BrowserManager {
|
||||
private processing = new Set<string>();
|
||||
private loginWindows = new Map<string, BrowserWindow>();
|
||||
private initializedSessions = new Set<string>();
|
||||
private activeActionWindows = new Map<string, BrowserWindow>();
|
||||
private cancelledActionIds = new Set<string>();
|
||||
|
||||
constructor(profileDir: string) {
|
||||
this.screenshotDir = join(profileDir, "browser-screenshots");
|
||||
@@ -121,6 +125,36 @@ export class BrowserManager {
|
||||
return socialSession;
|
||||
}
|
||||
|
||||
async clearSocialAccountData(socialAccountId: string, screenshotPaths: string[] = []) {
|
||||
const existing = this.loginWindows.get(socialAccountId);
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
existing.close();
|
||||
}
|
||||
this.loginWindows.delete(socialAccountId);
|
||||
this.queues.delete(socialAccountId);
|
||||
this.processing.delete(socialAccountId);
|
||||
|
||||
const ses = this.getSession(socialAccountId);
|
||||
try {
|
||||
await ses.clearStorageData();
|
||||
await ses.clearCache();
|
||||
await ses.clearAuthCache();
|
||||
} catch (error) {
|
||||
log.warn(`[browser] Failed to fully clear session for ${socialAccountId}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
screenshotPaths
|
||||
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
|
||||
.filter((screenshotPath) => {
|
||||
const resolvedPath = resolve(screenshotPath);
|
||||
const resolvedRoot = resolve(this.screenshotDir);
|
||||
return resolvedPath === resolvedRoot || resolvedPath.startsWith(`${resolvedRoot}${sep}`);
|
||||
})
|
||||
.map((screenshotPath) => rm(screenshotPath, { force: true }).catch(() => undefined)),
|
||||
);
|
||||
}
|
||||
|
||||
async launchLoginBrowser(
|
||||
socialAccountId: string,
|
||||
platform: string,
|
||||
@@ -129,12 +163,13 @@ export class BrowserManager {
|
||||
const existing = this.loginWindows.get(socialAccountId);
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
existing.focus();
|
||||
return { success: true, summary: "Login window already open", screenshotPath: null, error: null };
|
||||
return { success: true, cancelled: false, summary: "Login window already open", screenshotPath: null, error: null };
|
||||
}
|
||||
|
||||
const rules = this.getPlatformRules(platform);
|
||||
const url = loginUrl || rules.loginUrl;
|
||||
const ses = this.getSession(socialAccountId);
|
||||
this.assertAllowedUrl(platform, url, "login");
|
||||
|
||||
log.info(`[browser] Opening login window for ${platform} → ${url}`);
|
||||
|
||||
@@ -151,6 +186,7 @@ export class BrowserManager {
|
||||
});
|
||||
|
||||
this.loginWindows.set(socialAccountId, win);
|
||||
this.attachWindowSecurity(win, platform);
|
||||
|
||||
win.setMenuBarVisibility(false);
|
||||
|
||||
@@ -167,6 +203,7 @@ export class BrowserManager {
|
||||
log.info(`[browser] Login window closed for ${platform} — verified=${verified}`);
|
||||
resolve({
|
||||
success: verified,
|
||||
cancelled: false,
|
||||
summary: verified
|
||||
? `Authenticated session detected for ${platform}`
|
||||
: `No authenticated ${platform} session detected`,
|
||||
@@ -187,6 +224,36 @@ export class BrowserManager {
|
||||
});
|
||||
}
|
||||
|
||||
cancelAction(actionId: string): boolean {
|
||||
this.cancelledActionIds.add(actionId);
|
||||
const activeWindow = this.activeActionWindows.get(actionId);
|
||||
if (activeWindow && !activeWindow.isDestroyed()) {
|
||||
activeWindow.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const [socialAccountId, queue] of this.queues.entries()) {
|
||||
const index = queue.findIndex((item) => item.action.id === actionId);
|
||||
if (index < 0) {
|
||||
continue;
|
||||
}
|
||||
const [item] = queue.splice(index, 1);
|
||||
item?.resolve({
|
||||
success: false,
|
||||
cancelled: true,
|
||||
summary: "Action cancelled before execution.",
|
||||
screenshotPath: null,
|
||||
error: "Action cancelled.",
|
||||
});
|
||||
if (queue.length === 0) {
|
||||
this.queues.delete(socialAccountId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async processQueue(socialAccountId: string) {
|
||||
if (this.processing.has(socialAccountId)) return;
|
||||
const queue = this.queues.get(socialAccountId);
|
||||
@@ -225,28 +292,39 @@ export class BrowserManager {
|
||||
sandbox: true,
|
||||
},
|
||||
});
|
||||
this.activeActionWindows.set(action.id, win);
|
||||
this.attachWindowSecurity(win, platform);
|
||||
win.setMenuBarVisibility(false);
|
||||
|
||||
try {
|
||||
let summary = "Action completed";
|
||||
const assertNotCancelled = () => {
|
||||
if (this.cancelledActionIds.has(action.id)) {
|
||||
throw new Error("BROWSER_ACTION_CANCELLED");
|
||||
}
|
||||
};
|
||||
|
||||
switch (action.actionType) {
|
||||
case "navigate": {
|
||||
assertNotCancelled();
|
||||
const url = String(payload.url || "https://google.com");
|
||||
this.assertAllowedUrl(platform, url, "navigate");
|
||||
await win.loadURL(url);
|
||||
await this.waitForLoad(win);
|
||||
assertNotCancelled();
|
||||
const title = await win.webContents.executeJavaScript("document.title");
|
||||
summary = `Navigated to ${title}`;
|
||||
break;
|
||||
}
|
||||
|
||||
case "post": {
|
||||
assertNotCancelled();
|
||||
const content = String(payload.content || "");
|
||||
const postUrl = this.getComposeUrl(platform);
|
||||
await win.loadURL(postUrl);
|
||||
await this.waitForLoad(win);
|
||||
await this.delay(2000);
|
||||
assertNotCancelled();
|
||||
|
||||
if (platform === "twitter") {
|
||||
await win.webContents.executeJavaScript(`
|
||||
@@ -297,12 +375,14 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
case "reply": {
|
||||
assertNotCancelled();
|
||||
const targetUrl = String(payload.targetUrl || "");
|
||||
this.assertAllowedUrl(platform, targetUrl, "reply");
|
||||
const replyContent = String(payload.content || "");
|
||||
await win.loadURL(targetUrl);
|
||||
await this.waitForLoad(win);
|
||||
await this.delay(2000);
|
||||
assertNotCancelled();
|
||||
|
||||
await win.webContents.executeJavaScript(`
|
||||
(async () => {
|
||||
@@ -326,11 +406,13 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
case "like": {
|
||||
assertNotCancelled();
|
||||
const targetUrl = String(payload.targetUrl || "");
|
||||
this.assertAllowedUrl(platform, targetUrl, "like");
|
||||
await win.loadURL(targetUrl);
|
||||
await this.waitForLoad(win);
|
||||
await this.delay(2000);
|
||||
assertNotCancelled();
|
||||
await win.webContents.executeJavaScript(`
|
||||
(() => {
|
||||
const btn = document.querySelector('[data-testid="like"], button[aria-label*="Like"]');
|
||||
@@ -343,11 +425,13 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
case "follow": {
|
||||
assertNotCancelled();
|
||||
const targetUrl = String(payload.targetUrl || payload.profileUrl || "");
|
||||
this.assertAllowedUrl(platform, targetUrl, "follow");
|
||||
await win.loadURL(targetUrl);
|
||||
await this.waitForLoad(win);
|
||||
await this.delay(2000);
|
||||
assertNotCancelled();
|
||||
await win.webContents.executeJavaScript(`
|
||||
(() => {
|
||||
const btn = document.querySelector('[data-testid*="follow"], button[aria-label*="Follow"]');
|
||||
@@ -360,12 +444,14 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
case "browse_feed": {
|
||||
assertNotCancelled();
|
||||
const feedUrl = String(payload.url || this.getFeedUrl(platform));
|
||||
this.assertAllowedUrl(platform, feedUrl, "browse_feed");
|
||||
const maxPosts = Number(payload.maxPosts || 10);
|
||||
await win.loadURL(feedUrl);
|
||||
await this.waitForLoad(win);
|
||||
await this.delay(3000);
|
||||
assertNotCancelled();
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await win.webContents.executeJavaScript("window.scrollBy(0, window.innerHeight)");
|
||||
@@ -383,12 +469,14 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
case "search": {
|
||||
assertNotCancelled();
|
||||
const query = String(payload.query || "");
|
||||
const searchUrl = this.getSearchUrl(platform, query);
|
||||
this.assertAllowedUrl(platform, searchUrl, "search");
|
||||
await win.loadURL(searchUrl);
|
||||
await this.waitForLoad(win);
|
||||
await this.delay(3000);
|
||||
assertNotCancelled();
|
||||
|
||||
const results: string[] = await win.webContents.executeJavaScript(`
|
||||
(() => {
|
||||
@@ -401,6 +489,7 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
case "screenshot": {
|
||||
assertNotCancelled();
|
||||
const url = String(payload.url || "about:blank");
|
||||
if (url !== "about:blank") {
|
||||
this.assertAllowedUrl(platform, url, "screenshot");
|
||||
@@ -408,6 +497,7 @@ export class BrowserManager {
|
||||
await this.waitForLoad(win);
|
||||
await this.delay(2000);
|
||||
}
|
||||
assertNotCancelled();
|
||||
const title = await win.webContents.executeJavaScript("document.title");
|
||||
summary = `Screenshot captured: ${title}`;
|
||||
break;
|
||||
@@ -431,11 +521,13 @@ export class BrowserManager {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
cancelled: false,
|
||||
summary,
|
||||
screenshotPath: existsSync(screenshotPath) ? screenshotPath : null,
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
const wasCancelled = this.cancelledActionIds.has(action.id) || (error instanceof Error && error.message === "BROWSER_ACTION_CANCELLED");
|
||||
try {
|
||||
const image: NativeImage = await win.webContents.capturePage();
|
||||
if (!image.isEmpty()) writeFileSync(screenshotPath, image.toPNG());
|
||||
@@ -443,14 +535,20 @@ export class BrowserManager {
|
||||
|
||||
if (!win.isDestroyed()) win.close();
|
||||
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
log.error(`[browser] Action ${action.actionType} failed: ${msg}`);
|
||||
const msg = wasCancelled ? "Action cancelled." : error instanceof Error ? error.message : String(error);
|
||||
if (!wasCancelled) {
|
||||
log.error(`[browser] Action ${action.actionType} failed: ${msg}`);
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
summary: "Action failed",
|
||||
cancelled: wasCancelled,
|
||||
summary: wasCancelled ? "Action cancelled" : "Action failed",
|
||||
screenshotPath: existsSync(screenshotPath) ? screenshotPath : null,
|
||||
error: msg,
|
||||
};
|
||||
} finally {
|
||||
this.activeActionWindows.delete(action.id);
|
||||
this.cancelledActionIds.delete(action.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,6 +574,43 @@ export class BrowserManager {
|
||||
return PLATFORM_RULES[platform] ?? PLATFORM_RULES.other;
|
||||
}
|
||||
|
||||
private isAllowedNavigation(platform: string, rawUrl: string) {
|
||||
try {
|
||||
this.assertAllowedUrl(platform, rawUrl, "navigate");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private attachWindowSecurity(win: BrowserWindow, platform: string) {
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (!this.isAllowedNavigation(platform, url)) {
|
||||
return { action: "deny" };
|
||||
}
|
||||
setImmediate(() => {
|
||||
void win.loadURL(url).catch((error) => {
|
||||
log.warn(`[browser] Failed to open allowed popup target for ${platform}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
});
|
||||
return { action: "deny" };
|
||||
});
|
||||
win.webContents.on("will-navigate", (event, navigationUrl) => {
|
||||
if (this.isAllowedNavigation(platform, navigationUrl)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
log.warn(`[browser] Blocked navigation outside allowlist for ${platform}: ${navigationUrl}`);
|
||||
});
|
||||
win.webContents.on("will-redirect", (event, navigationUrl) => {
|
||||
if (this.isAllowedNavigation(platform, navigationUrl)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
log.warn(`[browser] Blocked redirect outside allowlist for ${platform}: ${navigationUrl}`);
|
||||
});
|
||||
}
|
||||
|
||||
private assertAllowedUrl(platform: string, rawUrl: string, actionType: string) {
|
||||
const allowedHosts = this.getPlatformRules(platform).allowedHosts;
|
||||
let url: URL;
|
||||
@@ -484,6 +619,9 @@ export class BrowserManager {
|
||||
} catch {
|
||||
throw new Error(`Invalid URL for ${actionType}: ${rawUrl}`);
|
||||
}
|
||||
if (url.protocol !== "https:") {
|
||||
throw new Error(`Navigation blocked for ${actionType}. Only https URLs are allowed.`);
|
||||
}
|
||||
const host = url.hostname.toLowerCase();
|
||||
const isAllowed = allowedHosts.some((allowedHost) => host === allowedHost || host.endsWith(`.${allowedHost}`));
|
||||
if (!isAllowed) {
|
||||
@@ -578,6 +716,8 @@ export class BrowserManager {
|
||||
if (!win.isDestroyed()) win.close();
|
||||
}
|
||||
this.loginWindows.clear();
|
||||
this.activeActionWindows.clear();
|
||||
this.cancelledActionIds.clear();
|
||||
this.queues.clear();
|
||||
this.processing.clear();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { homedir } from "node:os";
|
||||
import type { ConnectorId } from "@shared/types";
|
||||
|
||||
function quoteShell(value: string) {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function escapeAppleScript(value: string) {
|
||||
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function connectorAuthSpec(connectorId: ConnectorId, command: string) {
|
||||
switch (connectorId) {
|
||||
case "codex_local":
|
||||
return {
|
||||
title: "Codex Login",
|
||||
shellCommand: `${quoteShell(command)} login`,
|
||||
intro: "AgentCompany is opening the Codex login flow.",
|
||||
};
|
||||
case "claude_local":
|
||||
return {
|
||||
title: "Claude Code Login",
|
||||
shellCommand: `${quoteShell(command)} auth login`,
|
||||
intro: "AgentCompany is opening the Claude Code login flow.",
|
||||
};
|
||||
case "opencode_local":
|
||||
return {
|
||||
title: "OpenCode Login",
|
||||
shellCommand: `${quoteShell(command)} auth login`,
|
||||
intro: "AgentCompany is opening the OpenCode provider login flow.",
|
||||
};
|
||||
case "gemini_local":
|
||||
return {
|
||||
title: "Gemini CLI Login",
|
||||
shellCommand: `${quoteShell(command)}`,
|
||||
intro: "AgentCompany is opening Gemini CLI. If authentication is required, use /auth in the Gemini session.",
|
||||
};
|
||||
case "kimi_local":
|
||||
return {
|
||||
title: "Kimi Code Login",
|
||||
shellCommand: `${quoteShell(command)}`,
|
||||
intro: "AgentCompany is opening Kimi Code. Complete the interactive login flow there if prompted.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildTerminalCommand(connectorId: ConnectorId, command: string, cwd: string) {
|
||||
const spec = connectorAuthSpec(connectorId, command);
|
||||
const shell = process.env.SHELL || "/bin/zsh";
|
||||
const script = [
|
||||
"clear",
|
||||
`echo ${quoteShell(spec.intro)}`,
|
||||
"echo",
|
||||
`cd ${quoteShell(cwd)}`,
|
||||
spec.shellCommand,
|
||||
"echo",
|
||||
`echo ${quoteShell("You can close this terminal after the login flow completes.")}`,
|
||||
`exec ${quoteShell(shell)} -l`,
|
||||
].join("; ");
|
||||
return { title: spec.title, script };
|
||||
}
|
||||
|
||||
export async function openConnectorAuthTerminal(connectorId: ConnectorId, command: string, cwd?: string) {
|
||||
const targetCwd = cwd || process.env.HOME || homedir() || process.cwd();
|
||||
const { script } = buildTerminalCommand(connectorId, command, targetCwd);
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
const appleScript = `tell application "Terminal" to do script "${escapeAppleScript(script)}"\ntell application "Terminal" to activate`;
|
||||
const child = spawn("osascript", ["-e", appleScript], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
child.unref();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const child = spawn("cmd.exe", ["/c", "start", "", "cmd.exe", "/k", script], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
shell: false,
|
||||
});
|
||||
child.unref();
|
||||
return true;
|
||||
}
|
||||
|
||||
const linuxCandidates: Array<[string, string[]]> = [
|
||||
["x-terminal-emulator", ["-e", "sh", "-lc", script]],
|
||||
["gnome-terminal", ["--", "sh", "-lc", script]],
|
||||
["konsole", ["-e", "sh", "-lc", script]],
|
||||
["xfce4-terminal", ["-e", `sh -lc ${quoteShell(script)}`]],
|
||||
];
|
||||
|
||||
for (const [binary, args] of linuxCandidates) {
|
||||
const availability = spawnSync("sh", ["-lc", `command -v ${quoteShell(binary)}`], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
if ((availability.status ?? 1) !== 0) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const child = spawn(binary, args, {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
child.unref();
|
||||
return true;
|
||||
} catch {
|
||||
// Try next terminal candidate.
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("No supported terminal application was found for connector login.");
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { homedir } from "node:os";
|
||||
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.HOME ?? homedir() ?? process.cwd();
|
||||
}
|
||||
|
||||
function parseVersion(command: string): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
const child = spawn(command, ["--version"], {
|
||||
env: 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;
|
||||
child.kill("SIGKILL");
|
||||
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)),
|
||||
]);
|
||||
|
||||
const authRequired = result.checks.some((check) =>
|
||||
/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 (authRequired) {
|
||||
authState = "required";
|
||||
} else if (result.status === "fail") {
|
||||
authState = "error";
|
||||
}
|
||||
const version = await parseVersion(connector.command);
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id,
|
||||
status:
|
||||
authRequired
|
||||
? "auth_required"
|
||||
: result.status === "pass"
|
||||
? "ready"
|
||||
: result.status === "warn"
|
||||
? "degraded"
|
||||
: "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 status = /command.*not found|not executable/i.test(errorMessage)
|
||||
? "not_installed"
|
||||
: /timed out/i.test(errorMessage)
|
||||
? "degraded"
|
||||
: "error";
|
||||
db.updateConnectorHealth({
|
||||
id,
|
||||
status,
|
||||
authState: "error",
|
||||
version: null,
|
||||
lastError: errorMessage,
|
||||
});
|
||||
}
|
||||
publishDomainChanged();
|
||||
}
|
||||
|
||||
return {
|
||||
checkConnector,
|
||||
randomFallbackCompanyId,
|
||||
};
|
||||
}
|
||||
+30
-124
@@ -1,11 +1,4 @@
|
||||
import type {
|
||||
AdapterExecutionContext,
|
||||
AdapterExecutionResult,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterEnvironmentTestResult,
|
||||
ServerAdapterModule,
|
||||
} from "@agentcompany/adapter-utils";
|
||||
import { asNumber, asString, ensureAbsoluteDirectory, ensureCommandResolvable, ensurePathInEnv, runChildProcess } from "@agentcompany/adapter-utils/server-utils";
|
||||
import type { ServerAdapterModule } from "@agentcompany/adapter-utils";
|
||||
import { agentConfigurationDoc as claudeDoc, models as claudeModels } from "@agentcompany/adapter-claude-local";
|
||||
import { execute as claudeExecute, sessionCodec as claudeSessionCodec, testEnvironment as claudeTestEnvironment } from "@agentcompany/adapter-claude-local/server";
|
||||
import {
|
||||
@@ -16,15 +9,23 @@ import {
|
||||
import { execute as codexExecute, sessionCodec as codexSessionCodec, testEnvironment as codexTestEnvironment } from "@agentcompany/adapter-codex-local/server";
|
||||
import { agentConfigurationDoc as geminiDoc, DEFAULT_GEMINI_LOCAL_MODEL, models as geminiModels } from "@agentcompany/adapter-gemini-local";
|
||||
import { execute as geminiExecute, sessionCodec as geminiSessionCodec, testEnvironment as geminiTestEnvironment } from "@agentcompany/adapter-gemini-local/server";
|
||||
import { agentConfigurationDoc as kimiDoc, DEFAULT_KIMI_LOCAL_MODEL, models as kimiModels } from "@agentcompany/adapter-kimi-local";
|
||||
import { execute as kimiExecute, testEnvironment as kimiTestEnvironment } from "@agentcompany/adapter-kimi-local/server";
|
||||
import { agentConfigurationDoc as opencodeDoc } from "@agentcompany/adapter-opencode-local";
|
||||
import { execute as opencodeExecute, listOpenCodeModels, sessionCodec as opencodeSessionCodec, testEnvironment as opencodeTestEnvironment } from "@agentcompany/adapter-opencode-local/server";
|
||||
import type { CapabilityMatrix, ConnectorId, ConnectorRecord } from "@shared/types";
|
||||
|
||||
function normalizeEnv(input: NodeJS.ProcessEnv): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(input).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||
);
|
||||
}
|
||||
const RESERVED_ENV_BINDING_KEYS = new Set([
|
||||
"PATH",
|
||||
"HOME",
|
||||
"USER",
|
||||
"SHELL",
|
||||
"NODE_OPTIONS",
|
||||
"ELECTRON_RUN_AS_NODE",
|
||||
"DYLD_INSERT_LIBRARIES",
|
||||
"LD_PRELOAD",
|
||||
"PWD",
|
||||
]);
|
||||
|
||||
interface ConnectorDefinition {
|
||||
id: ConnectorId;
|
||||
@@ -39,98 +40,6 @@ interface ConnectorDefinition {
|
||||
listModels?: () => Promise<Array<{ id: string; label: string }>>;
|
||||
}
|
||||
|
||||
const kimiModule: ServerAdapterModule = {
|
||||
type: "kimi_local",
|
||||
async testEnvironment(ctx: AdapterEnvironmentTestContext): Promise<AdapterEnvironmentTestResult> {
|
||||
const command = asString(ctx.config.command, "kimi");
|
||||
const cwd = asString(ctx.config.cwd, process.cwd());
|
||||
const env = normalizeEnv(ensurePathInEnv({ ...(ctx.config.env as Record<string, string> ?? process.env) }));
|
||||
try {
|
||||
await ensureCommandResolvable(command, cwd, env);
|
||||
return {
|
||||
adapterType: "kimi_local",
|
||||
status: "warn",
|
||||
checks: [
|
||||
{
|
||||
code: "kimi_command_detected",
|
||||
level: "info",
|
||||
message: `Detected Kimi Code command "${command}".`,
|
||||
},
|
||||
{
|
||||
code: "kimi_runtime_generic_mode",
|
||||
level: "warn",
|
||||
message:
|
||||
"Kimi integration is running in generic CLI mode. Session resume, model discovery, and structured transcript are not guaranteed.",
|
||||
},
|
||||
],
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
adapterType: "kimi_local",
|
||||
status: "fail",
|
||||
checks: [
|
||||
{
|
||||
code: "kimi_command_missing",
|
||||
level: "error",
|
||||
message: error instanceof Error ? error.message : "Kimi command is not available.",
|
||||
hint: "Install Kimi Code and ensure the CLI is available on PATH.",
|
||||
},
|
||||
],
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
},
|
||||
async execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const command = asString(ctx.config.command, "kimi");
|
||||
const cwd = asString(ctx.config.cwd, process.cwd());
|
||||
const model = asString(ctx.config.model, "kimi-for-coding");
|
||||
const timeoutSec = asNumber(ctx.config.timeoutSec, 0);
|
||||
const graceSec = asNumber(ctx.config.graceSec, 20);
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
|
||||
const prompt = typeof ctx.context.taskTitle === "string" && ctx.context.taskTitle.length > 0
|
||||
? [
|
||||
ctx.context.taskTitle,
|
||||
typeof ctx.context.taskDescription === "string" && ctx.context.taskDescription.length > 0
|
||||
? ctx.context.taskDescription
|
||||
: "",
|
||||
].filter(Boolean).join("\n\n")
|
||||
: [
|
||||
`You are agent ${ctx.agent.name}.`,
|
||||
"Respond with a concise execution summary when complete.",
|
||||
].join("\n\n");
|
||||
|
||||
const env = normalizeEnv(ensurePathInEnv({ ...(ctx.config.env as Record<string, string> ?? process.env) }));
|
||||
await ensureCommandResolvable(command, cwd, env);
|
||||
const result = await runChildProcess(ctx.runId, command, [], {
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
stdin: prompt,
|
||||
onLog: ctx.onLog,
|
||||
});
|
||||
|
||||
const summary =
|
||||
result.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(-8)
|
||||
.join("\n") || null;
|
||||
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
signal: result.signal,
|
||||
timedOut: result.timedOut,
|
||||
summary,
|
||||
model,
|
||||
billingType: "unknown",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const CONNECTORS: ConnectorDefinition[] = [
|
||||
{
|
||||
id: "codex_local",
|
||||
@@ -234,7 +143,7 @@ const CONNECTORS: ConnectorDefinition[] = [
|
||||
label: "Kimi Code",
|
||||
description: "Official Kimi Code CLI surfaced through a generic execution contract with explicit capability downgrade.",
|
||||
defaultCommand: "kimi",
|
||||
defaultModel: "kimi-for-coding",
|
||||
defaultModel: DEFAULT_KIMI_LOCAL_MODEL,
|
||||
notes: "MVP support is explicit generic mode until richer runtime contract evidence is implemented.",
|
||||
capabilityMatrix: {
|
||||
sessionResume: false,
|
||||
@@ -243,23 +152,14 @@ const CONNECTORS: ConnectorDefinition[] = [
|
||||
structuredTranscript: false,
|
||||
costAttribution: false,
|
||||
},
|
||||
configurationDoc: [
|
||||
"# kimi_local agent configuration",
|
||||
"",
|
||||
"Core fields:",
|
||||
"- command (string, optional): defaults to \"kimi\"",
|
||||
"- cwd (string, optional): working directory for the Kimi CLI process",
|
||||
"- model (string, optional): default kimi-for-coding",
|
||||
"- timeoutSec (number, optional)",
|
||||
"- graceSec (number, optional)",
|
||||
"",
|
||||
"Notes:",
|
||||
"- This connector currently runs Kimi in generic CLI mode.",
|
||||
"- Session resume, model discovery, structured transcript, and cost attribution are declared unsupported.",
|
||||
"- The UI must surface these limitations explicitly.",
|
||||
].join("\n"),
|
||||
module: kimiModule,
|
||||
listModels: async () => [{ id: "kimi-for-coding", label: "kimi-for-coding" }],
|
||||
configurationDoc: kimiDoc,
|
||||
module: {
|
||||
type: "kimi_local",
|
||||
execute: kimiExecute,
|
||||
testEnvironment: kimiTestEnvironment,
|
||||
models: kimiModels,
|
||||
},
|
||||
listModels: async () => kimiModels,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -285,6 +185,12 @@ export function parseEnvBindingText(text: string): Record<string, string> {
|
||||
return bindings;
|
||||
}
|
||||
|
||||
export function listInvalidEnvBindingKeys(text: string): string[] {
|
||||
return Object.keys(parseEnvBindingText(text)).filter((key) => {
|
||||
return RESERVED_ENV_BINDING_KEYS.has(key) || key.startsWith("AGENT_COMPANY_");
|
||||
});
|
||||
}
|
||||
|
||||
export function buildDefaultConnectorRows(existing: Array<Partial<ConnectorRecord>> = []): ConnectorRecord[] {
|
||||
const byId = new Map(existing.map((record) => [record.id, record]));
|
||||
return CONNECTORS.map((definition) => {
|
||||
@@ -294,7 +200,7 @@ export function buildDefaultConnectorRows(existing: Array<Partial<ConnectorRecor
|
||||
label: definition.label,
|
||||
description: definition.description,
|
||||
status: existingRecord?.status ?? "not_installed",
|
||||
command: definition.defaultCommand,
|
||||
command: existingRecord?.command ?? definition.defaultCommand,
|
||||
version: existingRecord?.version ?? null,
|
||||
authState: existingRecord?.authState ?? "unknown",
|
||||
lastCheckedAt: existingRecord?.lastCheckedAt ?? null,
|
||||
|
||||
+193
-20
@@ -1,9 +1,9 @@
|
||||
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { appendFile, copyFile, cp, rm } from "node:fs/promises";
|
||||
import { join, resolve, sep } from "node:path";
|
||||
import { extname, join, resolve, sep } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { DatabaseSync, backup as backupDatabase, type SQLInputValue } from "node:sqlite";
|
||||
import { getConnectorDefinition, buildDefaultConnectorRows, parseEnvBindingText } from "./connectors";
|
||||
import { getConnectorDefinition, buildDefaultConnectorRows, listInvalidEnvBindingKeys, parseEnvBindingText } from "./connectors";
|
||||
import { SecretVault } from "./vault";
|
||||
import type {
|
||||
ActivityRecord,
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
InboxItem,
|
||||
ProfileSnapshot,
|
||||
ProjectRecord,
|
||||
RunLogChunk,
|
||||
RunRecord,
|
||||
RunStatus,
|
||||
SecretRecord,
|
||||
@@ -34,6 +35,7 @@ import type {
|
||||
|
||||
type SqliteDatabase = DatabaseSync;
|
||||
type SqliteRow = Record<string, any>;
|
||||
const BOARD_READER_ID = "__board__";
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
@@ -66,6 +68,14 @@ function isWithinOrEqual(targetPath: string, rootPath: string) {
|
||||
return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(`${resolvedRoot}${sep}`);
|
||||
}
|
||||
|
||||
function safeRealpath(targetPath: string) {
|
||||
try {
|
||||
return realpathSync(targetPath);
|
||||
} catch {
|
||||
return resolve(targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
function taskPriorityOrderSql(columnName: string) {
|
||||
return `case ${columnName}
|
||||
when 'critical' then 4
|
||||
@@ -387,17 +397,31 @@ export class AppDatabase {
|
||||
}
|
||||
|
||||
const managedRoots = [this.profileDir, this.logsDir, this.artifactsDir, this.backupsDir];
|
||||
if (managedRoots.some((rootPath) => isWithinOrEqual(targetPath, rootPath))) {
|
||||
const sensitiveProfileExtensions = new Set([".sqlite", ".db", ".key"]);
|
||||
const forbiddenExecutableExtensions = new Set([".app", ".exe", ".bat", ".cmd", ".sh", ".command", ".desktop", ".msi", ".com", ".scr"]);
|
||||
const resolvedTarget = safeRealpath(targetPath);
|
||||
const targetStats = lstatSync(targetPath);
|
||||
const targetExtension = extname(resolvedTarget).toLowerCase();
|
||||
if (
|
||||
managedRoots.some((rootPath) => isWithinOrEqual(resolvedTarget, rootPath)) &&
|
||||
sensitiveProfileExtensions.has(targetExtension)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!targetStats.isDirectory() && forbiddenExecutableExtensions.has(targetExtension)) {
|
||||
return false;
|
||||
}
|
||||
if (managedRoots.some((rootPath) => isWithinOrEqual(resolvedTarget, safeRealpath(rootPath)))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const workspaces = this.readAll<{ localPath: string }>("select local_path as localPath from workspaces");
|
||||
if (workspaces.some((workspace) => isWithinOrEqual(targetPath, workspace.localPath))) {
|
||||
if (workspaces.some((workspace) => isWithinOrEqual(resolvedTarget, safeRealpath(workspace.localPath)))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const runLogs = this.readAll<{ logPath: string | null }>("select log_path as logPath from runs where log_path is not null");
|
||||
return runLogs.some((run) => run.logPath ? resolve(run.logPath) === resolve(targetPath) : false);
|
||||
return runLogs.some((run) => run.logPath ? safeRealpath(run.logPath) === resolvedTarget : false);
|
||||
}
|
||||
|
||||
updateSettings(input: { theme?: "system" | "light" | "dark"; updatesEnabled?: boolean; logRetentionDays?: number }) {
|
||||
@@ -445,6 +469,15 @@ export class AppDatabase {
|
||||
listSnapshot(): ProfileSnapshot {
|
||||
const connectors = this.readConnectors();
|
||||
const theme = (this.getSetting("theme") as "system" | "light" | "dark" | null) ?? "system";
|
||||
const boardReadReceiptMap = new Map(
|
||||
this.readAll<{ messageId: string; readAt: string }>(
|
||||
`select reads.message_id as messageId, reads.read_at as readAt
|
||||
from agent_message_reads reads
|
||||
join agent_messages messages on messages.id = reads.message_id
|
||||
where reads.agent_id = ?`,
|
||||
BOARD_READER_ID,
|
||||
).map((row) => [row.messageId, row.readAt]),
|
||||
);
|
||||
return {
|
||||
currentCompanyId: this.getCurrentCompanyId(),
|
||||
profilePath: this.profileDir,
|
||||
@@ -554,7 +587,7 @@ export class AppDatabase {
|
||||
).map((r) => ({
|
||||
id: r.id, companyId: r.company_id, fromAgentId: r.from_agent_id,
|
||||
toAgentId: r.to_agent_id, channel: r.channel, channelTargetId: r.channel_target_id,
|
||||
subject: r.subject, body: r.body, priority: r.priority, readAt: r.read_at,
|
||||
subject: r.subject, body: r.body, priority: r.priority, readAt: boardReadReceiptMap.get(r.id) ?? null,
|
||||
parentMessageId: r.parent_message_id, attachmentsJson: r.attachments_json,
|
||||
createdAt: r.created_at,
|
||||
})),
|
||||
@@ -926,9 +959,13 @@ export class AppDatabase {
|
||||
}
|
||||
|
||||
resolveSecretEnv(bindingText: string, companyId: string): Record<string, string> {
|
||||
const invalidKeys = new Set(listInvalidEnvBindingKeys(bindingText));
|
||||
const bindings = parseEnvBindingText(bindingText);
|
||||
const result: Record<string, string> = {};
|
||||
for (const [envKey, secretName] of Object.entries(bindings)) {
|
||||
if (invalidKeys.has(envKey)) {
|
||||
continue;
|
||||
}
|
||||
const row = this.db.prepare("select backend, ciphertext from secrets where company_id = ? and name = ?").get(companyId, secretName) as
|
||||
| { backend: "safe_storage" | "vault"; ciphertext: Uint8Array }
|
||||
| undefined;
|
||||
@@ -1006,6 +1043,13 @@ export class AppDatabase {
|
||||
return row ? { ...row, isPrimary: toBoolean(row.isPrimary) } : null;
|
||||
}
|
||||
|
||||
getRunAuthState(runId: string): { id: string; companyId: string; agentId: string; status: RunRecord["status"] } | null {
|
||||
const row = this.db
|
||||
.prepare("select id, company_id as companyId, agent_id as agentId, status from runs where id = ?")
|
||||
.get(runId) as { id: string; companyId: string; agentId: string; status: RunRecord["status"] } | undefined;
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
createRun(input: {
|
||||
companyId: string;
|
||||
taskId: string;
|
||||
@@ -1044,14 +1088,69 @@ export class AppDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
readRunLogChunk(runId: string, offset = 0, limit = 65_536): RunLogChunk {
|
||||
const run = this.db.prepare("select log_path as logPath from runs where id = ?").get(runId) as { logPath: string | null } | undefined;
|
||||
if (!run?.logPath || !existsSync(run.logPath)) {
|
||||
return {
|
||||
content: "",
|
||||
offset: 0,
|
||||
nextOffset: 0,
|
||||
totalBytes: 0,
|
||||
eof: true,
|
||||
};
|
||||
}
|
||||
|
||||
const totalBytes = statSync(run.logPath).size;
|
||||
const safeOffset = Math.max(0, Math.min(offset, totalBytes));
|
||||
const safeLimit = Math.max(1_024, Math.min(limit, 262_144));
|
||||
const bytesToRead = Math.max(0, Math.min(safeLimit, totalBytes - safeOffset));
|
||||
|
||||
if (bytesToRead === 0) {
|
||||
return {
|
||||
content: "",
|
||||
offset: safeOffset,
|
||||
nextOffset: safeOffset,
|
||||
totalBytes,
|
||||
eof: true,
|
||||
};
|
||||
}
|
||||
|
||||
const fileDescriptor = openSync(run.logPath, "r");
|
||||
try {
|
||||
const buffer = Buffer.allocUnsafe(bytesToRead);
|
||||
const bytesRead = readSync(fileDescriptor, buffer, 0, bytesToRead, safeOffset);
|
||||
const nextOffset = safeOffset + bytesRead;
|
||||
return {
|
||||
content: buffer.subarray(0, bytesRead).toString("utf8"),
|
||||
offset: safeOffset,
|
||||
nextOffset,
|
||||
totalBytes,
|
||||
eof: nextOffset >= totalBytes,
|
||||
};
|
||||
} finally {
|
||||
closeSync(fileDescriptor);
|
||||
}
|
||||
}
|
||||
|
||||
markRunStatus(runId: string, status: RunRecord["status"], message: string) {
|
||||
const ts = nowIso();
|
||||
const startedAt = status === "running" ? ts : null;
|
||||
this.db
|
||||
.prepare(
|
||||
"update runs set status = ?, summary = case when ? != '' then ? else summary end, started_at = coalesce(started_at, ?), updated_at = ? where id = ?",
|
||||
)
|
||||
.run(status, message, message, startedAt, ts, runId);
|
||||
this.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
"update runs set status = ?, summary = case when ? != '' then ? else summary end, started_at = coalesce(started_at, ?), updated_at = ? where id = ?",
|
||||
)
|
||||
.run(status, message, message, startedAt, ts, runId);
|
||||
|
||||
if (status === "running") {
|
||||
const run = this.db.prepare("select agent_id from runs where id = ?").get(runId) as { agent_id: string } | undefined;
|
||||
if (run) {
|
||||
this.db
|
||||
.prepare("update agents set status = 'running', updated_at = ? where id = ? and status in ('idle', 'active')")
|
||||
.run(ts, run.agent_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
finishRun(input: {
|
||||
@@ -1125,6 +1224,11 @@ export class AppDatabase {
|
||||
this.db
|
||||
.prepare("update agents set status = 'paused', updated_at = ? where id = ?")
|
||||
.run(ts, run.agentId);
|
||||
} else {
|
||||
// Reset agent status from "running" back to "idle" unless paused/terminated/pending_approval
|
||||
this.db
|
||||
.prepare("update agents set status = 'idle', updated_at = ? where id = ? and status = 'running'")
|
||||
.run(ts, run.agentId);
|
||||
}
|
||||
});
|
||||
return { budgetHardStopped };
|
||||
@@ -1192,7 +1296,7 @@ export class AppDatabase {
|
||||
this.db.prepare("delete from agents where company_id = ?").run(companyId);
|
||||
this.db.prepare("delete from secrets where company_id = ?").run(companyId);
|
||||
this.db.prepare("delete from workspaces where company_id = ?").run(companyId);
|
||||
for (const table of ["automation_rules", "automation_log", "agent_messages", "workflow_pipelines", "meetings", "documents", "knowledge_base", "sprints"]) {
|
||||
for (const table of ["automation_rules", "automation_log", "agent_messages", "workflow_pipelines", "meetings", "documents", "knowledge_base", "sprints", "browser_actions", "social_accounts"]) {
|
||||
try {
|
||||
this.db.prepare(`delete from ${table} where company_id = ?`).run(companyId);
|
||||
} catch (err) {
|
||||
@@ -1326,7 +1430,10 @@ export class AppDatabase {
|
||||
}
|
||||
|
||||
deleteSocialAccount(id: string, companyId: string) {
|
||||
this.db.prepare("DELETE FROM social_accounts WHERE id = ? AND company_id = ?").run(id, companyId);
|
||||
this.transaction(() => {
|
||||
this.db.prepare("DELETE FROM social_accounts WHERE id = ? AND company_id = ?").run(id, companyId);
|
||||
this.db.prepare("DELETE FROM browser_actions WHERE social_account_id = ? AND company_id = ?").run(id, companyId);
|
||||
});
|
||||
}
|
||||
|
||||
listSocialAccounts(companyId: string): SocialAccountRecord[] {
|
||||
@@ -1502,7 +1609,18 @@ export class AppDatabase {
|
||||
const failedRuns = (this.db.prepare("select count(*) as c from runs where company_id = ? and status in ('failed', 'timed_out', 'interrupted')").get(companyId) as { c: number })?.c ?? 0;
|
||||
const todoTasks = (this.db.prepare("select count(*) as c from tasks where company_id = ? and status in ('todo', 'in_progress', 'in_review')").get(companyId) as { c: number })?.c ?? 0;
|
||||
const budgetWarnings = (this.db.prepare("select count(*) as c from agents where company_id = ? and budget_monthly_usd > 0 and (spent_monthly_usd * 1.0 / budget_monthly_usd) >= 0.8").get(companyId) as { c: number })?.c ?? 0;
|
||||
const unreadMessages = (this.db.prepare("select count(*) as c from agent_messages where company_id = ? and read_at is null and (channel = 'company' or channel = 'incident' or priority = 'urgent')").get(companyId) as { c: number })?.c ?? 0;
|
||||
const unreadMessages = (
|
||||
this.db.prepare(
|
||||
`select count(*) as c
|
||||
from agent_messages messages
|
||||
left join agent_message_reads reads
|
||||
on reads.message_id = messages.id
|
||||
and reads.agent_id = ?
|
||||
where messages.company_id = ?
|
||||
and reads.read_at is null
|
||||
and (messages.channel = 'company' or messages.channel = 'incident' or messages.priority = 'urgent')`,
|
||||
).get(BOARD_READER_ID, companyId) as { c: number }
|
||||
)?.c ?? 0;
|
||||
return { pendingApprovals, activeRuns, failedRuns, todoTasks, budgetWarnings, unreadMessages };
|
||||
}
|
||||
|
||||
@@ -1674,6 +1792,7 @@ export class AppDatabase {
|
||||
cp(this.logsDir, join(targetDir, "logs"), { recursive: true, force: true }),
|
||||
cp(this.artifactsDir, join(targetDir, "artifacts"), { recursive: true, force: true }),
|
||||
copyIfExists(join(this.profileDir, "vault.key"), join(targetDir, "vault.key")),
|
||||
copyIfExists(join(this.profileDir, "agent-api-signing.key"), join(targetDir, "agent-api-signing.key")),
|
||||
copyIfExists(`${this.dbPath}-wal`, join(targetDir, "profile.sqlite-wal")),
|
||||
copyIfExists(`${this.dbPath}-shm`, join(targetDir, "profile.sqlite-shm")),
|
||||
];
|
||||
@@ -1778,10 +1897,11 @@ export class AppDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
private purgeExpiredPendingWakes() {
|
||||
purgeExpiredPendingWakes() {
|
||||
// Remove pending wakes older than 2 hours — if agent hasn't woken by then, the wake is stale
|
||||
const cutoff = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
|
||||
this.db.prepare("delete from pending_wakes where created_at < ?").run(cutoff);
|
||||
const result = this.db.prepare("delete from pending_wakes where created_at < ?").run(cutoff);
|
||||
return Number(result.changes);
|
||||
}
|
||||
|
||||
private interruptStaleRuns() {
|
||||
@@ -1789,10 +1909,14 @@ export class AppDatabase {
|
||||
this.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
"update runs set status = 'interrupted', error_message = 'AgentCompany restarted before the run could report completion.', finished_at = ?, updated_at = ? where status in ('queued', 'running')",
|
||||
"update runs set status = 'interrupted', error_message = 'AgentCompany restarted before the run could report completion.', finished_at = ?, updated_at = ? where status = 'running'",
|
||||
)
|
||||
.run(ts, ts);
|
||||
this.db.prepare("update tasks set active_run_id = null where active_run_id is not null").run();
|
||||
this.db.prepare("update tasks set active_run_id = null where active_run_id in (select id from runs where status = 'interrupted')").run();
|
||||
// Reset any agents stuck in "running" status back to "idle"
|
||||
this.db
|
||||
.prepare("update agents set status = 'idle', updated_at = ? where status = 'running'")
|
||||
.run(ts);
|
||||
this.releaseAllWorkspaceLocks();
|
||||
});
|
||||
}
|
||||
@@ -2032,6 +2156,19 @@ export class AppDatabase {
|
||||
return Number(result.changes);
|
||||
}
|
||||
|
||||
listQueuedRuns() {
|
||||
return this.readAll<RunRecord>(
|
||||
`select id, company_id as companyId, task_id as taskId, agent_id as agentId, workspace_id as workspaceId,
|
||||
connector_id as connectorId, status, summary, error_message as errorMessage, exit_code as exitCode,
|
||||
signal, model, session_display_id as sessionDisplayId, cost_usd as costUsd,
|
||||
unattributed_cost as unattributedCost, started_at as startedAt, finished_at as finishedAt,
|
||||
log_path as logPath, created_at as createdAt, updated_at as updatedAt
|
||||
from runs
|
||||
where status = 'queued'
|
||||
order by created_at asc`,
|
||||
).map((record) => ({ ...record, unattributedCost: toBoolean(record.unattributedCost) })) as RunRecord[];
|
||||
}
|
||||
|
||||
isWorkspaceLocked(workspaceId: string): boolean {
|
||||
const row = this.db
|
||||
.prepare("select lock_holder_run_id from workspaces where id = ?")
|
||||
@@ -2162,6 +2299,15 @@ export class AppDatabase {
|
||||
this.db.prepare("delete from automation_rules where id = ? and company_id = ?").run(id, companyId);
|
||||
}
|
||||
|
||||
listAutomationRuleNames(companyId: string): Set<string> {
|
||||
return new Set(
|
||||
this.readAll<{ name: string }>(
|
||||
"select name from automation_rules where company_id = ?",
|
||||
companyId,
|
||||
).map((row) => row.name),
|
||||
);
|
||||
}
|
||||
|
||||
toggleAutomationRule(id: string, companyId: string, status: "active" | "paused") {
|
||||
this.db.prepare("update automation_rules set status = ?, updated_at = ? where id = ? and company_id = ?").run(status, nowIso(), id, companyId);
|
||||
}
|
||||
@@ -2213,6 +2359,22 @@ export class AppDatabase {
|
||||
sql += " order by created_at asc";
|
||||
|
||||
let messages = this.readAll<AgentMessageRecord>(sql, ...params);
|
||||
if (!opts?.agentId) {
|
||||
const boardReadReceiptMap = new Map(
|
||||
this.readAll<{ messageId: string; readAt: string }>(
|
||||
`select reads.message_id as messageId, reads.read_at as readAt
|
||||
from agent_message_reads reads
|
||||
join agent_messages messages on messages.id = reads.message_id
|
||||
where reads.agent_id = ? and messages.company_id = ?`,
|
||||
BOARD_READER_ID,
|
||||
companyId,
|
||||
).map((row) => [row.messageId, row.readAt]),
|
||||
);
|
||||
messages = messages.map((message) => ({
|
||||
...message,
|
||||
readAt: boardReadReceiptMap.get(message.id) ?? null,
|
||||
}));
|
||||
}
|
||||
if (opts?.agentId) {
|
||||
let department: string | null = null;
|
||||
try {
|
||||
@@ -2286,7 +2448,11 @@ export class AppDatabase {
|
||||
}
|
||||
|
||||
markMessageRead(messageId: string, companyId: string) {
|
||||
this.db.prepare("update agent_messages set read_at = ? where id = ? and company_id = ?").run(nowIso(), messageId, companyId);
|
||||
this.markAgentMessageRead(messageId, companyId, BOARD_READER_ID);
|
||||
}
|
||||
|
||||
markMessageReadForReader(messageId: string, companyId: string, readerAgentId?: string | null) {
|
||||
this.markAgentMessageRead(messageId, companyId, readerAgentId ?? BOARD_READER_ID);
|
||||
}
|
||||
|
||||
markAgentMessageRead(messageId: string, companyId: string, agentId: string) {
|
||||
@@ -2856,6 +3022,13 @@ export class AppDatabase {
|
||||
await cp(join(sourceDir, "artifacts"), this.artifactsDir, { recursive: true, force: true });
|
||||
}
|
||||
await copyIfExists(join(sourceDir, "vault.key"), join(this.profileDir, "vault.key"));
|
||||
await copyIfExists(join(sourceDir, "agent-api-signing.key"), join(this.profileDir, "agent-api-signing.key"));
|
||||
if (existsSync(join(this.profileDir, "vault.key"))) {
|
||||
chmodSync(join(this.profileDir, "vault.key"), 0o600);
|
||||
}
|
||||
if (existsSync(join(this.profileDir, "agent-api-signing.key"))) {
|
||||
chmodSync(join(this.profileDir, "agent-api-signing.key"), 0o600);
|
||||
}
|
||||
|
||||
this.db = this.openConnection();
|
||||
this.init();
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
export const DEFAULT_AUTOMATION_RULES: ReadonlyArray<{
|
||||
name: string;
|
||||
description: string;
|
||||
trigger: string;
|
||||
action: string;
|
||||
conditionsJson?: string;
|
||||
actionConfigJson?: string;
|
||||
priority?: number;
|
||||
}> = [
|
||||
{
|
||||
name: "Auto-assign new tasks",
|
||||
description: "Automatically assign new unassigned tasks to the best-fit agent based on capabilities and workload.",
|
||||
trigger: "task_created",
|
||||
action: "assign_task",
|
||||
priority: 90,
|
||||
},
|
||||
{
|
||||
name: "Escalate blocked tasks",
|
||||
description: "When a task is blocked, escalate to the assignee's manager.",
|
||||
trigger: "task_blocked",
|
||||
action: "escalate_to_manager",
|
||||
actionConfigJson: JSON.stringify({ body: "A task assigned to your report is blocked and needs your attention." }),
|
||||
priority: 85,
|
||||
},
|
||||
{
|
||||
name: "Escalate completed work to manager",
|
||||
description: "When a run completes successfully, escalate to the assignee's manager so they review the work.",
|
||||
trigger: "run_completed",
|
||||
action: "escalate_to_manager",
|
||||
actionConfigJson: JSON.stringify({ body: "A task run has completed successfully. Please review the work and approve or send back for changes." }),
|
||||
priority: 80,
|
||||
},
|
||||
{
|
||||
name: "Reassign on run failure",
|
||||
description: "When a run fails, attempt to reassign the task to another capable agent.",
|
||||
trigger: "run_failed",
|
||||
action: "reassign_task",
|
||||
priority: 75,
|
||||
},
|
||||
{
|
||||
name: "Budget alert at 80%",
|
||||
description: "Send an alert when an agent reaches 80% of their monthly budget.",
|
||||
trigger: "budget_threshold",
|
||||
action: "send_message",
|
||||
actionConfigJson: JSON.stringify({ subject: "Budget warning", body: "Agent is approaching budget limit (80% utilized).", priority: "urgent" }),
|
||||
priority: 70,
|
||||
},
|
||||
{
|
||||
name: "Notify department on task completion",
|
||||
description: "When a run completes successfully, send a status update to the agent's department channel.",
|
||||
trigger: "run_completed",
|
||||
action: "send_message",
|
||||
actionConfigJson: JSON.stringify({ channel: "department", subject: "Task completed", body: "A task has been completed successfully. Check the task details for more information.", priority: "low" }),
|
||||
priority: 40,
|
||||
},
|
||||
{
|
||||
name: "Wake new hire on approval",
|
||||
description: "When a new agent hire is approved, immediately wake the new agent to start onboarding.",
|
||||
trigger: "hire_approved",
|
||||
action: "trigger_heartbeat",
|
||||
actionConfigJson: JSON.stringify({}),
|
||||
priority: 90,
|
||||
},
|
||||
{
|
||||
name: "Auto-run assigned tasks",
|
||||
description: "When a task transitions to 'todo' status, automatically wake the assigned agent to start working on it.",
|
||||
trigger: "task_status_changed",
|
||||
conditionsJson: JSON.stringify({ status: "todo" }),
|
||||
action: "run_task",
|
||||
actionConfigJson: JSON.stringify({}),
|
||||
priority: 65,
|
||||
},
|
||||
{
|
||||
name: "Notify company on sprint start",
|
||||
description: "When a sprint starts, notify the entire company.",
|
||||
trigger: "sprint_started",
|
||||
action: "send_message",
|
||||
actionConfigJson: JSON.stringify({ channel: "company", subject: "Sprint started", body: "A new sprint has begun. Check your assigned tasks and start execution.", priority: "normal" }),
|
||||
priority: 50,
|
||||
},
|
||||
{
|
||||
name: "Schedule retrospective on sprint end",
|
||||
description: "When a sprint ends, schedule a retrospective meeting.",
|
||||
trigger: "sprint_ended",
|
||||
action: "schedule_meeting",
|
||||
actionConfigJson: JSON.stringify({ meetingType: "retrospective", title: "Sprint Retrospective", durationMinutes: 45 }),
|
||||
priority: 55,
|
||||
},
|
||||
{
|
||||
name: "Create status report on schedule",
|
||||
description: "Periodically create a status report document for the company.",
|
||||
trigger: "schedule",
|
||||
action: "create_document",
|
||||
actionConfigJson: JSON.stringify({ documentType: "status_report", title: "Automated Status Report" }),
|
||||
priority: 30,
|
||||
},
|
||||
{
|
||||
name: "Auto-assign work to idle agents",
|
||||
description: "When an agent has been idle for 10+ minutes, automatically assign available unassigned tasks from the backlog.",
|
||||
trigger: "agent_idle",
|
||||
action: "assign_task",
|
||||
priority: 60,
|
||||
},
|
||||
{
|
||||
name: "Wake idle agents to check for work",
|
||||
description: "When an agent has no tasks and is idle, wake them so they can check for available work and coordinate with their team.",
|
||||
trigger: "agent_idle",
|
||||
action: "trigger_heartbeat",
|
||||
priority: 45,
|
||||
},
|
||||
{
|
||||
name: "Wake manager when task moves to review",
|
||||
description: "When a task status changes to in_review, wake the assignee's manager to review the work.",
|
||||
trigger: "task_status_changed",
|
||||
conditionsJson: JSON.stringify({ status: "in_review" }),
|
||||
action: "escalate_to_manager",
|
||||
actionConfigJson: JSON.stringify({ body: "A task from your report has been submitted for review. Please review the work and either approve (mark as done) or send back with feedback." }),
|
||||
priority: 82,
|
||||
},
|
||||
{
|
||||
name: "Wake assigned agent on task feedback",
|
||||
description: "When a reviewed task is sent back to in_progress with feedback, wake the assigned agent to address the feedback.",
|
||||
trigger: "task_status_changed",
|
||||
conditionsJson: JSON.stringify({ status: "in_progress", previousStatus: "in_review" }),
|
||||
action: "trigger_heartbeat",
|
||||
actionConfigJson: JSON.stringify({}),
|
||||
priority: 78,
|
||||
},
|
||||
];
|
||||
|
||||
export function seedDefaultAutomationRules(
|
||||
companyId: string,
|
||||
deps: {
|
||||
listAutomationRuleNames: (companyId: string) => Set<string>;
|
||||
saveAutomationRule: (input: {
|
||||
companyId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
trigger: string;
|
||||
conditionsJson?: string;
|
||||
action: string;
|
||||
actionConfigJson?: string;
|
||||
priority?: number;
|
||||
status?: string;
|
||||
}) => string;
|
||||
},
|
||||
) {
|
||||
const existingRuleNames = deps.listAutomationRuleNames(companyId);
|
||||
for (const rule of DEFAULT_AUTOMATION_RULES) {
|
||||
if (existingRuleNames.has(rule.name)) {
|
||||
continue;
|
||||
}
|
||||
deps.saveAutomationRule({
|
||||
companyId,
|
||||
name: rule.name,
|
||||
description: rule.description,
|
||||
trigger: rule.trigger,
|
||||
conditionsJson: rule.conditionsJson ?? "{}",
|
||||
action: rule.action,
|
||||
actionConfigJson: rule.actionConfigJson ?? "{}",
|
||||
priority: rule.priority ?? 50,
|
||||
status: "active",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { resolve, sep } from "node:path";
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
dialog,
|
||||
Menu,
|
||||
nativeImage,
|
||||
net,
|
||||
protocol,
|
||||
session,
|
||||
shell,
|
||||
Tray,
|
||||
} from "electron";
|
||||
import { attachWindowStatePersistence, loadWindowState } from "./window-state";
|
||||
|
||||
interface DesktopShellDependencies {
|
||||
preloadPath: string;
|
||||
rendererDist: string;
|
||||
iconPath: string;
|
||||
isDev: boolean;
|
||||
devServerUrl?: string;
|
||||
windowStatePath: string;
|
||||
shouldQuit: () => boolean;
|
||||
onRequestQuit: () => void;
|
||||
onCheckForUpdates: () => Promise<void>;
|
||||
onBackupProfile: () => Promise<void>;
|
||||
onShortcut: (action: "toggle-command-palette" | "toggle-console" | "refresh") => void;
|
||||
notify: (title: string, body: string) => void;
|
||||
}
|
||||
|
||||
function isSocialBrowserSession(contents: Electron.WebContents) {
|
||||
return contents.session !== session.defaultSession;
|
||||
}
|
||||
|
||||
function isWithinDirectory(rootPath: string, targetPath: string) {
|
||||
const normalizedRoot = resolve(rootPath);
|
||||
const normalizedTarget = resolve(targetPath);
|
||||
return normalizedTarget === normalizedRoot || normalizedTarget.startsWith(`${normalizedRoot}${sep}`);
|
||||
}
|
||||
|
||||
export function createDesktopShellService({
|
||||
preloadPath,
|
||||
rendererDist,
|
||||
iconPath,
|
||||
isDev,
|
||||
devServerUrl,
|
||||
windowStatePath,
|
||||
shouldQuit,
|
||||
onRequestQuit,
|
||||
onCheckForUpdates,
|
||||
onBackupProfile,
|
||||
onShortcut,
|
||||
notify,
|
||||
}: DesktopShellDependencies) {
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let appTray: Tray | null = null;
|
||||
let backgroundCloseNoticeShown = false;
|
||||
let windowCreationPromise: Promise<BrowserWindow | null> | null = null;
|
||||
|
||||
function getMainWindow() {
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
function installSecurityGuards() {
|
||||
session.defaultSession.setPermissionCheckHandler(() => false);
|
||||
session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => {
|
||||
callback(false);
|
||||
});
|
||||
app.on("web-contents-created", (_event, contents) => {
|
||||
contents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||
contents.on("will-attach-webview", (event) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
contents.on("will-navigate", (event) => {
|
||||
if (isSocialBrowserSession(contents)) return;
|
||||
event.preventDefault();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function registerProtocol() {
|
||||
protocol.handle("app", (request) => {
|
||||
const url = new URL(request.url);
|
||||
const relativePath = decodeURIComponent(url.pathname === "/" ? "index.html" : url.pathname.replace(/^\/+/, ""));
|
||||
const targetPath = resolve(rendererDist, relativePath);
|
||||
if (!isWithinDirectory(rendererDist, targetPath) || !existsSync(targetPath)) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
return net.fetch(pathToFileURL(targetPath).toString());
|
||||
});
|
||||
}
|
||||
|
||||
async function createWindow() {
|
||||
const windowIcon = nativeImage.createFromPath(iconPath);
|
||||
const savedWindowState = loadWindowState(windowStatePath);
|
||||
mainWindow = new BrowserWindow({
|
||||
title: "AgentCompany",
|
||||
icon: windowIcon.isEmpty() ? undefined : windowIcon,
|
||||
width: savedWindowState.bounds.width,
|
||||
height: savedWindowState.bounds.height,
|
||||
x: savedWindowState.bounds.x,
|
||||
y: savedWindowState.bounds.y,
|
||||
minWidth: 1180,
|
||||
minHeight: 760,
|
||||
backgroundColor: "#13110f",
|
||||
titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default",
|
||||
webPreferences: {
|
||||
preload: preloadPath,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
webviewTag: false,
|
||||
devTools: isDev,
|
||||
webSecurity: true,
|
||||
allowRunningInsecureContent: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (isDev && devServerUrl) {
|
||||
await mainWindow.loadURL(devServerUrl);
|
||||
} else {
|
||||
await mainWindow.loadURL("app://-/index.html");
|
||||
}
|
||||
attachWindowStatePersistence(mainWindow, windowStatePath);
|
||||
if (savedWindowState.maximized) {
|
||||
mainWindow.maximize();
|
||||
}
|
||||
mainWindow.on("close", (event) => {
|
||||
if (process.platform === "darwin" || shouldQuit()) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
mainWindow?.hide();
|
||||
if (!backgroundCloseNoticeShown) {
|
||||
backgroundCloseNoticeShown = true;
|
||||
notify("AgentCompany is still running", "Automation continues in the background until you quit the app.");
|
||||
}
|
||||
});
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureWindow() {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
if (!windowCreationPromise) {
|
||||
windowCreationPromise = createWindow()
|
||||
.then(() => mainWindow)
|
||||
.finally(() => {
|
||||
windowCreationPromise = null;
|
||||
});
|
||||
}
|
||||
return windowCreationPromise;
|
||||
}
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
function buildMenu() {
|
||||
const macAppMenu: Electron.MenuItemConstructorOptions = {
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: "about" },
|
||||
{ type: "separator" },
|
||||
{ role: "services" },
|
||||
{ type: "separator" },
|
||||
{ role: "hide" },
|
||||
{ role: "hideOthers" },
|
||||
{ role: "unhide" },
|
||||
{ type: "separator" },
|
||||
{ role: "quit" },
|
||||
],
|
||||
};
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
...(process.platform === "darwin" ? [macAppMenu] : []),
|
||||
{
|
||||
label: "File",
|
||||
submenu: [
|
||||
{
|
||||
label: "Open Workspace Folder",
|
||||
accelerator: "CmdOrCtrl+O",
|
||||
click: async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openDirectory"],
|
||||
});
|
||||
if (result.canceled || result.filePaths.length === 0) return;
|
||||
shell.openPath(result.filePaths[0] ?? "");
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Backup Profile",
|
||||
accelerator: "CmdOrCtrl+Shift+B",
|
||||
click: async () => {
|
||||
await onBackupProfile();
|
||||
},
|
||||
},
|
||||
...(process.platform === "darwin" ? [] : [{ type: "separator" as const }, { role: "quit" as const }]),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
submenu: [
|
||||
{ role: "undo" },
|
||||
{ role: "redo" },
|
||||
{ type: "separator" },
|
||||
{ role: "cut" },
|
||||
{ role: "copy" },
|
||||
{ role: "paste" },
|
||||
{ role: "selectAll" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "View",
|
||||
submenu: [
|
||||
{
|
||||
label: "Command Palette",
|
||||
accelerator: "CmdOrCtrl+K",
|
||||
click: () => onShortcut("toggle-command-palette"),
|
||||
},
|
||||
{
|
||||
label: "Toggle Console",
|
||||
accelerator: "CmdOrCtrl+`",
|
||||
click: () => onShortcut("toggle-console"),
|
||||
},
|
||||
{
|
||||
label: "Refresh Data",
|
||||
accelerator: "CmdOrCtrl+R",
|
||||
click: () => onShortcut("refresh"),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ role: "toggleDevTools", visible: isDev },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Run",
|
||||
submenu: [
|
||||
{
|
||||
label: "Check For Updates",
|
||||
click: async () => {
|
||||
await onCheckForUpdates();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
||||
}
|
||||
|
||||
function createTray() {
|
||||
if (process.platform === "darwin" || appTray) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trayIcon = nativeImage.createFromPath(iconPath);
|
||||
appTray = new Tray(trayIcon.isEmpty() ? nativeImage.createEmpty() : trayIcon);
|
||||
appTray.setToolTip("AgentCompany");
|
||||
appTray.setContextMenu(
|
||||
Menu.buildFromTemplate([
|
||||
{
|
||||
label: "Show AgentCompany",
|
||||
click: () => {
|
||||
void ensureWindow().then((win) => {
|
||||
if (win) {
|
||||
win.show();
|
||||
win.focus();
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Hide AgentCompany",
|
||||
click: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.hide();
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Quit",
|
||||
click: () => {
|
||||
onRequestQuit();
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
appTray.on("click", () => {
|
||||
void ensureWindow().then((win) => {
|
||||
if (!win) return;
|
||||
if (win.isVisible()) {
|
||||
win.hide();
|
||||
return;
|
||||
}
|
||||
win.show();
|
||||
win.focus();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
appTray?.destroy();
|
||||
appTray = null;
|
||||
}
|
||||
|
||||
function initialize() {
|
||||
installSecurityGuards();
|
||||
registerProtocol();
|
||||
buildMenu();
|
||||
createTray();
|
||||
}
|
||||
|
||||
return {
|
||||
initialize,
|
||||
installSecurityGuards,
|
||||
registerProtocol,
|
||||
buildMenu,
|
||||
createTray,
|
||||
createWindow,
|
||||
ensureWindow,
|
||||
getMainWindow,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { delimiter as pathDelimiter } from "node:path";
|
||||
import { app, nativeImage } from "electron";
|
||||
|
||||
interface HostEnvironmentDependencies {
|
||||
iconPath: string;
|
||||
logger: {
|
||||
info: (...args: unknown[]) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export async function prepareHostEnvironment({
|
||||
iconPath,
|
||||
logger,
|
||||
}: HostEnvironmentDependencies) {
|
||||
if (process.platform !== "win32" && process.env.AGENTCOMPANY_ENABLE_SHELL_PATH_PROBE === "true") {
|
||||
try {
|
||||
const userShell = process.env.SHELL || "/bin/zsh";
|
||||
const shellPath = await new Promise<string>((resolve) => {
|
||||
const child = spawn(userShell, ["-ilc", "echo -n $PATH"], {
|
||||
env: { ...process.env },
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 5000,
|
||||
});
|
||||
let output = "";
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
child.on("close", () => resolve(output.trim()));
|
||||
child.on("error", () => resolve(""));
|
||||
});
|
||||
if (shellPath && shellPath.length > 0) {
|
||||
const current = new Set((process.env.PATH ?? "").split(pathDelimiter).filter(Boolean));
|
||||
const shellParts = shellPath.split(pathDelimiter).filter(Boolean);
|
||||
const toAdd = shellParts.filter((entry) => !current.has(entry));
|
||||
if (toAdd.length > 0) {
|
||||
process.env.PATH = [process.env.PATH, ...toAdd].join(pathDelimiter);
|
||||
logger.info(`[path] Added ${toAdd.length} shell PATH entries.`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal environment enrichment.
|
||||
}
|
||||
} else if (process.platform !== "win32") {
|
||||
logger.info("[path] Skipping interactive shell PATH probe; using deterministic PATH setup only.");
|
||||
}
|
||||
|
||||
const appIcon = nativeImage.createFromPath(iconPath);
|
||||
if (!appIcon.isEmpty()) {
|
||||
if (process.platform === "darwin" && app.dock) {
|
||||
app.dock.setIcon(appIcon);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
app.setAppUserModelId("com.agentcompany.desktop");
|
||||
}
|
||||
}
|
||||
+19
-3555
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
IPC_CHANNELS,
|
||||
agentMessageInputSchema,
|
||||
autoAssignSchema,
|
||||
markMessageReadSchema,
|
||||
messageQuerySchema,
|
||||
} from "@shared/contracts";
|
||||
import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common";
|
||||
|
||||
export function registerAgentCoordinationHandlers({
|
||||
registerHandle,
|
||||
db,
|
||||
ensureCompanyExists,
|
||||
findInvalidReference,
|
||||
publishDomainChanged,
|
||||
emitEvent,
|
||||
wakeAgentIfPossible,
|
||||
}: Pick<
|
||||
RegisterDomainHandlersDependencies,
|
||||
| "registerHandle"
|
||||
| "db"
|
||||
| "ensureCompanyExists"
|
||||
| "findInvalidReference"
|
||||
| "publishDomainChanged"
|
||||
| "emitEvent"
|
||||
| "wakeAgentIfPossible"
|
||||
>) {
|
||||
registerHandle(IPC_CHANNELS.sendAgentMessage, async (_event, payload) => {
|
||||
const parsed = agentMessageInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (parsed.channel === "direct" && !parsed.toAgentId) {
|
||||
return fail("DIRECT_MESSAGE_RECIPIENT_REQUIRED", "Direct messages require a recipient agent.");
|
||||
}
|
||||
const invalidReference = findInvalidReference(parsed.companyId, [
|
||||
{ id: parsed.fromAgentId, table: "agents", label: "message sender" },
|
||||
{ id: parsed.toAgentId, table: "agents", label: "message recipient" },
|
||||
{ id: parsed.channel === "project" ? parsed.channelTargetId : null, table: "projects", label: "project channel" },
|
||||
]);
|
||||
if (invalidReference) {
|
||||
return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`);
|
||||
}
|
||||
const id = db.sendAgentMessage(parsed);
|
||||
const message = db.getAgentMessage(id, parsed.companyId);
|
||||
if (message) {
|
||||
emitEvent({ type: "new-message", message });
|
||||
}
|
||||
publishDomainChanged();
|
||||
if (parsed.toAgentId) {
|
||||
wakeAgentIfPossible(parsed.toAgentId, parsed.companyId, "message");
|
||||
}
|
||||
if (parsed.channel === "department" && parsed.channelTargetId) {
|
||||
const departmentAgents = db.getIdleAgentsForDepartment(parsed.companyId, parsed.channelTargetId);
|
||||
for (const agent of departmentAgents.filter((entry: { id: string }) => entry.id !== parsed.fromAgentId)) {
|
||||
wakeAgentIfPossible(agent.id, parsed.companyId, "message");
|
||||
}
|
||||
}
|
||||
if (parsed.channel === "company") {
|
||||
const ceo = db.listSnapshot().agents.find((agent: { companyId: string; reportsTo: string | null; status: string }) =>
|
||||
agent.companyId === parsed.companyId && !agent.reportsTo && agent.status !== "terminated",
|
||||
);
|
||||
if (ceo && ceo.id !== parsed.fromAgentId) {
|
||||
wakeAgentIfPossible(ceo.id, parsed.companyId, "message");
|
||||
}
|
||||
}
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.listAgentMessages, async (_event, payload) => {
|
||||
const parsed = messageQuerySchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const invalidReference = findInvalidReference(parsed.companyId, [
|
||||
{ id: parsed.agentId, table: "agents", label: "message reader" },
|
||||
{ id: parsed.channel === "project" ? parsed.channelTargetId : null, table: "projects", label: "project channel" },
|
||||
]);
|
||||
if (invalidReference) {
|
||||
return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`);
|
||||
}
|
||||
return ok(db.listAgentMessages(parsed.companyId, {
|
||||
agentId: parsed.agentId,
|
||||
channel: parsed.channel,
|
||||
channelTargetId: parsed.channelTargetId,
|
||||
unreadOnly: parsed.unreadOnly,
|
||||
limit: parsed.limit,
|
||||
}));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.markMessageRead, async (_event, payload) => {
|
||||
const parsed = markMessageReadSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.getAgentMessage(parsed.messageId, parsed.companyId)) {
|
||||
return fail("MESSAGE_NOT_FOUND", "Message not found in the current company.");
|
||||
}
|
||||
if (parsed.readerAgentId && !db.belongsToCompany("agents", parsed.readerAgentId, parsed.companyId)) {
|
||||
return fail("INVALID_COMPANY_REFERENCE", "The selected message reader does not belong to the current company.");
|
||||
}
|
||||
db.markMessageReadForReader(parsed.messageId, parsed.companyId, parsed.readerAgentId ?? null);
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.autoAssignTask, async (_event, payload) => {
|
||||
const parsed = autoAssignSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const agentId = db.autoAssignTask(parsed.taskId, parsed.companyId);
|
||||
if (!agentId) {
|
||||
return fail("NO_AVAILABLE_AGENT", "No available agent could be found for this task.");
|
||||
}
|
||||
publishDomainChanged();
|
||||
return ok(agentId);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
IPC_CHANNELS,
|
||||
automationRuleInputSchema,
|
||||
deleteEntitySchema,
|
||||
runWorkflowSchema,
|
||||
toggleAutomationRuleSchema,
|
||||
workflowInputSchema,
|
||||
} from "@shared/contracts";
|
||||
import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common";
|
||||
|
||||
export function registerAutomationOperationsHandlers({
|
||||
registerHandle,
|
||||
db,
|
||||
ensureCompanyExists,
|
||||
publishDomainChanged,
|
||||
runWorkflowPipeline,
|
||||
}: Pick<
|
||||
RegisterDomainHandlersDependencies,
|
||||
| "registerHandle"
|
||||
| "db"
|
||||
| "ensureCompanyExists"
|
||||
| "publishDomainChanged"
|
||||
| "runWorkflowPipeline"
|
||||
>) {
|
||||
const companyIdSchema = z.string().uuid();
|
||||
|
||||
registerHandle(IPC_CHANNELS.saveAutomationRule, async (_event, payload) => {
|
||||
const parsed = automationRuleInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const id = db.saveAutomationRule(parsed);
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.deleteAutomationRule, async (_event, payload) => {
|
||||
const parsed = deleteEntitySchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("automation_rules", parsed.id, parsed.companyId)) {
|
||||
return fail("ENTITY_NOT_FOUND", "Automation rule not found in the current company.");
|
||||
}
|
||||
db.deleteAutomationRule(parsed.id, parsed.companyId);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.toggleAutomationRule, async (_event, payload) => {
|
||||
const parsed = toggleAutomationRuleSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("automation_rules", parsed.id, parsed.companyId)) {
|
||||
return fail("ENTITY_NOT_FOUND", "Automation rule not found in the current company.");
|
||||
}
|
||||
db.toggleAutomationRule(parsed.id, parsed.companyId, parsed.status);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getAutomationLog, async (_event, companyId) => {
|
||||
const parsed = companyIdSchema.parse(companyId);
|
||||
const companyError = ensureCompanyExists(parsed);
|
||||
if (companyError) return companyError;
|
||||
return ok(db.getAutomationLog(parsed));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.saveWorkflow, async (_event, payload) => {
|
||||
const parsed = workflowInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const id = db.saveWorkflow(parsed);
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.deleteWorkflow, async (_event, payload) => {
|
||||
const parsed = deleteEntitySchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("workflow_pipelines", parsed.id, parsed.companyId)) {
|
||||
return fail("ENTITY_NOT_FOUND", "Workflow not found in the current company.");
|
||||
}
|
||||
db.deleteWorkflow(parsed.id, parsed.companyId);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.runWorkflow, async (_event, payload) => {
|
||||
const parsed = runWorkflowSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("workflow_pipelines", parsed.id, parsed.companyId)) {
|
||||
return fail("ENTITY_NOT_FOUND", "Workflow not found in the current company.");
|
||||
}
|
||||
await runWorkflowPipeline(parsed.id, parsed.companyId);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
IPC_CHANNELS,
|
||||
browserActionQuerySchema,
|
||||
cancelBrowserActionSchema,
|
||||
deleteEntitySchema,
|
||||
socialAccountInputSchema,
|
||||
triggerBrowserLoginSchema,
|
||||
} from "@shared/contracts";
|
||||
import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common";
|
||||
|
||||
export function registerBrowserHandlers({
|
||||
registerHandle,
|
||||
db,
|
||||
logger,
|
||||
ensureCompanyExists,
|
||||
publishDomainChanged,
|
||||
getBrowserActions,
|
||||
}: Pick<
|
||||
RegisterDomainHandlersDependencies,
|
||||
| "registerHandle"
|
||||
| "db"
|
||||
| "logger"
|
||||
| "ensureCompanyExists"
|
||||
| "publishDomainChanged"
|
||||
| "getBrowserActions"
|
||||
>) {
|
||||
registerHandle(IPC_CHANNELS.saveSocialAccount, (_event, payload) => {
|
||||
const input = socialAccountInputSchema.parse(payload);
|
||||
const err = ensureCompanyExists(input.companyId);
|
||||
if (err) return err;
|
||||
const id = db.saveSocialAccount(input);
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.deleteSocialAccount, async (_event, payload) => {
|
||||
const input = deleteEntitySchema.parse(payload);
|
||||
const err = ensureCompanyExists(input.companyId);
|
||||
if (err) return err;
|
||||
const browserActions = db.listBrowserActions(input.companyId, { socialAccountId: input.id, limit: 500 });
|
||||
await getBrowserActions()?.clearSocialAccountData(
|
||||
input.id,
|
||||
browserActions.flatMap((action: { screenshotPath: string | null }) => (action.screenshotPath ? [action.screenshotPath] : [])),
|
||||
);
|
||||
db.deleteSocialAccount(input.id, input.companyId);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.listBrowserActions, (_event, payload) => {
|
||||
const input = browserActionQuerySchema.parse(payload);
|
||||
const err = ensureCompanyExists(input.companyId);
|
||||
if (err) return err;
|
||||
return ok(db.listBrowserActions(input.companyId, {
|
||||
socialAccountId: input.socialAccountId,
|
||||
status: input.status,
|
||||
limit: input.limit,
|
||||
}));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.cancelBrowserAction, (_event, payload) => {
|
||||
const input = cancelBrowserActionSchema.parse(payload);
|
||||
const action = db.getBrowserAction(input.actionId, input.companyId);
|
||||
if (!action) {
|
||||
return fail("BROWSER_ACTION_NOT_FOUND", "Browser action not found.");
|
||||
}
|
||||
getBrowserActions()?.cancelAction(input.actionId);
|
||||
db.updateBrowserAction(input.actionId, {
|
||||
status: "cancelled",
|
||||
resultSummary: "Browser action cancelled from the desktop control surface.",
|
||||
errorMessage: "Action cancelled.",
|
||||
finishedAt: new Date().toISOString(),
|
||||
});
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.triggerBrowserLogin, async (_event, payload) => {
|
||||
const input = triggerBrowserLoginSchema.parse(payload);
|
||||
const err = ensureCompanyExists(input.companyId);
|
||||
if (err) return err;
|
||||
const browserActions = getBrowserActions();
|
||||
if (!browserActions) {
|
||||
return fail("BROWSER_NOT_READY", "Browser manager not initialized");
|
||||
}
|
||||
|
||||
const account = db.getSocialAccount(input.socialAccountId, input.companyId);
|
||||
if (!account) {
|
||||
return fail("ACCOUNT_NOT_FOUND", "Social account not found");
|
||||
}
|
||||
|
||||
browserActions.launchLoginBrowser(input.socialAccountId, account.platform).then((result) => {
|
||||
if (result.success) {
|
||||
const latestAccount = db.getSocialAccount(input.socialAccountId, account.companyId);
|
||||
if (!latestAccount) {
|
||||
return;
|
||||
}
|
||||
db.saveSocialAccount({
|
||||
id: latestAccount.id,
|
||||
companyId: latestAccount.companyId,
|
||||
platform: latestAccount.platform,
|
||||
accountName: latestAccount.accountName,
|
||||
displayName: latestAccount.displayName,
|
||||
profileUrl: latestAccount.profileUrl,
|
||||
credentialSecretId: latestAccount.credentialSecretId,
|
||||
status: "logged_in",
|
||||
requireApproval: latestAccount.requireApproval,
|
||||
});
|
||||
publishDomainChanged();
|
||||
}
|
||||
}).catch((error) => {
|
||||
logger.error(`[browser] Login failed: ${error}`);
|
||||
});
|
||||
|
||||
return ok(true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
IPC_CHANNELS,
|
||||
deleteEntitySchema,
|
||||
documentInputSchema,
|
||||
knowledgeEntryInputSchema,
|
||||
meetingInputSchema,
|
||||
sprintInputSchema,
|
||||
} from "@shared/contracts";
|
||||
import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common";
|
||||
|
||||
const COMPANY_SCOPED_REFERENCE_TABLES = new Set([
|
||||
"agents",
|
||||
"goals",
|
||||
"projects",
|
||||
"tasks",
|
||||
"approvals",
|
||||
"runs",
|
||||
"secrets",
|
||||
"meetings",
|
||||
"documents",
|
||||
"knowledge_base",
|
||||
"sprints",
|
||||
"automation_rules",
|
||||
"workflow_pipelines",
|
||||
]);
|
||||
|
||||
export function registerContentOperationsHandlers({
|
||||
registerHandle,
|
||||
db,
|
||||
ensureCompanyExists,
|
||||
findInvalidReference,
|
||||
publishDomainChanged,
|
||||
wakeAgentIfPossible,
|
||||
dispatchAutomation,
|
||||
}: Pick<
|
||||
RegisterDomainHandlersDependencies,
|
||||
| "registerHandle"
|
||||
| "db"
|
||||
| "ensureCompanyExists"
|
||||
| "findInvalidReference"
|
||||
| "publishDomainChanged"
|
||||
| "wakeAgentIfPossible"
|
||||
| "dispatchAutomation"
|
||||
>) {
|
||||
registerHandle(IPC_CHANNELS.saveMeeting, async (_event, payload) => {
|
||||
const parsed = meetingInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
let participantAgentIds: string[] = [];
|
||||
try {
|
||||
const parsedParticipantIds = JSON.parse(parsed.participantAgentIds);
|
||||
participantAgentIds = Array.isArray(parsedParticipantIds)
|
||||
? parsedParticipantIds.filter((value): value is string => typeof value === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return fail("INVALID_PARTICIPANTS", "Meeting participants must be valid JSON.");
|
||||
}
|
||||
const invalidReference = findInvalidReference(parsed.companyId, [
|
||||
{ id: parsed.organizerAgentId, table: "agents", label: "meeting organizer" },
|
||||
...participantAgentIds.map((agentId) => ({ id: agentId, table: "agents", label: "meeting participant" })),
|
||||
]);
|
||||
if (invalidReference) {
|
||||
return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`);
|
||||
}
|
||||
const id = db.saveMeeting(parsed);
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.deleteMeeting, async (_event, payload) => {
|
||||
const parsed = deleteEntitySchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("meetings", parsed.id, parsed.companyId)) {
|
||||
return fail("ENTITY_NOT_FOUND", "Meeting not found in the current company.");
|
||||
}
|
||||
db.deleteMeeting(parsed.id, parsed.companyId);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.saveDocument, async (_event, payload) => {
|
||||
const parsed = documentInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const invalidReference = findInvalidReference(parsed.companyId, [
|
||||
{ id: parsed.authorAgentId, table: "agents", label: "document author" },
|
||||
{ id: parsed.reviewerAgentId, table: "agents", label: "document reviewer" },
|
||||
{ id: parsed.projectId, table: "projects", label: "document project" },
|
||||
{ id: parsed.goalId, table: "goals", label: "document goal" },
|
||||
{ id: parsed.parentDocId, table: "documents", label: "parent document" },
|
||||
]);
|
||||
if (invalidReference) {
|
||||
return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`);
|
||||
}
|
||||
const isNew = !parsed.id;
|
||||
const id = db.saveDocument(parsed);
|
||||
if (isNew) {
|
||||
dispatchAutomation("document_created", {
|
||||
companyId: parsed.companyId,
|
||||
agentId: parsed.authorAgentId ?? undefined,
|
||||
projectId: parsed.projectId ?? undefined,
|
||||
});
|
||||
if (parsed.authorAgentId && parsed.reviewerAgentId) {
|
||||
wakeAgentIfPossible(parsed.reviewerAgentId, parsed.companyId, "assignment");
|
||||
}
|
||||
}
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.deleteDocument, async (_event, payload) => {
|
||||
const parsed = deleteEntitySchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("documents", parsed.id, parsed.companyId)) {
|
||||
return fail("ENTITY_NOT_FOUND", "Document not found in the current company.");
|
||||
}
|
||||
db.deleteDocument(parsed.id, parsed.companyId);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.saveKnowledgeEntry, async (_event, payload) => {
|
||||
const parsed = knowledgeEntryInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const knowledgeReferences = [
|
||||
{ id: parsed.authorAgentId, table: "agents", label: "knowledge author" },
|
||||
...(parsed.referencedEntityId && parsed.referencedEntityType
|
||||
&& COMPANY_SCOPED_REFERENCE_TABLES.has(parsed.referencedEntityType)
|
||||
? [{ id: parsed.referencedEntityId, table: parsed.referencedEntityType, label: "referenced entity" }]
|
||||
: []),
|
||||
];
|
||||
const invalidReference = findInvalidReference(parsed.companyId, [
|
||||
...knowledgeReferences,
|
||||
]);
|
||||
if (invalidReference) {
|
||||
return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`);
|
||||
}
|
||||
const id = db.saveKnowledgeEntry(parsed);
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.deleteKnowledgeEntry, async (_event, payload) => {
|
||||
const parsed = deleteEntitySchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("knowledge_base", parsed.id, parsed.companyId)) {
|
||||
return fail("ENTITY_NOT_FOUND", "Knowledge entry not found in the current company.");
|
||||
}
|
||||
db.deleteKnowledgeEntry(parsed.id, parsed.companyId);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.saveSprint, async (_event, payload) => {
|
||||
const parsed = sprintInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
let previousSprintStatus: string | null = null;
|
||||
if (parsed.id) {
|
||||
try {
|
||||
previousSprintStatus = db.listSnapshot().sprints.find((s: { id: string }) => s.id === parsed.id)?.status ?? null;
|
||||
} catch {
|
||||
previousSprintStatus = null;
|
||||
}
|
||||
}
|
||||
const id = db.saveSprint(parsed);
|
||||
if (parsed.status === "active" && previousSprintStatus !== "active") {
|
||||
dispatchAutomation("sprint_started", { companyId: parsed.companyId });
|
||||
}
|
||||
if (parsed.status === "completed" && previousSprintStatus !== "completed") {
|
||||
dispatchAutomation("sprint_ended", { companyId: parsed.companyId });
|
||||
}
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.deleteSprint, async (_event, payload) => {
|
||||
const parsed = deleteEntitySchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("sprints", parsed.id, parsed.companyId)) {
|
||||
return fail("ENTITY_NOT_FOUND", "Sprint not found in the current company.");
|
||||
}
|
||||
db.deleteSprint(parsed.id, parsed.companyId);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
IPC_CHANNELS,
|
||||
companyInputSchema,
|
||||
connectorInputSchema,
|
||||
deleteEntitySchema,
|
||||
secretInputSchema,
|
||||
} from "@shared/contracts";
|
||||
import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common";
|
||||
|
||||
export function registerCoreAdminHandlers({
|
||||
registerHandle,
|
||||
db,
|
||||
ensureCompanyExists,
|
||||
publishDomainChanged,
|
||||
checkConnector,
|
||||
openConnectorAuthTerminal,
|
||||
}: Pick<
|
||||
RegisterCoreHandlersDependencies,
|
||||
"registerHandle" | "db" | "ensureCompanyExists" | "publishDomainChanged" | "checkConnector" | "openConnectorAuthTerminal"
|
||||
>) {
|
||||
registerHandle(IPC_CHANNELS.saveCompany, async (_event, payload) => {
|
||||
const parsed = companyInputSchema.parse(payload);
|
||||
const id = db.saveCompany(parsed);
|
||||
db.addActivity({
|
||||
companyId: id,
|
||||
actor: "board",
|
||||
action: parsed.id ? "company.updated" : "company.created",
|
||||
entityType: "company",
|
||||
entityId: id,
|
||||
detail: parsed.name,
|
||||
});
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.setCurrentCompany, async (_event, companyId) => {
|
||||
if (typeof companyId !== "string" || !companyId) {
|
||||
return fail("INVALID_COMPANY_ID", "A valid company id is required.");
|
||||
}
|
||||
if (!db.hasCompany(companyId)) {
|
||||
return fail("COMPANY_NOT_FOUND", "The selected company does not exist.");
|
||||
}
|
||||
db.setCurrentCompany(companyId);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.saveConnector, async (_event, payload) => {
|
||||
const parsed = connectorInputSchema.parse(payload);
|
||||
const existingConnector = db.getConnector(parsed.id);
|
||||
if (parsed.command !== existingConnector.command) {
|
||||
return fail("CONNECTOR_COMMAND_LOCKED", `Connector command is managed by AgentCompany and must remain "${existingConnector.command}".`);
|
||||
}
|
||||
db.saveConnector({
|
||||
...parsed,
|
||||
command: existingConnector.command,
|
||||
model: parsed.model ?? null,
|
||||
});
|
||||
db.addActivity({
|
||||
companyId: db.getCurrentCompanyId() ?? "00000000-0000-0000-0000-000000000000",
|
||||
actor: "board",
|
||||
action: "connector.updated",
|
||||
entityType: "connector",
|
||||
entityId: parsed.id,
|
||||
detail: existingConnector.command,
|
||||
});
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.testConnector, async (_event, id) => {
|
||||
const parsed = connectorInputSchema.shape.id.parse(id);
|
||||
await checkConnector(parsed);
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.openConnectorAuthTerminal, async (_event, id) => {
|
||||
const parsed = connectorInputSchema.shape.id.parse(id);
|
||||
await openConnectorAuthTerminal(parsed);
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.saveSecret, async (_event, payload) => {
|
||||
const parsed = secretInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const id = db.saveSecret(parsed);
|
||||
db.addActivity({
|
||||
companyId: parsed.companyId,
|
||||
actor: "board",
|
||||
action: parsed.id ? "secret.updated" : "secret.created",
|
||||
entityType: "secret",
|
||||
entityId: id,
|
||||
detail: parsed.name,
|
||||
});
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.deleteSecret, async (_event, payload) => {
|
||||
const parsed = deleteEntitySchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("secrets", parsed.id, parsed.companyId)) {
|
||||
return fail("SECRET_NOT_FOUND", "Secret not found in the current company.");
|
||||
}
|
||||
db.deleteSecret(parsed.id, parsed.companyId);
|
||||
db.addActivity({
|
||||
companyId: parsed.companyId,
|
||||
actor: "board",
|
||||
action: "secret.deleted",
|
||||
entityType: "secret",
|
||||
entityId: parsed.id,
|
||||
detail: "",
|
||||
});
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.deleteCompany, async (_event, companyId) => {
|
||||
if (typeof companyId !== "string" || companyId.length === 0) {
|
||||
return fail("INVALID_INPUT", "Company ID is required.");
|
||||
}
|
||||
if (!db.hasCompany(companyId)) {
|
||||
return fail("COMPANY_NOT_FOUND", "Company not found.");
|
||||
}
|
||||
db.deleteCompany(companyId);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { IpcMainInvokeEvent } from "electron";
|
||||
import type { DesktopResult } from "@shared/contracts";
|
||||
import type { AppDatabase } from "./database";
|
||||
|
||||
export type CompanyScopedTable = "workspaces" | "agents" | "goals" | "projects" | "tasks" | "approvals" | "runs" | "secrets";
|
||||
|
||||
export interface RegisterCoreHandlersDependencies {
|
||||
registerHandle: (
|
||||
channel: string,
|
||||
handler: (event: IpcMainInvokeEvent, ...args: unknown[]) => Promise<DesktopResult<unknown>> | DesktopResult<unknown>,
|
||||
) => void;
|
||||
db: AppDatabase;
|
||||
ensureCompanyExists: (companyId: string) => DesktopResult<never> | null;
|
||||
findInvalidReference: (
|
||||
companyId: string,
|
||||
refs: Array<{ id: string | null | undefined; table: CompanyScopedTable; label: string }>,
|
||||
) => { id: string | null | undefined; table: CompanyScopedTable; label: string } | null;
|
||||
publishDomainChanged: () => void;
|
||||
emitEvent: (event: any) => void;
|
||||
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
|
||||
handleTaskCreated: (taskId: string, companyId: string) => void;
|
||||
handleTaskStatusChange: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void;
|
||||
handleApprovalResolved: (approvalId: string, companyId: string, decision: string) => void;
|
||||
dispatchAutomation: (trigger: string, ctx: { companyId: string; [key: string]: unknown }) => void;
|
||||
queueTaskRun: (taskId: string, trigger: string, actor: string) => DesktopResult<string>;
|
||||
checkConnector: (id: any) => Promise<void>;
|
||||
openConnectorAuthTerminal: (id: any) => Promise<boolean>;
|
||||
checkForUpdates: () => Promise<boolean>;
|
||||
getWorkerProcess: () => { postMessage: (message: unknown) => void } | null;
|
||||
resetAfterRestore: () => void;
|
||||
getApiPort: () => number;
|
||||
listConnectorModels: (connectorId: string) => Promise<Array<{ id: string; label: string }>>;
|
||||
}
|
||||
|
||||
export function ok<T>(data: T): DesktopResult<T> {
|
||||
return { ok: true, data };
|
||||
}
|
||||
|
||||
export function fail(code: string, message: string): DesktopResult<never> {
|
||||
return { ok: false, error: { code, message } };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { RegisterCoreHandlersDependencies } from "./ipc-core-common";
|
||||
import { registerCoreAdminHandlers } from "./ipc-core-admin-handlers";
|
||||
import { registerCoreOperationHandlers } from "./ipc-core-operations-handlers";
|
||||
import { registerCoreReadHandlers } from "./ipc-core-read-handlers";
|
||||
import { registerCoreWorkGraphHandlers } from "./ipc-core-work-graph-handlers";
|
||||
|
||||
export function registerCoreHandlers(dependencies: RegisterCoreHandlersDependencies) {
|
||||
registerCoreReadHandlers(dependencies);
|
||||
registerCoreAdminHandlers(dependencies);
|
||||
registerCoreWorkGraphHandlers(dependencies);
|
||||
registerCoreOperationHandlers(dependencies);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import {
|
||||
IPC_CHANNELS,
|
||||
approvalDecisionSchema,
|
||||
approvalInputSchema,
|
||||
cancelRunSchema,
|
||||
commentInputSchema,
|
||||
heartbeatSettingsSchema,
|
||||
listCommentsSchema,
|
||||
runLogChunkSchema,
|
||||
runLogSchema,
|
||||
startRunSchema,
|
||||
triggerHeartbeatSchema,
|
||||
} from "@shared/contracts";
|
||||
import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common";
|
||||
|
||||
export function registerCoreOperationHandlers({
|
||||
registerHandle,
|
||||
db,
|
||||
ensureCompanyExists,
|
||||
publishDomainChanged,
|
||||
emitEvent,
|
||||
wakeAgentIfPossible,
|
||||
handleApprovalResolved,
|
||||
dispatchAutomation,
|
||||
queueTaskRun,
|
||||
getWorkerProcess,
|
||||
}: Pick<
|
||||
RegisterCoreHandlersDependencies,
|
||||
| "registerHandle"
|
||||
| "db"
|
||||
| "ensureCompanyExists"
|
||||
| "publishDomainChanged"
|
||||
| "emitEvent"
|
||||
| "wakeAgentIfPossible"
|
||||
| "handleApprovalResolved"
|
||||
| "dispatchAutomation"
|
||||
| "queueTaskRun"
|
||||
| "getWorkerProcess"
|
||||
>) {
|
||||
registerHandle(IPC_CHANNELS.requestApproval, async (_event, payload) => {
|
||||
const parsed = approvalInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const invalidTask = parsed.relatedTaskId && !db.belongsToCompany("tasks", parsed.relatedTaskId, parsed.companyId);
|
||||
const invalidAgent = parsed.requestedByAgentId && !db.belongsToCompany("agents", parsed.requestedByAgentId, parsed.companyId);
|
||||
if (invalidTask || invalidAgent) {
|
||||
return fail("INVALID_COMPANY_REFERENCE", "The approval references data outside the current company.");
|
||||
}
|
||||
const id = db.requestApproval(parsed);
|
||||
db.addActivity({
|
||||
companyId: parsed.companyId,
|
||||
actor: parsed.requestedByAgentId ?? "board",
|
||||
action: "approval.created",
|
||||
entityType: "approval",
|
||||
entityId: id,
|
||||
detail: parsed.payloadSummary,
|
||||
});
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.decideApproval, async (_event, payload) => {
|
||||
const parsed = approvalDecisionSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("approvals", parsed.approvalId, parsed.companyId)) {
|
||||
return fail("APPROVAL_NOT_FOUND", "Approval not found in the current company.");
|
||||
}
|
||||
const approval = db.getApprovalRecord(parsed.approvalId);
|
||||
if (!approval || approval.companyId !== parsed.companyId) {
|
||||
return fail("APPROVAL_NOT_FOUND", "Approval not found in the current company.");
|
||||
}
|
||||
db.decideApproval(parsed);
|
||||
db.addActivity({
|
||||
companyId: approval.companyId,
|
||||
actor: "board",
|
||||
action: `approval.${parsed.state}`,
|
||||
entityType: "approval",
|
||||
entityId: approval.id,
|
||||
detail: parsed.decisionNote,
|
||||
});
|
||||
handleApprovalResolved(parsed.approvalId, approval.companyId, parsed.state);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.startTaskRun, async (_event, payload) => {
|
||||
const parsed = startRunSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("tasks", parsed.taskId, parsed.companyId)) {
|
||||
return fail("TASK_NOT_FOUND", "Task not found in the current company.");
|
||||
}
|
||||
return queueTaskRun(parsed.taskId, "manual", "board");
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.cancelRun, (_event, payload) => {
|
||||
const parsed = cancelRunSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("runs", parsed.runId, parsed.companyId)) {
|
||||
return fail("RUN_NOT_FOUND", "Run not found in the current company.");
|
||||
}
|
||||
getWorkerProcess()?.postMessage({ type: "cancel-run", runId: parsed.runId });
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getRunLog, (_event, payload) => {
|
||||
const parsed = runLogSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("runs", parsed.runId, parsed.companyId)) {
|
||||
return fail("RUN_NOT_FOUND", "Run not found in the current company.");
|
||||
}
|
||||
return ok(db.readRunLog(parsed.runId));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getRunLogChunk, (_event, payload) => {
|
||||
const parsed = runLogChunkSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("runs", parsed.runId, parsed.companyId)) {
|
||||
return fail("RUN_NOT_FOUND", "Run not found in the current company.");
|
||||
}
|
||||
return ok(db.readRunLogChunk(parsed.runId, parsed.offset, parsed.limit));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.addComment, async (_event, payload) => {
|
||||
const parsed = commentInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const comment = db.addComment({
|
||||
companyId: parsed.companyId,
|
||||
taskId: parsed.taskId,
|
||||
authorAgentId: parsed.authorAgentId ?? null,
|
||||
authorName: parsed.authorName,
|
||||
body: parsed.body,
|
||||
});
|
||||
db.addActivity({
|
||||
companyId: parsed.companyId,
|
||||
actor: parsed.authorAgentId ?? "board",
|
||||
action: "comment.created",
|
||||
entityType: "task",
|
||||
entityId: parsed.taskId,
|
||||
detail: parsed.body.slice(0, 100),
|
||||
});
|
||||
emitEvent({ type: "agent-message", taskId: parsed.taskId, comment });
|
||||
publishDomainChanged();
|
||||
try {
|
||||
const task = db.getTask(parsed.taskId);
|
||||
if (task.assigneeAgentId) {
|
||||
wakeAgentIfPossible(task.assigneeAgentId, task.companyId, "comment");
|
||||
}
|
||||
dispatchAutomation("comment_posted", {
|
||||
companyId: parsed.companyId,
|
||||
taskId: parsed.taskId,
|
||||
agentId: parsed.authorAgentId ?? undefined,
|
||||
});
|
||||
} catch {
|
||||
// Task may have been deleted between comment creation and wake evaluation.
|
||||
}
|
||||
return ok(comment.id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.listComments, async (_event, payload) => {
|
||||
const parsed = listCommentsSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("tasks", parsed.taskId, parsed.companyId)) {
|
||||
return fail("TASK_NOT_FOUND", "Task not found in the current company.");
|
||||
}
|
||||
return ok(db.listComments(parsed.taskId));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.setHeartbeat, async (_event, payload) => {
|
||||
const parsed = heartbeatSettingsSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("agents", parsed.agentId, parsed.companyId)) {
|
||||
return fail("AGENT_NOT_FOUND", "Agent not found in the current company.");
|
||||
}
|
||||
db.setHeartbeat(parsed.agentId, parsed.enabled, parsed.intervalSec);
|
||||
db.addActivity({
|
||||
companyId: parsed.companyId,
|
||||
actor: "board",
|
||||
action: parsed.enabled ? "heartbeat.enabled" : "heartbeat.disabled",
|
||||
entityType: "agent",
|
||||
entityId: parsed.agentId,
|
||||
detail: `Interval: ${parsed.intervalSec}s`,
|
||||
});
|
||||
emitEvent({ type: "heartbeat-status", agentId: parsed.agentId, enabled: parsed.enabled, nextWakeAt: null });
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.triggerHeartbeat, async (_event, payload) => {
|
||||
const parsed = triggerHeartbeatSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("agents", parsed.agentId, parsed.companyId)) {
|
||||
return fail("AGENT_NOT_FOUND", "Agent not found in the current company.");
|
||||
}
|
||||
const runId = wakeAgentIfPossible(parsed.agentId, parsed.companyId, parsed.trigger);
|
||||
if (!runId) {
|
||||
return fail("HEARTBEAT_SKIPPED", "Agent cannot be woken right now (check connector status, budget, or active runs).");
|
||||
}
|
||||
return ok(runId);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { dialog, shell } from "electron";
|
||||
import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
IPC_CHANNELS,
|
||||
connectorInputSchema,
|
||||
metricsQuerySchema,
|
||||
settingsSchema,
|
||||
} from "@shared/contracts";
|
||||
import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common";
|
||||
|
||||
export function registerCoreReadHandlers({
|
||||
registerHandle,
|
||||
db,
|
||||
ensureCompanyExists,
|
||||
publishDomainChanged,
|
||||
checkForUpdates,
|
||||
resetAfterRestore,
|
||||
getApiPort,
|
||||
listConnectorModels,
|
||||
}: Pick<
|
||||
RegisterCoreHandlersDependencies,
|
||||
| "registerHandle"
|
||||
| "db"
|
||||
| "ensureCompanyExists"
|
||||
| "publishDomainChanged"
|
||||
| "checkForUpdates"
|
||||
| "resetAfterRestore"
|
||||
| "getApiPort"
|
||||
| "listConnectorModels"
|
||||
>) {
|
||||
registerHandle(IPC_CHANNELS.loadSnapshot, () => ok(db.listRendererSnapshot()));
|
||||
|
||||
registerHandle(IPC_CHANNELS.pickDirectory, async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
});
|
||||
return ok(result.canceled ? null : (result.filePaths[0] ?? null));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.openPath, async (_event, targetPath) => {
|
||||
if (typeof targetPath !== "string" || targetPath.length === 0) {
|
||||
return fail("INVALID_PATH", "A valid path is required.");
|
||||
}
|
||||
if (!db.canOpenPath(targetPath)) {
|
||||
return fail("OPEN_PATH_FORBIDDEN", "The requested path is outside the managed desktop workspace.");
|
||||
}
|
||||
const error = await shell.openPath(targetPath);
|
||||
return error ? fail("OPEN_PATH_FAILED", error) : ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.backupProfile, async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
});
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return ok(null);
|
||||
}
|
||||
const backupPath = join(result.filePaths[0]!, `agentcompany-backup-${Date.now()}`);
|
||||
await db.exportBackup(backupPath);
|
||||
return ok(backupPath);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.restoreProfile, async () => {
|
||||
const hasActiveRuns = db.listSnapshot().runs.some((run: { status: string }) => run.status === "queued" || run.status === "running");
|
||||
if (hasActiveRuns) {
|
||||
return fail("RESTORE_BLOCKED", "Stop all active runs before restoring a profile backup.");
|
||||
}
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openDirectory"],
|
||||
});
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return ok(false);
|
||||
}
|
||||
await db.restoreFromBackup(result.filePaths[0]!);
|
||||
resetAfterRestore();
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.updateSettings, async (_event, payload) => {
|
||||
const parsed = settingsSchema.parse(payload);
|
||||
db.updateSettings(parsed);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.checkForUpdates, async () => {
|
||||
await checkForUpdates();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getSidebarBadges, async (_event, companyId) => {
|
||||
if (typeof companyId !== "string" || !companyId) {
|
||||
return fail("INVALID_COMPANY_ID", "A valid company id is required.");
|
||||
}
|
||||
return ok(db.getSidebarBadges(companyId));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getApiPort, () => ok(getApiPort()));
|
||||
|
||||
registerHandle(IPC_CHANNELS.listConnectorModels, async (_event, connectorId) => {
|
||||
try {
|
||||
const parsed = connectorInputSchema.shape.id.parse(connectorId);
|
||||
return ok(await listConnectorModels(parsed));
|
||||
} catch {
|
||||
return ok([]);
|
||||
}
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getInbox, (_event, payload) => {
|
||||
const companyId = z.string().uuid().parse(payload);
|
||||
const err = ensureCompanyExists(companyId);
|
||||
if (err) return err;
|
||||
return ok(db.getInbox(companyId));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getCompanyMetrics, (_event, payload) => {
|
||||
const input = metricsQuerySchema.parse(payload);
|
||||
const err = ensureCompanyExists(input.companyId);
|
||||
if (err) return err;
|
||||
return ok(db.getCompanyMetrics(input.companyId, input.days));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getAgentMetrics, (_event, payload) => {
|
||||
const input = metricsQuerySchema.parse(payload);
|
||||
const err = ensureCompanyExists(input.companyId);
|
||||
if (err) return err;
|
||||
if (!input.agentId) {
|
||||
return fail("MISSING_AGENT_ID", "agentId is required");
|
||||
}
|
||||
const metrics = db.getCompanyMetrics(input.companyId, input.days);
|
||||
const agentMetric = metrics.agentMetrics.find((metric: { agentId: string }) => metric.agentId === input.agentId);
|
||||
if (!agentMetric) {
|
||||
return fail("AGENT_NOT_FOUND", "Agent metrics not found");
|
||||
}
|
||||
return ok(agentMetric);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getStandupReport, (_event, payload) => {
|
||||
const companyId = z.string().uuid().parse(payload);
|
||||
const err = ensureCompanyExists(companyId);
|
||||
if (err) return err;
|
||||
return ok(db.getStandupReport(companyId));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import {
|
||||
IPC_CHANNELS,
|
||||
agentInputSchema,
|
||||
deleteEntitySchema,
|
||||
goalInputSchema,
|
||||
projectInputSchema,
|
||||
taskInputSchema,
|
||||
workspaceInputSchema,
|
||||
} from "@shared/contracts";
|
||||
import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common";
|
||||
|
||||
export function registerCoreWorkGraphHandlers({
|
||||
registerHandle,
|
||||
db,
|
||||
ensureCompanyExists,
|
||||
findInvalidReference,
|
||||
publishDomainChanged,
|
||||
wakeAgentIfPossible,
|
||||
handleTaskCreated,
|
||||
handleTaskStatusChange,
|
||||
}: Pick<
|
||||
RegisterCoreHandlersDependencies,
|
||||
| "registerHandle"
|
||||
| "db"
|
||||
| "ensureCompanyExists"
|
||||
| "findInvalidReference"
|
||||
| "publishDomainChanged"
|
||||
| "wakeAgentIfPossible"
|
||||
| "handleTaskCreated"
|
||||
| "handleTaskStatusChange"
|
||||
>) {
|
||||
registerHandle(IPC_CHANNELS.saveWorkspace, async (_event, payload) => {
|
||||
const parsed = workspaceInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const invalidReference = findInvalidReference(parsed.companyId, [
|
||||
{ id: parsed.projectId, table: "projects", label: "project" },
|
||||
]);
|
||||
if (invalidReference) {
|
||||
return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`);
|
||||
}
|
||||
const id = db.saveWorkspace(parsed);
|
||||
db.addActivity({
|
||||
companyId: parsed.companyId,
|
||||
actor: "board",
|
||||
action: parsed.id ? "workspace.updated" : "workspace.created",
|
||||
entityType: "workspace",
|
||||
entityId: id,
|
||||
detail: parsed.localPath,
|
||||
});
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.saveAgent, async (_event, payload) => {
|
||||
const parsed = agentInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const invalidReference = findInvalidReference(parsed.companyId, [
|
||||
{ id: parsed.reportsTo, table: "agents", label: "manager" },
|
||||
{ id: parsed.workspaceId, table: "workspaces", label: "workspace" },
|
||||
]);
|
||||
if (invalidReference) {
|
||||
return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`);
|
||||
}
|
||||
let id: string;
|
||||
try {
|
||||
id = db.saveAgent({
|
||||
id: parsed.id ?? null,
|
||||
companyId: parsed.companyId,
|
||||
name: parsed.name,
|
||||
role: parsed.role,
|
||||
title: parsed.title,
|
||||
department: parsed.department ?? null,
|
||||
status: parsed.status,
|
||||
reportsTo: parsed.reportsTo ?? null,
|
||||
connectorId: parsed.connectorId,
|
||||
workspaceId: parsed.workspaceId ?? null,
|
||||
model: parsed.model ?? null,
|
||||
metadataJson: parsed.metadataJson,
|
||||
capabilities: parsed.capabilities,
|
||||
budgetMonthlyUsd: parsed.budgetMonthlyUsd,
|
||||
});
|
||||
} catch (error) {
|
||||
return fail("INVALID_AGENT_HIERARCHY", error instanceof Error ? error.message : "Agent could not be saved.");
|
||||
}
|
||||
db.addActivity({
|
||||
companyId: parsed.companyId,
|
||||
actor: "board",
|
||||
action: parsed.id ? "agent.updated" : "agent.created",
|
||||
entityType: "agent",
|
||||
entityId: id,
|
||||
detail: parsed.name,
|
||||
});
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.saveGoal, async (_event, payload) => {
|
||||
const parsed = goalInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const invalidReference = findInvalidReference(parsed.companyId, [
|
||||
{ id: parsed.parentId, table: "goals", label: "parent goal" },
|
||||
{ id: parsed.ownerAgentId, table: "agents", label: "owner agent" },
|
||||
]);
|
||||
if (invalidReference) {
|
||||
return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`);
|
||||
}
|
||||
const id = db.saveGoal(parsed);
|
||||
db.addActivity({
|
||||
companyId: parsed.companyId,
|
||||
actor: "board",
|
||||
action: parsed.id ? "goal.updated" : "goal.created",
|
||||
entityType: "goal",
|
||||
entityId: id,
|
||||
detail: parsed.title,
|
||||
});
|
||||
publishDomainChanged();
|
||||
if (parsed.status === "active" && parsed.ownerAgentId) {
|
||||
wakeAgentIfPossible(parsed.ownerAgentId, parsed.companyId, "goal_activated");
|
||||
}
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.saveProject, async (_event, payload) => {
|
||||
const parsed = projectInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const invalidReference = findInvalidReference(parsed.companyId, [
|
||||
{ id: parsed.goalId, table: "goals", label: "goal" },
|
||||
{ id: parsed.leadAgentId, table: "agents", label: "lead agent" },
|
||||
]);
|
||||
if (invalidReference) {
|
||||
return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`);
|
||||
}
|
||||
const id = db.saveProject(parsed);
|
||||
db.addActivity({
|
||||
companyId: parsed.companyId,
|
||||
actor: "board",
|
||||
action: parsed.id ? "project.updated" : "project.created",
|
||||
entityType: "project",
|
||||
entityId: id,
|
||||
detail: parsed.name,
|
||||
});
|
||||
if (parsed.leadAgentId) {
|
||||
wakeAgentIfPossible(parsed.leadAgentId, parsed.companyId, "assignment");
|
||||
}
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.saveTask, async (_event, payload) => {
|
||||
const parsed = taskInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const invalidReference = findInvalidReference(parsed.companyId, [
|
||||
{ id: parsed.projectId, table: "projects", label: "project" },
|
||||
{ id: parsed.goalId, table: "goals", label: "goal" },
|
||||
{ id: parsed.parentId, table: "tasks", label: "parent task" },
|
||||
{ id: parsed.assigneeAgentId, table: "agents", label: "assignee agent" },
|
||||
{ id: parsed.workspaceId, table: "workspaces", label: "workspace" },
|
||||
]);
|
||||
if (invalidReference) {
|
||||
return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`);
|
||||
}
|
||||
const isNew = !parsed.id;
|
||||
let previousStatus: string | null = null;
|
||||
if (!isNew) {
|
||||
try {
|
||||
previousStatus = db.getTask(parsed.id!).status;
|
||||
} catch {
|
||||
previousStatus = null;
|
||||
}
|
||||
}
|
||||
let id: string;
|
||||
try {
|
||||
id = db.saveTask(parsed);
|
||||
} catch (error) {
|
||||
return fail("INVALID_TASK_STATE", error instanceof Error ? error.message : "Task could not be saved.");
|
||||
}
|
||||
db.addActivity({
|
||||
companyId: parsed.companyId,
|
||||
actor: "board",
|
||||
action: isNew ? "task.created" : "task.updated",
|
||||
entityType: "task",
|
||||
entityId: id,
|
||||
detail: parsed.title,
|
||||
});
|
||||
if (isNew) {
|
||||
handleTaskCreated(id, parsed.companyId);
|
||||
} else if (previousStatus && previousStatus !== parsed.status) {
|
||||
handleTaskStatusChange(id, parsed.companyId, previousStatus, parsed.status);
|
||||
}
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
const deleteHandlers = [
|
||||
{ channel: IPC_CHANNELS.deleteAgent, table: "agents", entity: "agent", deleteFn: (id: string, companyId: string) => db.deleteAgent(id, companyId) },
|
||||
{ channel: IPC_CHANNELS.deleteTask, table: "tasks", entity: "task", deleteFn: (id: string, companyId: string) => db.deleteTask(id, companyId) },
|
||||
{ channel: IPC_CHANNELS.deleteGoal, table: "goals", entity: "goal", deleteFn: (id: string, companyId: string) => db.deleteGoal(id, companyId) },
|
||||
{ channel: IPC_CHANNELS.deleteProject, table: "projects", entity: "project", deleteFn: (id: string, companyId: string) => db.deleteProject(id, companyId) },
|
||||
{ channel: IPC_CHANNELS.deleteWorkspace, table: "workspaces", entity: "workspace", deleteFn: (id: string, companyId: string) => db.deleteWorkspace(id, companyId) },
|
||||
{ channel: IPC_CHANNELS.deleteApproval, table: "approvals", entity: "approval", deleteFn: (id: string, companyId: string) => db.deleteApproval(id, companyId) },
|
||||
] as const;
|
||||
|
||||
for (const { channel, table, entity, deleteFn } of deleteHandlers) {
|
||||
registerHandle(channel, async (_event, payload) => {
|
||||
const parsed = deleteEntitySchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany(table, parsed.id, parsed.companyId)) {
|
||||
return fail(`${entity.toUpperCase()}_NOT_FOUND`, `${entity.charAt(0).toUpperCase() + entity.slice(1)} not found in the current company.`);
|
||||
}
|
||||
deleteFn(parsed.id, parsed.companyId);
|
||||
db.addActivity({
|
||||
companyId: parsed.companyId,
|
||||
actor: "board",
|
||||
action: `${entity}.deleted`,
|
||||
entityType: entity,
|
||||
entityId: parsed.id,
|
||||
detail: "",
|
||||
});
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { IpcMainInvokeEvent } from "electron";
|
||||
import { z } from "zod";
|
||||
import type { DesktopResult } from "@shared/contracts";
|
||||
import type { bootstrapOnboardingSchema } from "@shared/contracts";
|
||||
import type { AppDatabase } from "./database";
|
||||
|
||||
export interface BrowserActionControl {
|
||||
clearSocialAccountData: (socialAccountId: string, screenshotPaths?: string[]) => Promise<void>;
|
||||
cancelAction: (actionId: string) => boolean;
|
||||
launchLoginBrowser: (socialAccountId: string, platform: string, loginUrl?: string) => Promise<{
|
||||
success: boolean;
|
||||
cancelled: boolean;
|
||||
summary: string;
|
||||
screenshotPath: string | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface RegisterDomainHandlersDependencies {
|
||||
registerHandle: (
|
||||
channel: string,
|
||||
handler: (event: IpcMainInvokeEvent, ...args: unknown[]) => Promise<DesktopResult<unknown>> | DesktopResult<unknown>,
|
||||
) => void;
|
||||
db: AppDatabase;
|
||||
logger: {
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
ensureCompanyExists: (companyId: string) => DesktopResult<never> | null;
|
||||
findInvalidReference: (
|
||||
companyId: string,
|
||||
refs: Array<{ id: string | null | undefined; table: string; label: string }>,
|
||||
) => { id: string | null | undefined; table: string; label: string } | null;
|
||||
publishDomainChanged: () => void;
|
||||
emitEvent: (event: any) => void;
|
||||
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
|
||||
dispatchAutomation: (trigger: string, ctx: { companyId: string; [key: string]: unknown }) => void;
|
||||
runWorkflowPipeline: (workflowId: string, companyId: string) => Promise<void>;
|
||||
bootstrapOnboarding: (input: z.infer<typeof bootstrapOnboardingSchema>) => unknown;
|
||||
getBrowserActions: () => BrowserActionControl | null;
|
||||
randomFallbackCompanyId: () => string;
|
||||
}
|
||||
|
||||
export function ok<T>(data: T): DesktopResult<T> {
|
||||
return { ok: true, data };
|
||||
}
|
||||
|
||||
export function fail(code: string, message: string): DesktopResult<never> {
|
||||
return { ok: false, error: { code, message } };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { RegisterDomainHandlersDependencies } from "./ipc-domain-common";
|
||||
import { registerAgentCoordinationHandlers } from "./ipc-agent-coordination-handlers";
|
||||
import { registerAutomationOperationsHandlers } from "./ipc-automation-operations-handlers";
|
||||
import { registerBrowserHandlers } from "./ipc-browser-handlers";
|
||||
import { registerContentOperationsHandlers } from "./ipc-content-operations-handlers";
|
||||
import { registerOnboardingHandlers } from "./ipc-onboarding-handlers";
|
||||
|
||||
export function registerDomainHandlers(dependencies: RegisterDomainHandlersDependencies) {
|
||||
registerOnboardingHandlers(dependencies);
|
||||
registerBrowserHandlers(dependencies);
|
||||
registerContentOperationsHandlers(dependencies);
|
||||
registerAutomationOperationsHandlers(dependencies);
|
||||
registerAgentCoordinationHandlers(dependencies);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
IPC_CHANNELS,
|
||||
bootstrapOnboardingSchema,
|
||||
} from "@shared/contracts";
|
||||
import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common";
|
||||
|
||||
export function registerOnboardingHandlers({
|
||||
registerHandle,
|
||||
bootstrapOnboarding,
|
||||
}: Pick<
|
||||
RegisterDomainHandlersDependencies,
|
||||
"registerHandle" | "bootstrapOnboarding"
|
||||
>) {
|
||||
registerHandle(IPC_CHANNELS.bootstrapOnboarding, (_event, payload) => {
|
||||
const input = bootstrapOnboardingSchema.parse(payload);
|
||||
try {
|
||||
return ok(bootstrapOnboarding(input));
|
||||
} catch (error) {
|
||||
return fail("ONBOARDING_BOOTSTRAP_FAILED", error instanceof Error ? error.message : "Unable to bootstrap onboarding.");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { IpcMainInvokeEvent, BrowserWindow } from "electron";
|
||||
import { ipcMain, Notification } from "electron";
|
||||
import { ZodError } from "zod";
|
||||
import type { DesktopEvent } from "@shared/types";
|
||||
import type { DesktopResult } from "@shared/contracts";
|
||||
|
||||
interface IpcSupportDependencies {
|
||||
isDev: boolean;
|
||||
devServerOrigin: string | null;
|
||||
logger: {
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
getDb: () => {
|
||||
hasCompany: (companyId: string) => boolean;
|
||||
belongsToCompany: (table: string, id: string, companyId: string) => boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function isTrustedRendererUrl(url: string, isDev: boolean, devServerOrigin: string | null) {
|
||||
if (!url) {
|
||||
return false;
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (isDev) {
|
||||
return devServerOrigin ? parsed.origin === devServerOrigin : false;
|
||||
}
|
||||
return parsed.protocol === "app:" && parsed.hostname === "-";
|
||||
}
|
||||
|
||||
export function createIpcSupport({
|
||||
isDev,
|
||||
devServerOrigin,
|
||||
logger,
|
||||
getMainWindow,
|
||||
getDb,
|
||||
}: IpcSupportDependencies) {
|
||||
function sanitizeHandlerError(error: unknown) {
|
||||
if (error instanceof ZodError) {
|
||||
return {
|
||||
code: "IPC_INVALID_PAYLOAD",
|
||||
message: "The desktop request payload is invalid.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
code: "IPC_HANDLER_FAILED",
|
||||
message: "The desktop operation failed. Review the application logs for details.",
|
||||
};
|
||||
}
|
||||
|
||||
function fail(code: string, message: string): DesktopResult<never> {
|
||||
return { ok: false, error: { code, message } };
|
||||
}
|
||||
|
||||
function registerHandle(
|
||||
channel: string,
|
||||
handler: (event: IpcMainInvokeEvent, ...args: unknown[]) => Promise<DesktopResult<unknown>> | DesktopResult<unknown>,
|
||||
) {
|
||||
ipcMain.handle(channel, async (event, ...args) => {
|
||||
const senderUrl = event.senderFrame?.url ?? event.sender.getURL();
|
||||
if (!isTrustedRendererUrl(senderUrl, isDev, devServerOrigin)) {
|
||||
logger.error(`[ipc:${channel}] blocked untrusted sender ${senderUrl || "<empty>"}`);
|
||||
return fail("IPC_FORBIDDEN", "IPC sender is not allowed.");
|
||||
}
|
||||
try {
|
||||
return await handler(event, ...args);
|
||||
} catch (error) {
|
||||
const rawMessage = error instanceof Error ? error.message : String(error);
|
||||
const sanitized = sanitizeHandlerError(error);
|
||||
logger.error(`[ipc:${channel}] ${rawMessage}`);
|
||||
return fail(sanitized.code, sanitized.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function emitEvent(event: DesktopEvent) {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("agent-company:event", event);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureCompanyExists(companyId: string) {
|
||||
return getDb().hasCompany(companyId)
|
||||
? null
|
||||
: fail("COMPANY_NOT_FOUND", "The selected company no longer exists.");
|
||||
}
|
||||
|
||||
function findInvalidReference(
|
||||
companyId: string,
|
||||
refs: Array<{ id: string | null | undefined; table: string; label: string }>,
|
||||
) {
|
||||
return refs.find((ref) => ref.id && !getDb().belongsToCompany(ref.table, ref.id, companyId)) ?? null;
|
||||
}
|
||||
|
||||
function publishDomainChanged() {
|
||||
emitEvent({ type: "domain-changed" });
|
||||
}
|
||||
|
||||
function notify(title: string, body: string) {
|
||||
if (Notification.isSupported()) {
|
||||
new Notification({ title, body }).show();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fail,
|
||||
registerHandle,
|
||||
emitEvent,
|
||||
ensureCompanyExists,
|
||||
findInvalidReference,
|
||||
publishDomainChanged,
|
||||
notify,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { delimiter as PATH_DELIMITER } from "node:path";
|
||||
import { app, protocol } from "electron";
|
||||
|
||||
function listExtraPathEntries() {
|
||||
return (
|
||||
process.platform === "win32"
|
||||
? [
|
||||
`${process.env.LOCALAPPDATA ?? ""}\\Programs`,
|
||||
`${process.env.APPDATA ?? ""}\\npm`,
|
||||
`${process.env.USERPROFILE ?? ""}\\.cargo\\bin`,
|
||||
`${process.env.USERPROFILE ?? ""}\\.local\\bin`,
|
||||
`${process.env.USERPROFILE ?? ""}\\.bun\\bin`,
|
||||
]
|
||||
: [
|
||||
"/usr/local/bin",
|
||||
"/opt/homebrew/bin",
|
||||
"/opt/homebrew/sbin",
|
||||
`${process.env.HOME}/.local/bin`,
|
||||
`${process.env.HOME}/.cargo/bin`,
|
||||
`${process.env.HOME}/.nvm/current/bin`,
|
||||
`${process.env.HOME}/.volta/bin`,
|
||||
`${process.env.HOME}/.bun/bin`,
|
||||
"/usr/local/sbin",
|
||||
"/snap/bin",
|
||||
]
|
||||
).filter((entry) => entry && !entry.includes("undefined"));
|
||||
}
|
||||
|
||||
function prependCommonCliPaths() {
|
||||
const currentPath = process.env.PATH ?? "";
|
||||
const existing = new Set(currentPath.split(PATH_DELIMITER).filter(Boolean));
|
||||
const toAdd = listExtraPathEntries().filter((entry) => !existing.has(entry));
|
||||
if (toAdd.length === 0) {
|
||||
return;
|
||||
}
|
||||
process.env.PATH = [...toAdd, currentPath].join(PATH_DELIMITER);
|
||||
}
|
||||
|
||||
function registerPrivilegedSchemes() {
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: "app",
|
||||
privileges: {
|
||||
secure: true,
|
||||
standard: true,
|
||||
supportFetchAPI: true,
|
||||
stream: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
export function configureMainProcess(appName: string) {
|
||||
app.name = appName;
|
||||
app.enableSandbox();
|
||||
prependCommonCliPaths();
|
||||
registerPrivilegedSchemes();
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import log from "electron-log/main.js";
|
||||
import type { AgentApiServer } from "./api-server";
|
||||
import { initializeAgentTokenSigningKey } from "./agent-auth";
|
||||
import { bootstrapApplication } from "./bootstrap-application";
|
||||
import { openConnectorAuthTerminal as openConnectorAuthTerminalImpl } from "./connector-auth-service";
|
||||
import { getConnectorDefinition } from "./connectors";
|
||||
import type { AppDatabase } from "./database";
|
||||
import { seedDefaultAutomationRules as seedDefaultAutomationRulesImpl } from "./default-automation-rules";
|
||||
import { registerCoreHandlers } from "./ipc-core-handlers";
|
||||
import { registerDomainHandlers } from "./ipc-domain-handlers";
|
||||
|
||||
type BootstrapResult = Awaited<ReturnType<typeof bootstrapApplication>>;
|
||||
|
||||
interface MainRuntimeState {
|
||||
db: AppDatabase | null;
|
||||
apiServer: AgentApiServer | null;
|
||||
serviceGraph: BootstrapResult["serviceGraph"] | null;
|
||||
ipcSupport: BootstrapResult["ipcSupport"] | null;
|
||||
}
|
||||
|
||||
export function createMainRuntimeSupport(state: MainRuntimeState) {
|
||||
function requireDb() {
|
||||
if (!state.db) {
|
||||
throw new Error("Database is not initialized.");
|
||||
}
|
||||
return state.db;
|
||||
}
|
||||
|
||||
function requireServiceGraph() {
|
||||
if (!state.serviceGraph) {
|
||||
throw new Error("Service graph is not initialized.");
|
||||
}
|
||||
return state.serviceGraph;
|
||||
}
|
||||
|
||||
function requireIpcSupport() {
|
||||
if (!state.ipcSupport) {
|
||||
throw new Error("IPC support is not initialized.");
|
||||
}
|
||||
return state.ipcSupport;
|
||||
}
|
||||
|
||||
function requireAutomationService() {
|
||||
const service = requireServiceGraph().getAutomationService();
|
||||
if (!service) {
|
||||
throw new Error("Automation service is not initialized.");
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
function requireBrowserActionService() {
|
||||
const service = requireServiceGraph().getBrowserActionService();
|
||||
if (!service) {
|
||||
throw new Error("Browser action service is not initialized.");
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
function requireConnectorHealthService() {
|
||||
const service = requireServiceGraph().getConnectorHealthService();
|
||||
if (!service) {
|
||||
throw new Error("Connector health service is not initialized.");
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
function requireOnboardingService() {
|
||||
const service = requireServiceGraph().getOnboardingService();
|
||||
if (!service) {
|
||||
throw new Error("Onboarding service is not initialized.");
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
function requireOrchestrationService() {
|
||||
const service = requireServiceGraph().getOrchestrationService();
|
||||
if (!service) {
|
||||
throw new Error("Orchestration service is not initialized.");
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
function requireUpdaterService() {
|
||||
const service = requireServiceGraph().getUpdaterService();
|
||||
if (!service) {
|
||||
throw new Error("Updater service is not initialized.");
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
function emitEvent(event: Parameters<BootstrapResult["ipcSupport"]["emitEvent"]>[0]) {
|
||||
requireIpcSupport().emitEvent(event);
|
||||
}
|
||||
|
||||
function publishDomainChanged() {
|
||||
requireIpcSupport().publishDomainChanged();
|
||||
}
|
||||
|
||||
function ensureCompanyExists(companyId: string) {
|
||||
return requireIpcSupport().ensureCompanyExists(companyId);
|
||||
}
|
||||
|
||||
function findInvalidReference<T extends { id: string | null | undefined; table: string; label: string }>(
|
||||
companyId: string,
|
||||
refs: T[],
|
||||
) {
|
||||
return requireIpcSupport().findInvalidReference(companyId, refs) as T | null;
|
||||
}
|
||||
|
||||
function seedDefaultAutomationRules(companyId: string) {
|
||||
const db = requireDb();
|
||||
seedDefaultAutomationRulesImpl(companyId, {
|
||||
listAutomationRuleNames: (targetCompanyId) => db.listAutomationRuleNames(targetCompanyId),
|
||||
saveAutomationRule: (input) => db.saveAutomationRule(input),
|
||||
});
|
||||
}
|
||||
|
||||
function registerHandlers() {
|
||||
registerCoreHandlers({
|
||||
registerHandle: requireIpcSupport().registerHandle,
|
||||
db: requireDb(),
|
||||
ensureCompanyExists,
|
||||
findInvalidReference,
|
||||
publishDomainChanged,
|
||||
emitEvent,
|
||||
wakeAgentIfPossible: (agentId, companyId, trigger) =>
|
||||
requireOrchestrationService().wakeAgentIfPossible(agentId, companyId, trigger),
|
||||
handleTaskCreated: (taskId, companyId) => requireAutomationService().handleTaskCreated(taskId, companyId),
|
||||
handleTaskStatusChange: (taskId, companyId, previousStatus, newStatus) =>
|
||||
requireAutomationService().handleTaskStatusChange(taskId, companyId, previousStatus, newStatus),
|
||||
handleApprovalResolved: (approvalId, companyId, decision) =>
|
||||
requireAutomationService().handleApprovalResolved(approvalId, companyId, decision),
|
||||
dispatchAutomation: (trigger, ctx) => requireAutomationService().dispatchAutomation(trigger, ctx),
|
||||
queueTaskRun: (taskId, trigger, actor) =>
|
||||
requireOrchestrationService().queueTaskRun(taskId, trigger, actor),
|
||||
checkConnector: async (id) => requireConnectorHealthService().checkConnector(id),
|
||||
openConnectorAuthTerminal: async (id) => {
|
||||
const connector = requireDb().getConnector(id);
|
||||
return openConnectorAuthTerminalImpl(id, connector.command);
|
||||
},
|
||||
checkForUpdates: () => requireUpdaterService().checkForUpdates(),
|
||||
getWorkerProcess: () => requireServiceGraph().getWorkerService()?.getWorker() ?? null,
|
||||
resetAfterRestore: () => {
|
||||
initializeAgentTokenSigningKey(requireDb().profileDir);
|
||||
requireBrowserActionService().resetBrowserManager();
|
||||
},
|
||||
getApiPort: () => state.apiServer?.getPort() ?? 0,
|
||||
listConnectorModels: async (connectorId) => {
|
||||
const definition = getConnectorDefinition(connectorId as Parameters<typeof getConnectorDefinition>[0]);
|
||||
return definition.listModels ? definition.listModels() : [];
|
||||
},
|
||||
});
|
||||
|
||||
registerDomainHandlers({
|
||||
registerHandle: requireIpcSupport().registerHandle,
|
||||
db: requireDb(),
|
||||
logger: log,
|
||||
ensureCompanyExists,
|
||||
findInvalidReference,
|
||||
publishDomainChanged,
|
||||
emitEvent,
|
||||
wakeAgentIfPossible: (agentId, companyId, trigger) =>
|
||||
requireOrchestrationService().wakeAgentIfPossible(agentId, companyId, trigger),
|
||||
dispatchAutomation: (trigger, ctx) => requireAutomationService().dispatchAutomation(trigger, ctx),
|
||||
runWorkflowPipeline: (workflowId, companyId) =>
|
||||
requireAutomationService().runWorkflowPipeline(workflowId, companyId),
|
||||
bootstrapOnboarding: (input) => requireOnboardingService().bootstrapOnboarding(input),
|
||||
getBrowserActions: () => requireServiceGraph().getBrowserActionService(),
|
||||
randomFallbackCompanyId: () => requireConnectorHealthService().randomFallbackCompanyId(),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
publishDomainChanged,
|
||||
seedDefaultAutomationRules,
|
||||
registerHandlers,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { AgentApiServer } from "./api-server";
|
||||
import { createAgentToken, initializeAgentTokenSigningKey } from "./agent-auth";
|
||||
import { bootstrapApplication } from "./bootstrap-application";
|
||||
import { AppDatabase } from "./database";
|
||||
import { createMainRuntimeSupport } from "./main-runtime-support";
|
||||
|
||||
interface CreateMainRuntimeDependencies {
|
||||
hasSingleInstanceLock: boolean;
|
||||
isDev: boolean;
|
||||
devServerOrigin: string | null;
|
||||
profileDir: string;
|
||||
windowStatePath: string;
|
||||
preloadPath: string;
|
||||
rendererDist: string;
|
||||
iconPath: string;
|
||||
workerPath: string;
|
||||
devServerUrl?: string;
|
||||
}
|
||||
|
||||
export function createMainRuntime({
|
||||
hasSingleInstanceLock,
|
||||
isDev,
|
||||
devServerOrigin,
|
||||
profileDir,
|
||||
windowStatePath,
|
||||
preloadPath,
|
||||
rendererDist,
|
||||
iconPath,
|
||||
workerPath,
|
||||
devServerUrl,
|
||||
}: CreateMainRuntimeDependencies) {
|
||||
const state: {
|
||||
db: AppDatabase | null;
|
||||
apiServer: AgentApiServer | null;
|
||||
serviceGraph: Awaited<ReturnType<typeof bootstrapApplication>>["serviceGraph"] | null;
|
||||
ipcSupport: Awaited<ReturnType<typeof bootstrapApplication>>["ipcSupport"] | null;
|
||||
isQuitting: boolean;
|
||||
} = {
|
||||
db: null,
|
||||
apiServer: null,
|
||||
serviceGraph: null,
|
||||
ipcSupport: null,
|
||||
isQuitting: false,
|
||||
};
|
||||
const support = createMainRuntimeSupport(state);
|
||||
|
||||
async function bootstrap() {
|
||||
await bootstrapApplication({
|
||||
hasSingleInstanceLock,
|
||||
isDev,
|
||||
devServerOrigin,
|
||||
profileDir,
|
||||
appDatabaseFactory: (targetProfileDir) => new AppDatabase(targetProfileDir),
|
||||
initSigningKey: initializeAgentTokenSigningKey,
|
||||
windowStatePath,
|
||||
preloadPath,
|
||||
rendererDist,
|
||||
iconPath,
|
||||
devServerUrl,
|
||||
workerPath,
|
||||
createApiServer: (db, callbacks, allowedOrigins) =>
|
||||
new AgentApiServer(
|
||||
db,
|
||||
support.publishDomainChanged,
|
||||
(agentId, companyId, trigger) => callbacks.onWakeAgent(agentId, companyId, trigger),
|
||||
callbacks,
|
||||
allowedOrigins,
|
||||
),
|
||||
createRunToken: createAgentToken,
|
||||
seedDefaultAutomationRules: support.seedDefaultAutomationRules,
|
||||
isQuittingRef: {
|
||||
get current() {
|
||||
return state.isQuitting;
|
||||
},
|
||||
set current(value: boolean) {
|
||||
state.isQuitting = value;
|
||||
},
|
||||
},
|
||||
onRuntimeReady: ({ db, apiServer, serviceGraph, ipcSupport }) => {
|
||||
state.db = db;
|
||||
state.apiServer = apiServer;
|
||||
state.serviceGraph = serviceGraph;
|
||||
state.ipcSupport = ipcSupport;
|
||||
// Register IPC handlers immediately so the renderer can call loadSnapshot()
|
||||
// as soon as the window appears — before connector health checks finish.
|
||||
support.registerHandlers();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
bootstrap,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import type { AppDatabase } from "./database";
|
||||
import type { ConnectorId } from "@shared/types";
|
||||
|
||||
interface OnboardingBootstrapSummary {
|
||||
companyId: string;
|
||||
companyName: string;
|
||||
workspaceId: string | null;
|
||||
leadAgentId: string | null;
|
||||
projectId: string | null;
|
||||
taskId: string | null;
|
||||
runId: string | null;
|
||||
}
|
||||
|
||||
interface BootstrapInput {
|
||||
companyName: string;
|
||||
companyDescription: string;
|
||||
workspacePath: string;
|
||||
autoApproveHires: boolean;
|
||||
goalTitle: string;
|
||||
goalDescription: string;
|
||||
connectorId?: ConnectorId;
|
||||
}
|
||||
|
||||
interface OnboardingDependencies {
|
||||
db: AppDatabase;
|
||||
seedDefaultAutomationRules: (companyId: string) => void;
|
||||
queueTaskRun: (taskId: string, trigger: string, actor: string) => {
|
||||
ok: boolean;
|
||||
data?: string;
|
||||
error?: { code: string; message: string };
|
||||
};
|
||||
publishDomainChanged: () => void;
|
||||
}
|
||||
|
||||
const DEFERRABLE_RUN_ERRORS = new Set([
|
||||
"WORKER_UNAVAILABLE",
|
||||
"CONNECTOR_NOT_READY",
|
||||
"TASK_ALREADY_RUNNING",
|
||||
"AGENT_ALREADY_RUNNING",
|
||||
]);
|
||||
|
||||
function resolveFallbackConnector(
|
||||
readyConnectors: Array<{ id: ConnectorId }>,
|
||||
preferredConnectorId?: ConnectorId,
|
||||
) {
|
||||
return preferredConnectorId ?? readyConnectors[0]!.id;
|
||||
}
|
||||
|
||||
export function createOnboardingService({
|
||||
db,
|
||||
seedDefaultAutomationRules,
|
||||
queueTaskRun,
|
||||
publishDomainChanged,
|
||||
}: OnboardingDependencies) {
|
||||
function updateAgentWorkspaceBinding(agentId: string, workspaceId: string | null) {
|
||||
if (!workspaceId) return;
|
||||
const agent = db.getAgent(agentId);
|
||||
if (agent.workspaceId === workspaceId) return;
|
||||
db.saveAgent({
|
||||
id: agent.id,
|
||||
companyId: agent.companyId,
|
||||
name: agent.name,
|
||||
role: agent.role,
|
||||
title: agent.title,
|
||||
department: agent.department,
|
||||
status: agent.status,
|
||||
reportsTo: agent.reportsTo,
|
||||
connectorId: agent.connectorId,
|
||||
workspaceId,
|
||||
model: agent.model,
|
||||
metadataJson: agent.metadataJson,
|
||||
capabilities: agent.capabilities,
|
||||
budgetMonthlyUsd: agent.budgetMonthlyUsd,
|
||||
});
|
||||
}
|
||||
|
||||
function createDefaultWorkspace(companyId: string, workspacePath: string) {
|
||||
const trimmedPath = workspacePath.trim();
|
||||
if (!trimmedPath) return null;
|
||||
return db.saveWorkspace({
|
||||
companyId,
|
||||
name: "Default Workspace",
|
||||
localPath: trimmedPath,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
}
|
||||
|
||||
function createInitialExecutionLoop(companyId: string, workspaceId: string | null): OnboardingBootstrapSummary {
|
||||
const snapshot = db.listSnapshot();
|
||||
const company = snapshot.companies.find((entry) => entry.id === companyId) ?? null;
|
||||
if (!company) {
|
||||
throw new Error("Company no longer exists during onboarding bootstrap.");
|
||||
}
|
||||
|
||||
const leadAgent = snapshot.agents.find((agent) => agent.companyId === companyId && !agent.reportsTo) ?? null;
|
||||
if (!leadAgent) {
|
||||
throw new Error("Unable to find the lead agent for the company bootstrap.");
|
||||
}
|
||||
|
||||
updateAgentWorkspaceBinding(leadAgent.id, workspaceId);
|
||||
|
||||
const activeGoal =
|
||||
snapshot.goals.find((goal) => goal.companyId === companyId && goal.status === "active")
|
||||
?? snapshot.goals.find((goal) => goal.companyId === companyId)
|
||||
?? null;
|
||||
|
||||
const kickoffProjectName = activeGoal ? `Execution plan: ${activeGoal.title}` : "Initial operating plan";
|
||||
const kickoffProjectDescription = activeGoal
|
||||
? `Turn the founding goal "${activeGoal.title}" into an execution plan, delegation map, and the first wave of real work.`
|
||||
: "Establish the first operating plan, assign work, and start the company execution loop.";
|
||||
|
||||
const projectId = db.saveProject({
|
||||
companyId,
|
||||
goalId: activeGoal?.id ?? null,
|
||||
name: kickoffProjectName,
|
||||
description: kickoffProjectDescription,
|
||||
leadAgentId: leadAgent.id,
|
||||
targetDate: null,
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
const kickoffTaskTitle = activeGoal ? `Operationalize goal: ${activeGoal.title}` : "Establish the first operating cadence";
|
||||
const kickoffTaskDescription = activeGoal
|
||||
? [
|
||||
`Read the founding goal "${activeGoal.title}" and convert it into a real operating plan.`,
|
||||
"Create the first execution structure inside AgentCompany: projects, tasks, delegation, approvals, and coordination messages as needed.",
|
||||
"Use the bound workspace as the default execution boundary for the first round of work.",
|
||||
].join("\n\n")
|
||||
: [
|
||||
"Establish the first operating plan for the company.",
|
||||
"Review the current company state, create the first real tasks, delegate initial work, and report progress through tasks, approvals, and messages.",
|
||||
].join("\n\n");
|
||||
|
||||
const taskId = db.saveTask({
|
||||
companyId,
|
||||
projectId,
|
||||
goalId: activeGoal?.id ?? null,
|
||||
parentId: null,
|
||||
title: kickoffTaskTitle,
|
||||
description: kickoffTaskDescription,
|
||||
assigneeAgentId: leadAgent.id,
|
||||
workspaceId: workspaceId ?? leadAgent.workspaceId ?? null,
|
||||
priority: "high",
|
||||
status: "todo",
|
||||
});
|
||||
|
||||
const startRunResult = queueTaskRun(taskId, "assignment", "system");
|
||||
if (!startRunResult.ok && !DEFERRABLE_RUN_ERRORS.has(startRunResult.error?.code ?? "")) {
|
||||
throw new Error(startRunResult.error?.message ?? "Failed to queue the initial execution loop.");
|
||||
}
|
||||
|
||||
return {
|
||||
companyId,
|
||||
companyName: company.name,
|
||||
workspaceId,
|
||||
leadAgentId: leadAgent.id,
|
||||
projectId,
|
||||
taskId,
|
||||
runId: startRunResult.ok ? startRunResult.data ?? null : null,
|
||||
};
|
||||
}
|
||||
|
||||
function bootstrapOnboarding(input: BootstrapInput): OnboardingBootstrapSummary {
|
||||
const snapshot = db.listSnapshot();
|
||||
const readyConnectors = snapshot.connectors.filter(
|
||||
(connector) => connector.status === "ready" || connector.status === "degraded",
|
||||
);
|
||||
if (readyConnectors.length === 0) {
|
||||
throw new Error("At least one ready connector is required before onboarding can continue.");
|
||||
}
|
||||
const selectedConnectorId = resolveFallbackConnector(readyConnectors, input.connectorId);
|
||||
|
||||
const companyId = db.saveCompany({
|
||||
name: input.companyName.trim(),
|
||||
description: input.companyDescription.trim(),
|
||||
status: "active",
|
||||
autoApproveHires: input.autoApproveHires,
|
||||
});
|
||||
const workspaceId = createDefaultWorkspace(companyId, input.workspacePath);
|
||||
|
||||
const goalTitle = input.goalTitle.trim();
|
||||
if (goalTitle) {
|
||||
db.saveGoal({
|
||||
companyId,
|
||||
title: goalTitle,
|
||||
description: input.goalDescription.trim() || `Achieve: ${goalTitle}`,
|
||||
status: "active",
|
||||
parentId: null,
|
||||
ownerAgentId: null,
|
||||
});
|
||||
}
|
||||
|
||||
const ceoId = db.saveAgent({
|
||||
companyId,
|
||||
name: "CEO",
|
||||
role: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
reportsTo: null,
|
||||
connectorId: selectedConnectorId,
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities:
|
||||
"Executive leadership, strategic planning, team building, goal decomposition, delegation, and organizational management. Responsible for hiring the leadership team, setting strategy, and coordinating all company operations.",
|
||||
budgetMonthlyUsd: 250,
|
||||
status: "idle",
|
||||
});
|
||||
db.setHeartbeat(ceoId, true, 120);
|
||||
db.setCurrentCompany(companyId);
|
||||
seedDefaultAutomationRules(companyId);
|
||||
|
||||
const kickoff = createInitialExecutionLoop(companyId, workspaceId);
|
||||
|
||||
db.addActivity({
|
||||
companyId,
|
||||
actor: "board",
|
||||
action: "company.bootstrapped",
|
||||
entityType: "company",
|
||||
entityId: companyId,
|
||||
detail: `Bootstrapped from onboarding with connector ${selectedConnectorId}.`,
|
||||
});
|
||||
publishDomainChanged();
|
||||
return kickoff;
|
||||
}
|
||||
|
||||
return {
|
||||
bootstrapOnboarding,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
import { buildTaskPrompt } from "./prompt-builder";
|
||||
import type { AppDatabase } from "./database";
|
||||
import type { DesktopResult } from "@shared/contracts";
|
||||
|
||||
type WorkspaceLockMode = "exclusive" | "shared" | "none";
|
||||
|
||||
interface WorkspaceResolution {
|
||||
workspace: ReturnType<AppDatabase["getWorkspace"]>;
|
||||
lockMode: WorkspaceLockMode;
|
||||
source: "task" | "agent" | "project" | "company_fallback" | "none";
|
||||
}
|
||||
|
||||
interface AutomationDispatch {
|
||||
companyId: string;
|
||||
taskId?: string;
|
||||
agentId?: string;
|
||||
runId?: string;
|
||||
projectId?: string;
|
||||
department?: string;
|
||||
previousStatus?: string;
|
||||
newStatus?: string;
|
||||
costUsd?: number;
|
||||
}
|
||||
|
||||
interface EnqueueRunPayload {
|
||||
runId: string;
|
||||
companyId: string;
|
||||
taskId: string;
|
||||
taskTitle: string;
|
||||
taskDescription: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
connectorId: string;
|
||||
workspaceId: string | null;
|
||||
workspacePath: string | null;
|
||||
workspaceMode: WorkspaceLockMode;
|
||||
command: string;
|
||||
model: string | null;
|
||||
thinkingEffort: string | null;
|
||||
env: Record<string, string>;
|
||||
sessionParams: Record<string, unknown> | null;
|
||||
systemPrompt: string | null;
|
||||
trigger: string | null;
|
||||
}
|
||||
|
||||
interface OrchestrationDependencies {
|
||||
db: AppDatabase;
|
||||
logger: {
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
publishDomainChanged: () => void;
|
||||
notify: (title: string, body: string) => void;
|
||||
createRunToken: (agentId: string, companyId: string, runId: string) => string;
|
||||
getApiPort: () => number;
|
||||
dispatchAutomation: (trigger: string, ctx: AutomationDispatch) => void;
|
||||
hasWorkerReady: () => boolean;
|
||||
enqueueWorkerRun: (payload: EnqueueRunPayload) => void;
|
||||
}
|
||||
|
||||
export function createOrchestrationService({
|
||||
db,
|
||||
logger,
|
||||
publishDomainChanged,
|
||||
notify,
|
||||
createRunToken,
|
||||
getApiPort,
|
||||
dispatchAutomation,
|
||||
hasWorkerReady,
|
||||
enqueueWorkerRun,
|
||||
}: OrchestrationDependencies) {
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const scheduleLastFiredMap = new Map<string, number>();
|
||||
|
||||
function ok<T>(data: T): DesktopResult<T> {
|
||||
return { ok: true, data };
|
||||
}
|
||||
|
||||
function fail(code: string, message: string): DesktopResult<never> {
|
||||
return { ok: false, error: { code, message } };
|
||||
}
|
||||
|
||||
function hasActiveRun(filter: { taskId?: string; agentId?: string; companyId: string }) {
|
||||
return db.hasActiveRun(filter);
|
||||
}
|
||||
|
||||
function resolveWorkspace(
|
||||
task: { workspaceId: string | null; projectId?: string | null },
|
||||
agent: { workspaceId: string | null },
|
||||
companyId: string,
|
||||
): WorkspaceResolution {
|
||||
const taskWorkspace = db.getWorkspace(task.workspaceId);
|
||||
if (taskWorkspace) {
|
||||
return { workspace: taskWorkspace, lockMode: "exclusive", source: "task" };
|
||||
}
|
||||
|
||||
const agentWorkspace = db.getWorkspace(agent.workspaceId);
|
||||
if (agentWorkspace) {
|
||||
return { workspace: agentWorkspace, lockMode: "exclusive", source: "agent" };
|
||||
}
|
||||
|
||||
const projectWorkspace = db.getProjectPrimaryWorkspace(task.projectId ?? null, companyId);
|
||||
if (projectWorkspace) {
|
||||
return { workspace: projectWorkspace, lockMode: "exclusive", source: "project" };
|
||||
}
|
||||
|
||||
const fallbackWorkspace = db.getAnyCompanyWorkspace(companyId);
|
||||
if (fallbackWorkspace) {
|
||||
return { workspace: fallbackWorkspace, lockMode: "shared", source: "company_fallback" };
|
||||
}
|
||||
|
||||
return { workspace: null, lockMode: "none", source: "none" };
|
||||
}
|
||||
|
||||
function autoPauseBudgetExceededAgent(agent: { id: string; companyId: string; name: string; spentMonthlyUsd: number; budgetMonthlyUsd: number }) {
|
||||
if (!(agent.budgetMonthlyUsd > 0) || agent.spentMonthlyUsd < agent.budgetMonthlyUsd) {
|
||||
return false;
|
||||
}
|
||||
db.addActivity({
|
||||
companyId: agent.companyId,
|
||||
actor: "system",
|
||||
action: "agent.auto_paused",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
detail: `Budget exhausted at ${agent.spentMonthlyUsd.toFixed(2)} / ${agent.budgetMonthlyUsd.toFixed(2)} USD.`,
|
||||
});
|
||||
notify("Agent paused for budget limit", agent.name);
|
||||
publishDomainChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureAgentHeartbeatEnabled(agentId: string) {
|
||||
const agent = db.getAgent(agentId);
|
||||
if (!agent.heartbeatEnabled) {
|
||||
db.setHeartbeat(agentId, true, agent.heartbeatIntervalSec || 120);
|
||||
}
|
||||
}
|
||||
|
||||
function getRunEligibilityFailure(
|
||||
agent: { status: string; budgetMonthlyUsd: number; spentMonthlyUsd: number; name: string; connectorId: string; id: string; companyId: string },
|
||||
connector: { id: string; status: string },
|
||||
) {
|
||||
if (["terminated", "paused", "pending_approval"].includes(agent.status)) {
|
||||
return "AGENT_NOT_RUNNABLE";
|
||||
}
|
||||
if (agent.budgetMonthlyUsd > 0 && agent.spentMonthlyUsd >= agent.budgetMonthlyUsd) {
|
||||
autoPauseBudgetExceededAgent(agent);
|
||||
logger.info(`[run] Agent ${agent.name} budget exhausted, skipping`);
|
||||
return "AGENT_BUDGET_EXHAUSTED";
|
||||
}
|
||||
if (connector.status !== "ready" && connector.status !== "degraded") {
|
||||
logger.info(`[run] Agent ${agent.name} connector ${connector.id} not ready (${connector.status})`);
|
||||
return "CONNECTOR_NOT_READY";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function dispatchRunToWorker(params: {
|
||||
runId: string;
|
||||
companyId: string;
|
||||
agent: ReturnType<AppDatabase["getAgent"]>;
|
||||
task: ReturnType<AppDatabase["getTask"]>;
|
||||
connector: ReturnType<AppDatabase["getConnector"]>;
|
||||
workspace: ReturnType<AppDatabase["getWorkspace"]>;
|
||||
workspaceMode: WorkspaceLockMode;
|
||||
companyName: string;
|
||||
companyDescription: string;
|
||||
snapshot: ReturnType<AppDatabase["listSnapshot"]>;
|
||||
trigger: string;
|
||||
}) {
|
||||
const { runId, companyId, agent, task, connector, workspace, workspaceMode, companyName, companyDescription, snapshot, trigger } = params;
|
||||
if (!hasWorkerReady()) {
|
||||
throw new Error("Orchestration worker is not ready.");
|
||||
}
|
||||
|
||||
const env = db.resolveSecretEnv(connector.envBindingText, companyId);
|
||||
const apiPort = getApiPort();
|
||||
const apiUrl = apiPort ? `http://127.0.0.1:${apiPort}` : "";
|
||||
const apiKey = apiUrl ? createRunToken(agent.id, companyId, runId) : "";
|
||||
|
||||
const assignedTasks = db.listAgentTasks(agent.id, companyId, ["todo", "in_progress", "blocked"]);
|
||||
const reviewTasks = db.listReviewTasksForManager(agent.id, companyId);
|
||||
const relevantTaskIds = new Set([
|
||||
...assignedTasks.map((task) => task.id),
|
||||
...reviewTasks.map((task) => task.id),
|
||||
]);
|
||||
const allRelevantTasks = [...assignedTasks, ...reviewTasks];
|
||||
const recentComments = snapshot.comments
|
||||
.filter((comment) => relevantTaskIds.has(comment.taskId))
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||
.slice(0, 30)
|
||||
.map((comment) => ({
|
||||
taskId: comment.taskId,
|
||||
taskTitle: allRelevantTasks.find((task) => task.id === comment.taskId)?.title ?? comment.taskId,
|
||||
authorName: comment.authorName,
|
||||
body: comment.body,
|
||||
createdAt: comment.createdAt,
|
||||
}));
|
||||
|
||||
const systemPrompt = apiUrl ? buildTaskPrompt({
|
||||
agent,
|
||||
apiUrl,
|
||||
apiKey,
|
||||
runId,
|
||||
task,
|
||||
companyName,
|
||||
goals: snapshot.goals.filter((goal) => goal.companyId === companyId),
|
||||
projects: snapshot.projects.filter((project) => project.companyId === companyId),
|
||||
directReports: db.getDirectReports(agent.id, companyId),
|
||||
chainOfCommand: db.getChainOfCommand(agent.id),
|
||||
assignedTasks,
|
||||
allAgents: snapshot.agents.filter((entry) => entry.companyId === companyId),
|
||||
pendingApprovals: snapshot.approvals.filter(
|
||||
(approval) => approval.companyId === companyId &&
|
||||
approval.requestedByAgentId === agent.id &&
|
||||
(approval.state === "pending" || approval.state === "revision_requested"),
|
||||
),
|
||||
tasksAwaitingReview: db.listReviewTasksForManager(agent.id, companyId),
|
||||
wakeReason: trigger,
|
||||
allTasks: snapshot.tasks.filter((entry) => entry.companyId === companyId),
|
||||
recentComments,
|
||||
companyDescription,
|
||||
socialAccounts: db.listSocialAccounts(companyId).map((account) => ({
|
||||
id: account.id,
|
||||
platform: account.platform,
|
||||
accountName: account.accountName,
|
||||
status: account.status,
|
||||
requireApproval: account.requireApproval,
|
||||
})),
|
||||
recentMessages: db.listAgentMessages(companyId, { agentId: agent.id, limit: 15 }),
|
||||
upcomingMeetings: snapshot.meetings
|
||||
.filter((meeting) => meeting.companyId === companyId && (meeting.status === "scheduled" || meeting.status === "in_progress"))
|
||||
.filter((meeting) => {
|
||||
try {
|
||||
const ids: string[] = JSON.parse(meeting.participantAgentIds);
|
||||
return ids.includes(agent.id) || meeting.organizerAgentId === agent.id;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.slice(0, 5),
|
||||
relevantKnowledge: snapshot.knowledgeBase
|
||||
.filter((entry) => entry.companyId === companyId && (entry.importance === "critical" || entry.importance === "high"))
|
||||
.slice(0, 8),
|
||||
activeSprint: snapshot.sprints.find((sprint) => sprint.companyId === companyId && sprint.status === "active") ?? null,
|
||||
recentDocuments: snapshot.documents
|
||||
.filter((document) => document.companyId === companyId && (document.authorAgentId === agent.id || document.reviewerAgentId === agent.id || document.status === "in_review"))
|
||||
.slice(0, 5),
|
||||
}) : null;
|
||||
|
||||
if (apiUrl) {
|
||||
env.AGENT_COMPANY_API_URL = apiUrl;
|
||||
env.AGENT_COMPANY_API_KEY = apiKey;
|
||||
env.AGENT_COMPANY_AGENT_ID = agent.id;
|
||||
env.AGENT_COMPANY_COMPANY_ID = companyId;
|
||||
env.AGENT_COMPANY_RUN_ID = runId;
|
||||
env.AGENT_COMPANY_TASK_ID = task.id;
|
||||
env.AGENT_COMPANY_WAKE_REASON = trigger;
|
||||
}
|
||||
|
||||
const taskSession = db.getTaskSession(task.id);
|
||||
let thinkingEffort: string | null = null;
|
||||
try {
|
||||
const meta = JSON.parse(agent.metadataJson || "{}");
|
||||
if (typeof meta.thinkingEffort === "string" && meta.thinkingEffort) {
|
||||
thinkingEffort = meta.thinkingEffort;
|
||||
}
|
||||
} catch {
|
||||
thinkingEffort = null;
|
||||
}
|
||||
|
||||
enqueueWorkerRun({
|
||||
runId,
|
||||
companyId,
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
taskDescription: task.description,
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
connectorId: connector.id,
|
||||
workspaceId: workspaceMode === "exclusive" ? (workspace?.id ?? null) : null,
|
||||
workspacePath: workspace?.localPath ?? null,
|
||||
workspaceMode,
|
||||
command: connector.command,
|
||||
model: connector.model ?? agent.model,
|
||||
thinkingEffort,
|
||||
env,
|
||||
sessionParams: taskSession.sessionParams,
|
||||
systemPrompt,
|
||||
trigger,
|
||||
});
|
||||
}
|
||||
|
||||
function startAgentHeartbeatRun(agentId: string, companyId: string, trigger: string): string | null {
|
||||
if (!hasWorkerReady()) return null;
|
||||
const snapshot = db.listSnapshot();
|
||||
const company = snapshot.companies.find((entry) => entry.id === companyId) ?? null;
|
||||
if (!company || company.status !== "active") {
|
||||
logger.info(`[heartbeat] Skipping ${agentId} because company ${companyId} is not active.`);
|
||||
return null;
|
||||
}
|
||||
const agent = db.getAgent(agentId);
|
||||
if (agent.companyId !== companyId) {
|
||||
logger.warn(`[heartbeat] Refusing cross-company run for agent ${agentId}: ${agent.companyId} !== ${companyId}`);
|
||||
return null;
|
||||
}
|
||||
const connector = db.getConnector(agent.connectorId);
|
||||
if (getRunEligibilityFailure(agent, connector)) {
|
||||
return null;
|
||||
}
|
||||
if (hasActiveRun({ agentId: agent.id, companyId })) {
|
||||
logger.info(`[heartbeat] Skipping ${agent.name} because it already has an active run.`);
|
||||
return null;
|
||||
}
|
||||
if (db.isAgentPendingApproval(agentId)) {
|
||||
logger.info(`[heartbeat] Skipping ${agent.name} because agent is pending hire approval.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const assignedTasks = db.listAgentTasks(agentId, companyId, ["todo", "in_progress", "blocked"]);
|
||||
const isTimer = trigger === "timer";
|
||||
const candidateTasks = isTimer
|
||||
? assignedTasks.filter((entry) => !entry.title.startsWith("[Heartbeat]"))
|
||||
: assignedTasks;
|
||||
|
||||
let task = candidateTasks.find((entry) => !entry.activeRunId) ?? null;
|
||||
|
||||
if (!task) {
|
||||
const hasActiveAssignedRun = candidateTasks.some(
|
||||
(entry) => entry.activeRunId && hasActiveRun({ taskId: entry.id, companyId }),
|
||||
);
|
||||
if (hasActiveAssignedRun) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isCeo = !agent.reportsTo;
|
||||
const isManager = snapshot.agents.some((entry) => entry.reportsTo === agent.id && entry.companyId === companyId);
|
||||
const activeGoals = snapshot.goals.filter((goal) => goal.companyId === companyId && (goal.status === "active" || goal.status === "planned"));
|
||||
const companyTasks = snapshot.tasks.filter((entry) => entry.companyId === companyId && entry.status !== "done" && entry.status !== "cancelled");
|
||||
const companyNeedsBootstrap = activeGoals.length > 0 && companyTasks.length < activeGoals.length;
|
||||
|
||||
if (isTimer && !isCeo && !isManager && !companyNeedsBootstrap) {
|
||||
logger.info(`[heartbeat] Skipping ${agent.name} — no actionable tasks and trigger is timer.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const taskTitle = isCeo
|
||||
? `[Heartbeat] ${agent.name} — Strategic planning & task delegation`
|
||||
: isManager
|
||||
? `[Heartbeat] ${agent.name} — Review team & delegate work`
|
||||
: `[Heartbeat] ${agent.name} — Check for work`;
|
||||
const taskDescription = isCeo
|
||||
? "Review active company goals. Break each goal into projects and actionable tasks. Assign tasks to department leads and team members. Create tasks using the API. This is your primary responsibility as CEO — ensure every goal has a clear execution plan with assigned owners."
|
||||
: isManager
|
||||
? "Review your department's goals and your team's workload. Create tasks for your direct reports if they have capacity. Review any work submitted for your approval. Ensure your team is productive and unblocked."
|
||||
: `Automated heartbeat run for ${agent.name}. Review assigned tasks, check goals, and take action as needed.`;
|
||||
|
||||
const taskId = db.saveTask({
|
||||
companyId,
|
||||
title: taskTitle,
|
||||
description: taskDescription,
|
||||
assigneeAgentId: agentId,
|
||||
workspaceId: agent.workspaceId ?? undefined,
|
||||
status: "in_progress",
|
||||
priority: isCeo ? "high" : "medium",
|
||||
});
|
||||
task = db.getTask(taskId);
|
||||
logger.info(`[heartbeat] Created bootstrap task for ${agent.name}: ${taskTitle}`);
|
||||
}
|
||||
|
||||
if (task.activeRunId || hasActiveRun({ taskId: task.id, companyId })) {
|
||||
logger.info(`[heartbeat] Skipping ${agent.name} because task ${task.title} already has an active run.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspaceResolution = resolveWorkspace(task, agent, companyId);
|
||||
const workspace = workspaceResolution.workspace;
|
||||
logger.info(
|
||||
`[heartbeat] Workspace resolution for ${agent.name}: task.workspaceId=${task.workspaceId ?? "null"}, agent.workspaceId=${agent.workspaceId ?? "null"}, projectId=${task.projectId ?? "null"}, source=${workspaceResolution.source}, mode=${workspaceResolution.lockMode}, resolved=${workspace?.name ?? "NONE"}, localPath=${workspace?.localPath ?? "NONE"}`,
|
||||
);
|
||||
|
||||
const run = db.createRun({
|
||||
companyId,
|
||||
taskId: task.id,
|
||||
agentId: agent.id,
|
||||
workspaceId: workspaceResolution.lockMode === "exclusive" ? (workspace?.id ?? null) : null,
|
||||
connectorId: connector.id,
|
||||
});
|
||||
|
||||
dispatchRunToWorker({
|
||||
runId: run.id,
|
||||
companyId,
|
||||
agent,
|
||||
task,
|
||||
connector,
|
||||
workspace,
|
||||
workspaceMode: workspaceResolution.lockMode,
|
||||
companyName: company.name,
|
||||
companyDescription: company.description ?? "",
|
||||
snapshot,
|
||||
trigger,
|
||||
});
|
||||
|
||||
db.markHeartbeatAt(agentId);
|
||||
db.addActivity({
|
||||
companyId,
|
||||
actor: "system",
|
||||
action: "heartbeat.triggered",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
detail: `${trigger} heartbeat for ${agent.name}`,
|
||||
});
|
||||
publishDomainChanged();
|
||||
return run.id;
|
||||
}
|
||||
|
||||
function wakeAgentIfPossible(agentId: string | null | undefined, companyId: string, trigger: string) {
|
||||
if (!agentId) return null;
|
||||
try {
|
||||
const agent = db.getAgent(agentId);
|
||||
if (agent.companyId !== companyId) {
|
||||
logger.warn(`[wake] Refusing cross-company wake for agent ${agentId}: ${agent.companyId} !== ${companyId}`);
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
ensureAgentHeartbeatEnabled(agentId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const agentBusy = hasActiveRun({ agentId, companyId });
|
||||
const runId = startAgentHeartbeatRun(agentId, companyId, trigger);
|
||||
if (!runId && agentBusy) {
|
||||
db.enqueuePendingWake(companyId, agentId, trigger);
|
||||
}
|
||||
return runId;
|
||||
}
|
||||
|
||||
function queueTaskRun(taskId: string, trigger: string, actor: string) {
|
||||
if (!hasWorkerReady()) {
|
||||
logger.warn(`[run] Worker not ready — cannot queue run for task ${taskId} (trigger: ${trigger})`);
|
||||
return fail("WORKER_UNAVAILABLE", "Orchestration worker is not ready.");
|
||||
}
|
||||
const task = db.getTask(taskId);
|
||||
const snapshot = db.listSnapshot();
|
||||
const company = snapshot.companies.find((entry) => entry.id === task.companyId) ?? null;
|
||||
if (!company || company.status !== "active") {
|
||||
return fail("COMPANY_NOT_ACTIVE", "Runs can only start while the company is active.");
|
||||
}
|
||||
if (task.activeRunId || hasActiveRun({ taskId: task.id, companyId: task.companyId })) {
|
||||
return fail("TASK_ALREADY_RUNNING", "This task already has an active run.");
|
||||
}
|
||||
if (!task.assigneeAgentId) return fail("TASK_UNASSIGNED", "Assign an agent before starting a run.");
|
||||
const agent = db.getAgent(task.assigneeAgentId);
|
||||
if (agent.companyId !== task.companyId) {
|
||||
return fail("AGENT_COMPANY_MISMATCH", "The assigned agent does not belong to the task company.");
|
||||
}
|
||||
const connector = db.getConnector(agent.connectorId);
|
||||
const eligibilityFailure = getRunEligibilityFailure(agent, connector);
|
||||
if (eligibilityFailure === "AGENT_NOT_RUNNABLE") {
|
||||
return fail("AGENT_NOT_RUNNABLE", "The assigned agent is paused, pending approval, or terminated.");
|
||||
}
|
||||
if (eligibilityFailure === "AGENT_BUDGET_EXHAUSTED") {
|
||||
return fail("AGENT_BUDGET_EXHAUSTED", "The assigned agent has exhausted its monthly budget.");
|
||||
}
|
||||
if (eligibilityFailure === "CONNECTOR_NOT_READY") {
|
||||
return fail("CONNECTOR_NOT_READY", "The assigned agent connector is not ready.");
|
||||
}
|
||||
if (hasActiveRun({ agentId: agent.id, companyId: task.companyId })) {
|
||||
return fail("AGENT_ALREADY_RUNNING", "The assigned agent already has an active run.");
|
||||
}
|
||||
if (db.isAgentPendingApproval(agent.id)) {
|
||||
return fail("AGENT_PENDING_APPROVAL", "This agent is pending hire approval and cannot run tasks.");
|
||||
}
|
||||
const blockingApproval = db.getBlockingApproval(task.companyId, agent.id, task.id);
|
||||
if (blockingApproval) {
|
||||
return fail(
|
||||
"APPROVAL_GATE_BLOCKED",
|
||||
`Run blocked by pending approval: ${blockingApproval.type} — "${blockingApproval.payloadSummary}". Approve or reject before starting.`,
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceResolution = resolveWorkspace(task, agent, task.companyId);
|
||||
const workspace = workspaceResolution.workspace;
|
||||
if (workspace && workspace.companyId !== task.companyId) {
|
||||
return fail("WORKSPACE_COMPANY_MISMATCH", "The selected workspace does not belong to the task company.");
|
||||
}
|
||||
|
||||
const run = db.createRun({
|
||||
companyId: task.companyId,
|
||||
taskId: task.id,
|
||||
agentId: agent.id,
|
||||
workspaceId: workspaceResolution.lockMode === "exclusive" ? (workspace?.id ?? null) : null,
|
||||
connectorId: connector.id,
|
||||
});
|
||||
|
||||
dispatchRunToWorker({
|
||||
runId: run.id,
|
||||
companyId: task.companyId,
|
||||
agent,
|
||||
task,
|
||||
connector,
|
||||
workspace,
|
||||
workspaceMode: workspaceResolution.lockMode,
|
||||
companyName: company.name,
|
||||
companyDescription: company.description,
|
||||
snapshot,
|
||||
trigger,
|
||||
});
|
||||
db.addActivity({
|
||||
companyId: task.companyId,
|
||||
actor,
|
||||
action: "run.queued",
|
||||
entityType: "run",
|
||||
entityId: run.id,
|
||||
detail: task.title,
|
||||
});
|
||||
publishDomainChanged();
|
||||
return ok(run.id);
|
||||
}
|
||||
|
||||
function resumeQueuedRuns() {
|
||||
if (!hasWorkerReady()) {
|
||||
return;
|
||||
}
|
||||
const snapshot = db.listSnapshot();
|
||||
const companiesById = new Map(snapshot.companies.map((company) => [company.id, company]));
|
||||
|
||||
for (const run of db.listQueuedRuns()) {
|
||||
const company = companiesById.get(run.companyId);
|
||||
if (!company || company.status !== "active") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasBlockingSiblingRun = snapshot.runs.some((entry) =>
|
||||
entry.id !== run.id &&
|
||||
entry.companyId === run.companyId &&
|
||||
["queued", "running"].includes(entry.status) &&
|
||||
(entry.taskId === run.taskId || entry.agentId === run.agentId),
|
||||
);
|
||||
if (hasBlockingSiblingRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const task = db.getTask(run.taskId);
|
||||
const agent = db.getAgent(run.agentId);
|
||||
const connector = db.getConnector(run.connectorId);
|
||||
if (getRunEligibilityFailure(agent, connector)) {
|
||||
continue;
|
||||
}
|
||||
const workspaceResolution = resolveWorkspace(task, agent, run.companyId);
|
||||
dispatchRunToWorker({
|
||||
runId: run.id,
|
||||
companyId: run.companyId,
|
||||
agent,
|
||||
task,
|
||||
connector,
|
||||
workspace: workspaceResolution.workspace,
|
||||
workspaceMode: workspaceResolution.lockMode,
|
||||
companyName: company.name,
|
||||
companyDescription: company.description,
|
||||
snapshot,
|
||||
trigger: "recovery",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(`[run] Skipped queued run recovery for ${run.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startHeartbeatScheduler() {
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||
|
||||
heartbeatTimer = setInterval(() => {
|
||||
try {
|
||||
if (!hasWorkerReady()) return;
|
||||
const snapshot = db.listSnapshot();
|
||||
const now = Date.now();
|
||||
|
||||
for (const company of snapshot.companies.filter((entry) => entry.status === "active")) {
|
||||
try {
|
||||
const eligibleAgents = db.listHeartbeatEligibleAgents(company.id);
|
||||
for (const agent of eligibleAgents) {
|
||||
if (hasActiveRun({ agentId: agent.id, companyId: company.id })) continue;
|
||||
|
||||
const lastHeartbeat = agent.lastHeartbeatAt ? new Date(agent.lastHeartbeatAt).getTime() : 0;
|
||||
const intervalMs = agent.heartbeatIntervalSec * 1000;
|
||||
if (now - lastHeartbeat < intervalMs) continue;
|
||||
|
||||
logger.info(`[heartbeat] Waking agent ${agent.name} (interval: ${agent.heartbeatIntervalSec}s)`);
|
||||
startAgentHeartbeatRun(agent.id, company.id, "timer");
|
||||
}
|
||||
|
||||
const companyGoals = snapshot.goals.filter((goal) => goal.companyId === company.id && (goal.status === "active" || goal.status === "planned"));
|
||||
const companyActiveTasks = snapshot.tasks.filter((task) => task.companyId === company.id && !["done", "cancelled"].includes(task.status) && !task.title.startsWith("[Heartbeat]"));
|
||||
if (companyGoals.length > 0 && companyActiveTasks.length === 0) {
|
||||
const ceo = snapshot.agents.find((agent) => agent.companyId === company.id && !agent.reportsTo && agent.status !== "terminated");
|
||||
const ceoLastWake = ceo?.lastHeartbeatAt ? new Date(ceo.lastHeartbeatAt).getTime() : 0;
|
||||
const bootstrapCooldown = 120_000;
|
||||
if (ceo && !hasActiveRun({ agentId: ceo.id, companyId: company.id }) && (now - ceoLastWake > bootstrapCooldown)) {
|
||||
logger.info(`[heartbeat] Company ${company.name} has ${companyGoals.length} goals but no tasks — bootstrapping CEO ${ceo.name}`);
|
||||
startAgentHeartbeatRun(ceo.id, company.id, "bootstrap");
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleKey = `schedule-${company.id}`;
|
||||
const lastScheduleFired = scheduleLastFiredMap.get(scheduleKey) ?? 0;
|
||||
if (now - lastScheduleFired >= 60_000) {
|
||||
dispatchAutomation("schedule", { companyId: company.id });
|
||||
scheduleLastFiredMap.set(scheduleKey, now);
|
||||
}
|
||||
|
||||
const companyAgents = snapshot.agents.filter((agent) => agent.companyId === company.id && agent.status !== "terminated" && agent.status !== "paused" && agent.status !== "pending_approval");
|
||||
for (const agent of companyAgents) {
|
||||
const hasAssigned = snapshot.tasks.some(
|
||||
(task) => task.assigneeAgentId === agent.id && task.companyId === company.id && ["todo", "in_progress", "blocked"].includes(task.status),
|
||||
);
|
||||
if (!hasAssigned) {
|
||||
const lastActive = agent.lastHeartbeatAt ? new Date(agent.lastHeartbeatAt).getTime() : 0;
|
||||
const idleThreshold = 20 * 60 * 1000;
|
||||
if (now - lastActive > idleThreshold) {
|
||||
dispatchAutomation("agent_idle", {
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
department: agent.department ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const currentMonth = new Date().toISOString().slice(0, 7);
|
||||
const lastResetMonth = db.getLastBudgetResetMonth(company.id) ?? "";
|
||||
if (currentMonth !== lastResetMonth) {
|
||||
db.resetMonthlyBudgets(company.id);
|
||||
db.setLastBudgetResetMonth(company.id, currentMonth);
|
||||
logger.info(`[heartbeat] Reset monthly budgets for company ${company.name} (${currentMonth})`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[heartbeat] Error processing company ${company.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const staleReleased = db.releaseStaleWorkspaceLocks();
|
||||
if (staleReleased > 0) {
|
||||
logger.info(`[heartbeat] Released ${staleReleased} stale workspace lock(s)`);
|
||||
publishDomainChanged();
|
||||
}
|
||||
const stalePendingWakes = db.purgeExpiredPendingWakes();
|
||||
if (stalePendingWakes > 0) {
|
||||
logger.info(`[heartbeat] Purged ${stalePendingWakes} stale pending wake(s)`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[heartbeat] Stale lock cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[heartbeat] Scheduler error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}, 15_000);
|
||||
}
|
||||
|
||||
function stopHeartbeatScheduler() {
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = null;
|
||||
}
|
||||
|
||||
return {
|
||||
resolveWorkspace,
|
||||
autoPauseBudgetExceededAgent,
|
||||
wakeAgentIfPossible,
|
||||
dispatchRunToWorker,
|
||||
queueTaskRun,
|
||||
resumeQueuedRuns,
|
||||
startAgentHeartbeatRun,
|
||||
startHeartbeatScheduler,
|
||||
stopHeartbeatScheduler,
|
||||
};
|
||||
}
|
||||
@@ -1,34 +1,8 @@
|
||||
import { runningProcesses } from "@agentcompany/adapter-utils/server-utils";
|
||||
import { getConnectorDefinition } from "@main/connectors";
|
||||
import { buildSanitizedRuntimeEnv } from "@main/runtime-env";
|
||||
import type { ConnectorId, RunStatus } from "@shared/types";
|
||||
|
||||
const ENV_ALLOWLIST: ReadonlySet<string> = new Set([
|
||||
"PATH",
|
||||
"HOME", "USER", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "TERM", "TMPDIR",
|
||||
"DEVELOPER_DIR", "SDKROOT",
|
||||
"NODE_ENV", "NODE_OPTIONS",
|
||||
"GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL",
|
||||
"GIT_SSH_COMMAND", "GIT_ASKPASS", "GIT_TERMINAL_PROMPT",
|
||||
"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR",
|
||||
"DISPLAY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS",
|
||||
"APPDATA", "LOCALAPPDATA", "USERPROFILE", "SYSTEMROOT", "COMSPEC", "PROGRAMFILES",
|
||||
"AGENT_COMPANY_API_URL", "AGENT_COMPANY_API_KEY", "AGENT_COMPANY_RUN_ID",
|
||||
]);
|
||||
|
||||
function buildSanitizedEnv(
|
||||
hostEnv: Record<string, string>,
|
||||
secretBindings: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const key of ENV_ALLOWLIST) {
|
||||
if (key in hostEnv) {
|
||||
result[key] = hostEnv[key];
|
||||
}
|
||||
}
|
||||
Object.assign(result, secretBindings);
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildRedactor(env: Record<string, string>): (text: string) => string {
|
||||
const sensitivePatterns: string[] = [];
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
@@ -114,6 +88,52 @@ function workspaceKey(job: RunJob) {
|
||||
return job.workspaceId ?? job.workspacePath ?? `task:${job.taskId}`;
|
||||
}
|
||||
|
||||
function buildConnectorExecutionConfig(job: RunJob, sanitizedEnv: Record<string, string>) {
|
||||
const baseConfig: Record<string, unknown> = {
|
||||
command: job.command,
|
||||
cwd: job.workspacePath ?? process.cwd(),
|
||||
model: job.model,
|
||||
env: sanitizedEnv,
|
||||
};
|
||||
|
||||
switch (job.connectorId) {
|
||||
case "codex_local":
|
||||
return {
|
||||
...baseConfig,
|
||||
modelReasoningEffort: job.thinkingEffort ?? "",
|
||||
dangerouslyBypassApprovalsAndSandbox: true,
|
||||
extraArgs: [
|
||||
"--skip-git-repo-check",
|
||||
"--sandbox",
|
||||
"danger-full-access",
|
||||
"--ask-for-approval",
|
||||
"never",
|
||||
],
|
||||
};
|
||||
case "claude_local":
|
||||
return {
|
||||
...baseConfig,
|
||||
effort: job.thinkingEffort ?? "",
|
||||
dangerouslySkipPermissions: true,
|
||||
};
|
||||
case "gemini_local":
|
||||
return {
|
||||
...baseConfig,
|
||||
yolo: true,
|
||||
approvalMode: "yolo",
|
||||
sandbox: false,
|
||||
};
|
||||
case "kimi_local":
|
||||
return {
|
||||
...baseConfig,
|
||||
extraArgs: ["--yolo"],
|
||||
};
|
||||
case "opencode_local":
|
||||
default:
|
||||
return baseConfig;
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(job: RunJob) {
|
||||
queue.push(job);
|
||||
post({
|
||||
@@ -151,7 +171,7 @@ async function startRun(job: RunJob) {
|
||||
message: `Running with ${definition.label}.`,
|
||||
});
|
||||
|
||||
const sanitizedEnv = buildSanitizedEnv(
|
||||
const sanitizedEnv = buildSanitizedRuntimeEnv(
|
||||
Object.fromEntries(
|
||||
Object.entries(process.env).filter((e): e is [string, string] => typeof e[1] === "string"),
|
||||
),
|
||||
@@ -178,17 +198,7 @@ async function startRun(job: RunJob) {
|
||||
typeof job.sessionParams?.sessionId === "string" ? job.sessionParams.sessionId : null,
|
||||
taskKey: job.taskId,
|
||||
},
|
||||
config: {
|
||||
command: job.command,
|
||||
cwd: job.workspacePath ?? process.cwd(),
|
||||
model: job.model,
|
||||
env: sanitizedEnv,
|
||||
effort: job.connectorId === "claude_local" ? (job.thinkingEffort ?? "") : "",
|
||||
modelReasoningEffort: job.connectorId === "codex_local" ? (job.thinkingEffort ?? "") : "",
|
||||
dangerouslySkipPermissions: true,
|
||||
dangerouslyBypassApprovalsAndSandbox: true,
|
||||
extraArgs: job.connectorId === "codex_local" ? ["--skip-git-repo-check"] : [],
|
||||
},
|
||||
config: buildConnectorExecutionConfig(job, sanitizedEnv),
|
||||
context: {
|
||||
taskId: job.taskId,
|
||||
taskTitle: job.systemPrompt ?? job.taskTitle,
|
||||
|
||||
@@ -204,15 +204,28 @@ ${ctx.assignedTasks.map((t) => `- [${t.id}] ${t.title} (status: ${t.status}, pri
|
||||
}
|
||||
|
||||
if (ctx.tasksAwaitingReview.length > 0) {
|
||||
sections.push(`# Tasks Awaiting Your Review
|
||||
${ctx.tasksAwaitingReview.map((t) => {
|
||||
const assigneeName = ctx.allAgents.find((a) => a.id === t.assigneeAgentId)?.name ?? "Unknown";
|
||||
return `- [${t.id}] ${t.title} (by ${assigneeName}, priority: ${t.priority})`;
|
||||
}).join("\n")}
|
||||
const reviewDetails = ctx.tasksAwaitingReview.map((t) => {
|
||||
const assigneeName = ctx.allAgents.find((a) => a.id === t.assigneeAgentId)?.name ?? "Unknown";
|
||||
const desc = t.description ? `\n Description: ${t.description.slice(0, 300)}${t.description.length > 300 ? "..." : ""}` : "";
|
||||
const taskComments = ctx.recentComments
|
||||
.filter((c) => c.taskId === t.id)
|
||||
.slice(-3)
|
||||
.map((c) => ` - ${c.authorName}: "${c.body.slice(0, 150)}${c.body.length > 150 ? "..." : ""}"`)
|
||||
.join("\n");
|
||||
const commentSection = taskComments ? `\n Recent comments:\n${taskComments}` : "\n No comments yet.";
|
||||
return `- [${t.id}] ${t.title} (by ${assigneeName}, priority: ${t.priority})${desc}${commentSection}`;
|
||||
}).join("\n\n");
|
||||
|
||||
These tasks have been submitted for your review by your direct reports. Read their comments, then either:
|
||||
- Approve: PATCH /api/issues/{issueId} with status "done" and an approving comment
|
||||
- Request changes: PATCH /api/issues/{issueId} with status "in_progress" and feedback comment
|
||||
sections.push(`# Tasks Awaiting Your Review (${ctx.tasksAwaitingReview.length} pending)
|
||||
|
||||
${reviewDetails}
|
||||
|
||||
REVIEW WORKFLOW — This is your HIGHEST PRIORITY after resolving blockers:
|
||||
1. For EACH task above, first read ALL comments: GET /api/issues/{issueId}/comments
|
||||
2. Evaluate whether the work meets the task description and acceptance criteria
|
||||
3. To APPROVE: PATCH /api/issues/{issueId} with {"status": "done", "comment": "Approved. <your feedback>"}
|
||||
4. To REQUEST CHANGES: PATCH /api/issues/{issueId} with {"status": "in_progress", "comment": "Changes needed: <specific feedback>"}
|
||||
5. Do NOT ignore review requests — your team is blocked until you respond
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
const ENV_ALLOWLIST: ReadonlySet<string> = new Set([
|
||||
"PATH",
|
||||
"HOME",
|
||||
"USER",
|
||||
"SHELL",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
"TERM",
|
||||
"TMPDIR",
|
||||
"DEVELOPER_DIR",
|
||||
"SDKROOT",
|
||||
"NODE_ENV",
|
||||
"NODE_OPTIONS",
|
||||
"GIT_AUTHOR_NAME",
|
||||
"GIT_AUTHOR_EMAIL",
|
||||
"GIT_COMMITTER_NAME",
|
||||
"GIT_COMMITTER_EMAIL",
|
||||
"GIT_SSH_COMMAND",
|
||||
"GIT_ASKPASS",
|
||||
"GIT_TERMINAL_PROMPT",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_DATA_HOME",
|
||||
"XDG_CACHE_HOME",
|
||||
"XDG_RUNTIME_DIR",
|
||||
"DISPLAY",
|
||||
"WAYLAND_DISPLAY",
|
||||
"DBUS_SESSION_BUS_ADDRESS",
|
||||
"APPDATA",
|
||||
"LOCALAPPDATA",
|
||||
"USERPROFILE",
|
||||
"SYSTEMROOT",
|
||||
"COMSPEC",
|
||||
"PROGRAMFILES",
|
||||
"PROGRAMFILES(X86)",
|
||||
"PROGRAMDATA",
|
||||
"PATHEXT",
|
||||
"AGENT_COMPANY_AGENT_ID",
|
||||
"AGENT_COMPANY_COMPANY_ID",
|
||||
"AGENT_COMPANY_API_URL",
|
||||
"AGENT_COMPANY_API_KEY",
|
||||
"AGENT_COMPANY_RUN_ID",
|
||||
"AGENT_COMPANY_TASK_ID",
|
||||
"AGENT_COMPANY_WAKE_REASON",
|
||||
"AGENT_COMPANY_WAKE_COMMENT_ID",
|
||||
"AGENT_COMPANY_APPROVAL_ID",
|
||||
"AGENT_COMPANY_APPROVAL_STATUS",
|
||||
"AGENT_COMPANY_WORKSPACE_CWD",
|
||||
"AGENT_COMPANY_WORKSPACE_SOURCE",
|
||||
"AGENT_COMPANY_WORKSPACE_STRATEGY",
|
||||
"AGENT_COMPANY_WORKSPACE_ID",
|
||||
"AGENT_COMPANY_WORKSPACE_REPO_URL",
|
||||
"AGENT_COMPANY_WORKSPACE_REPO_REF",
|
||||
"AGENT_COMPANY_WORKSPACE_BRANCH",
|
||||
"AGENT_COMPANY_WORKSPACE_WORKTREE_PATH",
|
||||
"AGENT_COMPANY_WORKSPACES_JSON",
|
||||
"AGENT_COMPANY_RUNTIME_SERVICE_INTENTS_JSON",
|
||||
"AGENT_COMPANY_RUNTIME_SERVICES_JSON",
|
||||
"AGENT_COMPANY_RUNTIME_PRIMARY_URL",
|
||||
"AGENT_COMPANY_LINKED_ISSUE_IDS",
|
||||
]);
|
||||
|
||||
const FORBIDDEN_RUNTIME_OVERRIDE_KEYS: ReadonlySet<string> = new Set([
|
||||
"PATH",
|
||||
"HOME",
|
||||
"USER",
|
||||
"SHELL",
|
||||
"NODE_OPTIONS",
|
||||
"ELECTRON_RUN_AS_NODE",
|
||||
"DYLD_INSERT_LIBRARIES",
|
||||
"LD_PRELOAD",
|
||||
"PWD",
|
||||
]);
|
||||
|
||||
function normalizeStringEnv(input: NodeJS.ProcessEnv | Record<string, string>): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(input).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||
);
|
||||
}
|
||||
|
||||
export function stripForbiddenRuntimeOverrides(env: Record<string, string>): Record<string, string> {
|
||||
const sanitized: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (FORBIDDEN_RUNTIME_OVERRIDE_KEYS.has(key)) {
|
||||
continue;
|
||||
}
|
||||
sanitized[key] = value;
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function buildSanitizedRuntimeEnv(
|
||||
hostEnv: NodeJS.ProcessEnv | Record<string, string>,
|
||||
runtimeOverrides: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
const normalizedHostEnv = normalizeStringEnv(hostEnv);
|
||||
const sanitized: Record<string, string> = {};
|
||||
for (const key of ENV_ALLOWLIST) {
|
||||
if (key in normalizedHostEnv) {
|
||||
sanitized[key] = normalizedHostEnv[key]!;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...sanitized,
|
||||
...stripForbiddenRuntimeOverrides(runtimeOverrides),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import type { AgentApiServer } from "./api-server";
|
||||
import type { AppDatabase } from "./database";
|
||||
import { BrowserManager } from "./browser-manager";
|
||||
import { createAutomationService } from "./automation-service";
|
||||
import { createBrowserActionService } from "./browser-action-service";
|
||||
import { createConnectorHealthService } from "./connector-health-service";
|
||||
import { createDesktopShellService } from "./desktop-shell-service";
|
||||
import { createOnboardingService } from "./onboarding-service";
|
||||
import { createOrchestrationService } from "./orchestration-service";
|
||||
import { createUpdaterService } from "./updater-service";
|
||||
import { createWorkerService } from "./worker-service";
|
||||
import { createAppLifecycleService } from "./app-lifecycle-service";
|
||||
import type { DesktopEvent, RunRecord } from "@shared/types";
|
||||
import type { AutomationEventContext } from "./automation-service";
|
||||
|
||||
interface ServiceGraphDependencies {
|
||||
db: AppDatabase;
|
||||
apiServerRef: { current: AgentApiServer | null };
|
||||
browserManagerRef: { current: BrowserManager | null };
|
||||
isQuittingRef: { current: boolean };
|
||||
preloadPath: string;
|
||||
rendererDist: string;
|
||||
iconPath: string;
|
||||
isDev: boolean;
|
||||
devServerUrl?: string;
|
||||
windowStatePath: string;
|
||||
workerPath: string;
|
||||
logger: {
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
emitEvent: (event: DesktopEvent) => void;
|
||||
publishDomainChanged: () => void;
|
||||
notify: (title: string, body: string) => void;
|
||||
createRunToken: (agentId: string, companyId: string, runId: string) => string;
|
||||
seedDefaultAutomationRules: (companyId: string) => void;
|
||||
}
|
||||
|
||||
export function createServiceGraph({
|
||||
db,
|
||||
apiServerRef,
|
||||
browserManagerRef,
|
||||
isQuittingRef,
|
||||
preloadPath,
|
||||
rendererDist,
|
||||
iconPath,
|
||||
isDev,
|
||||
devServerUrl,
|
||||
windowStatePath,
|
||||
workerPath,
|
||||
logger,
|
||||
emitEvent,
|
||||
publishDomainChanged,
|
||||
notify,
|
||||
createRunToken,
|
||||
seedDefaultAutomationRules,
|
||||
}: ServiceGraphDependencies) {
|
||||
let automationService: ReturnType<typeof createAutomationService> | null = null;
|
||||
let browserActionService: ReturnType<typeof createBrowserActionService> | null = null;
|
||||
let connectorHealthService: ReturnType<typeof createConnectorHealthService> | null = null;
|
||||
let onboardingService: ReturnType<typeof createOnboardingService> | null = null;
|
||||
let desktopShellService: ReturnType<typeof createDesktopShellService> | null = null;
|
||||
let orchestrationService: ReturnType<typeof createOrchestrationService> | null = null;
|
||||
let workerService: ReturnType<typeof createWorkerService> | null = null;
|
||||
let updaterService: ReturnType<typeof createUpdaterService> | null = null;
|
||||
let appLifecycleService: ReturnType<typeof createAppLifecycleService> | null = null;
|
||||
|
||||
const wakeAgentIfPossible = (agentId: string | null | undefined, companyId: string, trigger: string) => {
|
||||
if (!orchestrationService) {
|
||||
throw new Error("Orchestration service is not initialized.");
|
||||
}
|
||||
return orchestrationService.wakeAgentIfPossible(agentId, companyId, trigger);
|
||||
};
|
||||
|
||||
const dispatchBrowserAction = async (actionId: string, companyId: string) => {
|
||||
if (!browserActionService) {
|
||||
throw new Error("Browser action service is not initialized");
|
||||
}
|
||||
return browserActionService.dispatchBrowserAction(actionId, companyId);
|
||||
};
|
||||
|
||||
const dispatchAutomation = (trigger: string, ctx: AutomationEventContext) => {
|
||||
if (!automationService) {
|
||||
throw new Error("Automation service is not initialized.");
|
||||
}
|
||||
automationService.dispatchAutomation(trigger, ctx);
|
||||
};
|
||||
|
||||
const processRunCompletion = (run: RunRecord, event: Parameters<ReturnType<typeof createAutomationService>["processRunCompletion"]>[1]) => {
|
||||
if (!automationService) {
|
||||
throw new Error("Automation service is not initialized.");
|
||||
}
|
||||
automationService.processRunCompletion(run, event);
|
||||
};
|
||||
|
||||
const queueTaskRun = (taskId: string, trigger: string, actor: string) => {
|
||||
if (!orchestrationService) {
|
||||
throw new Error("Orchestration service is not initialized.");
|
||||
}
|
||||
return orchestrationService.queueTaskRun(taskId, trigger, actor);
|
||||
};
|
||||
|
||||
const dispatchRunToWorker = (params: Parameters<ReturnType<typeof createOrchestrationService>["dispatchRunToWorker"]>[0]) => {
|
||||
if (!orchestrationService) {
|
||||
throw new Error("Orchestration service is not initialized.");
|
||||
}
|
||||
return orchestrationService.dispatchRunToWorker(params);
|
||||
};
|
||||
|
||||
function init() {
|
||||
desktopShellService = createDesktopShellService({
|
||||
preloadPath,
|
||||
rendererDist,
|
||||
iconPath,
|
||||
isDev,
|
||||
devServerUrl,
|
||||
windowStatePath,
|
||||
shouldQuit: () => isQuittingRef.current,
|
||||
onRequestQuit: () => {
|
||||
isQuittingRef.current = true;
|
||||
},
|
||||
onCheckForUpdates: async () => {
|
||||
if (!updaterService) throw new Error("Updater service is not initialized.");
|
||||
await updaterService.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,
|
||||
});
|
||||
desktopShellService.initialize();
|
||||
|
||||
connectorHealthService = createConnectorHealthService({
|
||||
db,
|
||||
logger,
|
||||
publishDomainChanged,
|
||||
});
|
||||
|
||||
workerService = createWorkerService({
|
||||
db,
|
||||
logger,
|
||||
workerPath,
|
||||
isQuitting: () => isQuittingRef.current,
|
||||
publishDomainChanged,
|
||||
emitEvent,
|
||||
wakeAgentIfPossible,
|
||||
dispatchRunToWorker,
|
||||
processRunCompletion: (run, event) => processRunCompletion(run, event),
|
||||
notify,
|
||||
});
|
||||
|
||||
orchestrationService = createOrchestrationService({
|
||||
db,
|
||||
logger,
|
||||
publishDomainChanged,
|
||||
notify,
|
||||
createRunToken,
|
||||
getApiPort: () => apiServerRef.current?.getPort() ?? 0,
|
||||
dispatchAutomation,
|
||||
hasWorkerReady: () => Boolean(workerService?.getWorker()),
|
||||
enqueueWorkerRun: (payload) => {
|
||||
workerService?.getWorker()?.postMessage({ type: "enqueue-run", payload });
|
||||
},
|
||||
});
|
||||
workerService.createWorker();
|
||||
|
||||
automationService = createAutomationService({
|
||||
db,
|
||||
logger,
|
||||
emitEvent,
|
||||
publishDomainChanged,
|
||||
notify,
|
||||
wakeAgentIfPossible,
|
||||
dispatchBrowserAction,
|
||||
});
|
||||
|
||||
onboardingService = createOnboardingService({
|
||||
db,
|
||||
seedDefaultAutomationRules,
|
||||
queueTaskRun,
|
||||
publishDomainChanged,
|
||||
});
|
||||
|
||||
browserActionService = createBrowserActionService({
|
||||
db,
|
||||
browserManagerRef,
|
||||
publishDomainChanged,
|
||||
});
|
||||
browserActionService.resetBrowserManager();
|
||||
|
||||
updaterService = createUpdaterService({
|
||||
db,
|
||||
logger,
|
||||
notify,
|
||||
});
|
||||
updaterService.attachListeners();
|
||||
|
||||
appLifecycleService = createAppLifecycleService({
|
||||
getMainWindow: () => desktopShellService?.getMainWindow() ?? null,
|
||||
ensureWindow: async () => desktopShellService?.ensureWindow() ?? null,
|
||||
shouldQuit: () => isQuittingRef.current,
|
||||
onBeforeQuit: () => {
|
||||
isQuittingRef.current = true;
|
||||
orchestrationService?.stopHeartbeatScheduler();
|
||||
apiServerRef.current?.stop();
|
||||
apiServerRef.current = null;
|
||||
browserManagerRef.current?.shutdown();
|
||||
browserManagerRef.current = null;
|
||||
desktopShellService?.destroy();
|
||||
desktopShellService = null;
|
||||
workerService?.destroy();
|
||||
workerService = null;
|
||||
browserActionService = null;
|
||||
automationService = null;
|
||||
connectorHealthService = null;
|
||||
onboardingService = null;
|
||||
orchestrationService = null;
|
||||
updaterService = null;
|
||||
appLifecycleService = null;
|
||||
db.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
getDesktopShellService: () => desktopShellService,
|
||||
getConnectorHealthService: () => connectorHealthService,
|
||||
getWorkerService: () => workerService,
|
||||
getBrowserActionService: () => browserActionService,
|
||||
getOrchestrationService: () => orchestrationService,
|
||||
getAutomationService: () => automationService,
|
||||
getOnboardingService: () => onboardingService,
|
||||
getUpdaterService: () => updaterService,
|
||||
getAppLifecycleService: () => appLifecycleService,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,61 @@
|
||||
import electronUpdater from "electron-updater";
|
||||
import type { AppDatabase } from "./database";
|
||||
|
||||
const { autoUpdater } = electronUpdater;
|
||||
|
||||
interface UpdaterServiceDependencies {
|
||||
db: AppDatabase;
|
||||
logger: {
|
||||
info: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
notify: (title: string, body: string) => void;
|
||||
}
|
||||
|
||||
export function createUpdaterService({
|
||||
db,
|
||||
logger,
|
||||
notify,
|
||||
}: UpdaterServiceDependencies) {
|
||||
function attachListeners() {
|
||||
autoUpdater.on("checking-for-update", () => {
|
||||
logger.info("[updater] checking for updates");
|
||||
});
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
notify("Update available", `AgentCompany ${info.version} is available to download.`);
|
||||
});
|
||||
autoUpdater.on("update-not-available", () => {
|
||||
notify("No updates found", "AgentCompany is already on the latest configured version.");
|
||||
});
|
||||
autoUpdater.on("error", (error) => {
|
||||
logger.error("[updater] error", error);
|
||||
notify("Update check failed", error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
}
|
||||
|
||||
async function checkForUpdates() {
|
||||
const snapshot = db.listSnapshot();
|
||||
if (!snapshot.updatesEnabled || process.env.AGENTCOMPANY_DISABLE_UPDATES === "true") {
|
||||
return false;
|
||||
}
|
||||
const updateUrl = process.env.AGENTCOMPANY_UPDATE_URL?.trim();
|
||||
if (!updateUrl || updateUrl.includes("example.invalid")) {
|
||||
logger.info("[updater] Skipping update check because no real update URL is configured.");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
autoUpdater.logger = logger as any;
|
||||
autoUpdater.autoDownload = false;
|
||||
await autoUpdater.checkForUpdates();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
attachListeners,
|
||||
checkForUpdates,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import type { BrowserWindow, Rectangle } from "electron";
|
||||
|
||||
interface StoredWindowState {
|
||||
bounds: Rectangle;
|
||||
maximized: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_BOUNDS: Rectangle = {
|
||||
width: 1480,
|
||||
height: 980,
|
||||
x: 120,
|
||||
y: 80,
|
||||
};
|
||||
|
||||
function safeParseState(filePath: string): StoredWindowState | null {
|
||||
try {
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as Partial<StoredWindowState>;
|
||||
if (!parsed.bounds || typeof parsed.bounds !== "object") return null;
|
||||
const { width, height, x, y } = parsed.bounds as Partial<Rectangle>;
|
||||
if (![width, height].every((value) => typeof value === "number" && Number.isFinite(value))) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
bounds: {
|
||||
width: width!,
|
||||
height: height!,
|
||||
x: typeof x === "number" && Number.isFinite(x) ? x : DEFAULT_BOUNDS.x,
|
||||
y: typeof y === "number" && Number.isFinite(y) ? y : DEFAULT_BOUNDS.y,
|
||||
},
|
||||
maximized: parsed.maximized === true,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeState(filePath: string, state: StoredWindowState) {
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
export function loadWindowState(filePath: string) {
|
||||
return safeParseState(filePath) ?? {
|
||||
bounds: DEFAULT_BOUNDS,
|
||||
maximized: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function attachWindowStatePersistence(win: BrowserWindow, filePath: string) {
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const persist = () => {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
}
|
||||
saveTimer = setTimeout(() => {
|
||||
saveTimer = null;
|
||||
writeState(filePath, {
|
||||
bounds: win.getBounds(),
|
||||
maximized: win.isMaximized(),
|
||||
});
|
||||
}, 250);
|
||||
};
|
||||
|
||||
win.on("resize", persist);
|
||||
win.on("move", persist);
|
||||
win.on("maximize", persist);
|
||||
win.on("unmaximize", persist);
|
||||
win.on("close", () => {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
}
|
||||
writeState(filePath, {
|
||||
bounds: win.getBounds(),
|
||||
maximized: win.isMaximized(),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { utilityProcess } from "electron";
|
||||
import { z } from "zod";
|
||||
import { getConnectorDefinition } from "./connectors";
|
||||
import type { AppDatabase } from "./database";
|
||||
import type { RunRecord } from "@shared/types";
|
||||
|
||||
const runStatusSchema = z.enum(["queued", "running", "succeeded", "failed", "cancelled", "timed_out", "interrupted"]);
|
||||
export const workerMessageSchema = z.union([
|
||||
z.object({
|
||||
type: z.literal("run-log"),
|
||||
runId: z.string().uuid(),
|
||||
stream: z.enum(["stdout", "stderr"]),
|
||||
chunk: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("run-status"),
|
||||
runId: z.string().uuid(),
|
||||
status: runStatusSchema,
|
||||
message: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("run-finished"),
|
||||
runId: z.string().uuid(),
|
||||
status: runStatusSchema,
|
||||
summary: z.string().nullable(),
|
||||
errorMessage: z.string().nullable(),
|
||||
exitCode: z.number().int().nullable(),
|
||||
signal: z.string().nullable(),
|
||||
model: z.string().nullable(),
|
||||
sessionParams: z.record(z.string(), z.unknown()).nullable(),
|
||||
sessionDisplayId: z.string().nullable(),
|
||||
costUsd: z.number().nullable(),
|
||||
unattributedCost: z.boolean(),
|
||||
}),
|
||||
]);
|
||||
type WorkerUiEvent = Extract<z.infer<typeof workerMessageSchema>, { type: "run-log" | "run-status" }>;
|
||||
|
||||
interface WorkerServiceDependencies {
|
||||
db: AppDatabase;
|
||||
logger: {
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
workerPath: string;
|
||||
isQuitting: () => boolean;
|
||||
publishDomainChanged: () => void;
|
||||
emitEvent: (event: WorkerUiEvent) => void;
|
||||
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
|
||||
dispatchRunToWorker: (params: {
|
||||
runId: string;
|
||||
companyId: string;
|
||||
agent: ReturnType<AppDatabase["getAgent"]>;
|
||||
task: ReturnType<AppDatabase["getTask"]>;
|
||||
connector: ReturnType<AppDatabase["getConnector"]>;
|
||||
workspace: ReturnType<AppDatabase["getWorkspace"]>;
|
||||
workspaceMode: "exclusive" | "shared" | "none";
|
||||
companyName: string;
|
||||
companyDescription: string;
|
||||
snapshot: ReturnType<AppDatabase["listSnapshot"]>;
|
||||
trigger: string;
|
||||
}) => void;
|
||||
processRunCompletion: (run: RunRecord, event: z.infer<typeof workerMessageSchema> & { type: "run-finished" }) => void;
|
||||
notify: (title: string, body: string) => void;
|
||||
}
|
||||
|
||||
export function createWorkerService({
|
||||
db,
|
||||
logger,
|
||||
workerPath,
|
||||
isQuitting,
|
||||
publishDomainChanged,
|
||||
emitEvent,
|
||||
wakeAgentIfPossible,
|
||||
dispatchRunToWorker,
|
||||
processRunCompletion,
|
||||
notify,
|
||||
}: WorkerServiceDependencies) {
|
||||
let worker: ReturnType<typeof utilityProcess.fork> | null = null;
|
||||
|
||||
function getWorker() {
|
||||
return worker;
|
||||
}
|
||||
|
||||
function createWorker() {
|
||||
worker = utilityProcess.fork(workerPath, [], {
|
||||
stdio: "pipe",
|
||||
serviceName: "AgentCompany Orchestrator",
|
||||
});
|
||||
|
||||
worker.stdout?.on("data", (chunk) => logger.info(`[worker] ${String(chunk).trim()}`));
|
||||
worker.stderr?.on("data", (chunk) => logger.error(`[worker] ${String(chunk).trim()}`));
|
||||
|
||||
worker.on("exit", (code) => {
|
||||
if (isQuitting()) return;
|
||||
|
||||
logger.warn(`[worker] Worker exited with code ${code}, recovering...`);
|
||||
|
||||
const snapshot = db.listSnapshot();
|
||||
const affectedAgentIds = new Set<string>();
|
||||
const requeueCandidates: Array<{ id: string; taskId: string; agentId: string; companyId: string; workspaceId: string | null; connectorId: string }> = [];
|
||||
|
||||
for (const run of snapshot.runs) {
|
||||
if (run.status === "running") {
|
||||
db.finishRun({
|
||||
runId: run.id,
|
||||
status: "interrupted",
|
||||
summary: "Worker process crashed.",
|
||||
errorMessage: "Worker process exited unexpectedly.",
|
||||
exitCode: code,
|
||||
signal: null,
|
||||
model: null,
|
||||
sessionDisplayId: null,
|
||||
costUsd: null,
|
||||
unattributedCost: true,
|
||||
});
|
||||
if (run.workspaceId) {
|
||||
db.releaseWorkspaceLock(run.workspaceId, run.id);
|
||||
}
|
||||
affectedAgentIds.add(run.agentId);
|
||||
|
||||
const chain = db.getChainOfCommand(run.agentId);
|
||||
if (chain.length > 0) {
|
||||
wakeAgentIfPossible(chain[0].id, run.companyId, "report_failed");
|
||||
}
|
||||
} else if (run.status === "queued") {
|
||||
requeueCandidates.push({
|
||||
id: run.id,
|
||||
taskId: run.taskId,
|
||||
agentId: run.agentId,
|
||||
companyId: run.companyId,
|
||||
workspaceId: run.workspaceId,
|
||||
connectorId: run.connectorId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const agentId of affectedAgentIds) {
|
||||
const pendingWakes = db.consumePendingWakes(agentId);
|
||||
for (const wake of pendingWakes) {
|
||||
wakeAgentIfPossible(agentId, wake.companyId, wake.trigger);
|
||||
}
|
||||
}
|
||||
|
||||
publishDomainChanged();
|
||||
|
||||
createWorker();
|
||||
|
||||
if (requeueCandidates.length > 0 && worker) {
|
||||
logger.info(`[worker] Re-enqueuing ${requeueCandidates.length} queued run(s) after worker restart.`);
|
||||
const recoverySnapshot = db.listSnapshot();
|
||||
for (const candidate of requeueCandidates) {
|
||||
try {
|
||||
const task = db.getTask(candidate.taskId);
|
||||
const agent = db.getAgent(candidate.agentId);
|
||||
const connector = db.getConnector(agent.connectorId);
|
||||
const workspaceResolution = {
|
||||
taskWorkspace: db.getWorkspace(task.workspaceId),
|
||||
agentWorkspace: db.getWorkspace(agent.workspaceId),
|
||||
projectWorkspace: db.getProjectPrimaryWorkspace(task.projectId ?? null, candidate.companyId),
|
||||
};
|
||||
const workspace = workspaceResolution.taskWorkspace
|
||||
?? workspaceResolution.agentWorkspace
|
||||
?? workspaceResolution.projectWorkspace
|
||||
?? db.getAnyCompanyWorkspace(candidate.companyId);
|
||||
const workspaceMode = workspaceResolution.taskWorkspace || workspaceResolution.agentWorkspace || workspaceResolution.projectWorkspace
|
||||
? "exclusive"
|
||||
: workspace
|
||||
? "shared"
|
||||
: "none";
|
||||
const recoveryCompany = recoverySnapshot.companies.find((company) => company.id === candidate.companyId);
|
||||
|
||||
dispatchRunToWorker({
|
||||
runId: candidate.id,
|
||||
companyId: candidate.companyId,
|
||||
agent,
|
||||
task,
|
||||
connector,
|
||||
workspace,
|
||||
workspaceMode,
|
||||
companyName: recoveryCompany?.name ?? "Company",
|
||||
companyDescription: recoveryCompany?.description ?? "",
|
||||
snapshot: recoverySnapshot,
|
||||
trigger: "recovery",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(`[worker] Failed to re-enqueue run ${candidate.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
db.finishRun({
|
||||
runId: candidate.id,
|
||||
status: "interrupted",
|
||||
summary: "Could not re-enqueue after worker restart.",
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
model: null,
|
||||
sessionDisplayId: null,
|
||||
costUsd: null,
|
||||
unattributedCost: true,
|
||||
});
|
||||
if (candidate.workspaceId) {
|
||||
db.releaseWorkspaceLock(candidate.workspaceId, candidate.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
worker.on("message", async (message) => {
|
||||
let event: z.infer<typeof workerMessageSchema>;
|
||||
try {
|
||||
event = workerMessageSchema.parse(message);
|
||||
} catch (error) {
|
||||
logger.error("[worker] invalid message payload", error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "run-log") {
|
||||
await db.appendRunLog(event.runId, event.stream, event.chunk);
|
||||
emitEvent(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "run-status") {
|
||||
db.markRunStatus(event.runId, event.status, event.message);
|
||||
if (event.status === "running") {
|
||||
const run = db.listSnapshot().runs.find((entry) => entry.id === event.runId);
|
||||
if (run?.workspaceId && !db.claimWorkspaceLock(run.workspaceId, run.id)) {
|
||||
logger.error(`[workspace] Failed to claim lock for workspace ${run.workspaceId} when run ${run.id} entered running state. Cancelling run.`);
|
||||
worker?.postMessage({ type: "cancel-run", runId: run.id });
|
||||
}
|
||||
}
|
||||
emitEvent(event);
|
||||
publishDomainChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = db.listSnapshot();
|
||||
const run = snapshot.runs.find((entry) => entry.id === event.runId);
|
||||
if (!run) {
|
||||
return;
|
||||
}
|
||||
|
||||
const connector = getConnectorDefinition(run.connectorId);
|
||||
|
||||
let task: ReturnType<AppDatabase["getTask"]> | null = null;
|
||||
try {
|
||||
task = db.getTask(run.taskId);
|
||||
} catch {
|
||||
task = null;
|
||||
}
|
||||
|
||||
if (task) {
|
||||
if (event.sessionParams && connector.module.sessionCodec) {
|
||||
const serialized = connector.module.sessionCodec.serialize(event.sessionParams);
|
||||
db.updateTaskSession(task.id, serialized, connector.module.sessionCodec.getDisplayId?.(serialized ?? null) ?? event.sessionDisplayId ?? null);
|
||||
}
|
||||
if (!event.sessionParams) {
|
||||
db.updateTaskSession(task.id, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
const runOutcome = db.finishRun(event);
|
||||
if (run.workspaceId) {
|
||||
db.releaseWorkspaceLock(run.workspaceId, run.id);
|
||||
}
|
||||
db.addActivity({
|
||||
companyId: run.companyId,
|
||||
actor: run.agentId,
|
||||
action: `run.${event.status}`,
|
||||
entityType: "run",
|
||||
entityId: run.id,
|
||||
detail: event.summary ?? event.errorMessage ?? "",
|
||||
});
|
||||
publishDomainChanged();
|
||||
emitEvent({
|
||||
type: "run-status",
|
||||
runId: run.id,
|
||||
status: event.status,
|
||||
message: event.summary ?? event.errorMessage ?? event.status,
|
||||
});
|
||||
if ((event.status === "failed" || event.status === "timed_out" || event.status === "interrupted") && task) {
|
||||
notify("Run attention required", task.title);
|
||||
}
|
||||
|
||||
let updatedAgent: ReturnType<AppDatabase["getAgent"]> | null = null;
|
||||
try {
|
||||
updatedAgent = db.getAgent(run.agentId);
|
||||
} catch {
|
||||
updatedAgent = null;
|
||||
}
|
||||
|
||||
if (updatedAgent && runOutcome.budgetHardStopped) {
|
||||
db.addActivity({
|
||||
companyId: updatedAgent.companyId,
|
||||
actor: "system",
|
||||
action: "agent.auto_paused",
|
||||
entityType: "agent",
|
||||
entityId: updatedAgent.id,
|
||||
detail: `Budget exhausted at ${updatedAgent.spentMonthlyUsd.toFixed(2)} / ${updatedAgent.budgetMonthlyUsd.toFixed(2)} USD.`,
|
||||
});
|
||||
notify("Agent paused for budget limit", updatedAgent.name);
|
||||
publishDomainChanged();
|
||||
}
|
||||
|
||||
if (event.status === "failed" || event.status === "timed_out") {
|
||||
try {
|
||||
const chain = db.getChainOfCommand(run.agentId);
|
||||
if (chain.length > 0) {
|
||||
wakeAgentIfPossible(chain[0].id, run.companyId, "report_failed");
|
||||
}
|
||||
} catch {
|
||||
// Agent may have been deleted while resolving the escalation chain.
|
||||
}
|
||||
}
|
||||
|
||||
const pendingWakes = db.consumePendingWakes(run.agentId);
|
||||
if (pendingWakes.length > 0) {
|
||||
for (const wake of pendingWakes) {
|
||||
wakeAgentIfPossible(run.agentId, wake.companyId, wake.trigger);
|
||||
}
|
||||
}
|
||||
|
||||
processRunCompletion(run, event as z.infer<typeof workerMessageSchema> & { type: "run-finished" });
|
||||
});
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
worker?.kill();
|
||||
worker = null;
|
||||
}
|
||||
|
||||
return {
|
||||
createWorker,
|
||||
getWorker,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user