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,
|
||||
};
|
||||
}
|
||||
@@ -24,6 +24,7 @@ const api: DesktopApi = {
|
||||
startTaskRun: (input) => ipcRenderer.invoke(IPC_CHANNELS.startTaskRun, input),
|
||||
cancelRun: (input) => ipcRenderer.invoke(IPC_CHANNELS.cancelRun, input),
|
||||
getRunLog: (input) => ipcRenderer.invoke(IPC_CHANNELS.getRunLog, input),
|
||||
getRunLogChunk: (input) => ipcRenderer.invoke(IPC_CHANNELS.getRunLogChunk, input),
|
||||
backupProfile: () => ipcRenderer.invoke(IPC_CHANNELS.backupProfile),
|
||||
restoreProfile: () => ipcRenderer.invoke(IPC_CHANNELS.restoreProfile),
|
||||
updateSettings: (input) => ipcRenderer.invoke(IPC_CHANNELS.updateSettings, input),
|
||||
@@ -38,6 +39,7 @@ const api: DesktopApi = {
|
||||
deleteSecret: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteSecret, input),
|
||||
getSidebarBadges: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getSidebarBadges, companyId),
|
||||
listConnectorModels: (connectorId) => ipcRenderer.invoke(IPC_CHANNELS.listConnectorModels, connectorId),
|
||||
openConnectorAuthTerminal: (connectorId) => ipcRenderer.invoke(IPC_CHANNELS.openConnectorAuthTerminal, connectorId),
|
||||
addComment: (input) => ipcRenderer.invoke(IPC_CHANNELS.addComment, input),
|
||||
listComments: (input) => ipcRenderer.invoke(IPC_CHANNELS.listComments, input),
|
||||
setHeartbeat: (input) => ipcRenderer.invoke(IPC_CHANNELS.setHeartbeat, input),
|
||||
@@ -47,13 +49,12 @@ const api: DesktopApi = {
|
||||
getCompanyMetrics: (input) => ipcRenderer.invoke(IPC_CHANNELS.getCompanyMetrics, input),
|
||||
getAgentMetrics: (input) => ipcRenderer.invoke(IPC_CHANNELS.getAgentMetrics, input),
|
||||
getStandupReport: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getStandupReport, companyId),
|
||||
applyCompanyTemplate: (input) => ipcRenderer.invoke(IPC_CHANNELS.applyCompanyTemplate, input),
|
||||
listCompanyTemplates: () => ipcRenderer.invoke(IPC_CHANNELS.listCompanyTemplates),
|
||||
bootstrapOnboarding: (input) => ipcRenderer.invoke(IPC_CHANNELS.bootstrapOnboarding, input),
|
||||
saveSocialAccount: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveSocialAccount, input),
|
||||
deleteSocialAccount: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteSocialAccount, input),
|
||||
listBrowserActions: (input) => ipcRenderer.invoke(IPC_CHANNELS.listBrowserActions, input),
|
||||
cancelBrowserAction: (input) => ipcRenderer.invoke(IPC_CHANNELS.cancelBrowserAction, input),
|
||||
triggerBrowserLogin: (id) => ipcRenderer.invoke(IPC_CHANNELS.triggerBrowserLogin, id),
|
||||
triggerBrowserLogin: (input) => ipcRenderer.invoke(IPC_CHANNELS.triggerBrowserLogin, input),
|
||||
saveMeeting: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveMeeting, input),
|
||||
deleteMeeting: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteMeeting, input),
|
||||
saveDocument: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveDocument, input),
|
||||
|
||||
+346
-93
@@ -34,7 +34,6 @@ import {
|
||||
SidebarSimple,
|
||||
TerminalWindow,
|
||||
WarningCircle,
|
||||
Wrench,
|
||||
X,
|
||||
} from "@phosphor-icons/react";
|
||||
import type {
|
||||
@@ -42,6 +41,7 @@ import type {
|
||||
CompanyMetrics,
|
||||
DesktopEvent,
|
||||
InboxItem,
|
||||
OnboardingBootstrapResult,
|
||||
ProfileSnapshot,
|
||||
RunRecord,
|
||||
SectionId,
|
||||
@@ -55,6 +55,7 @@ import { Inbox, StandupView, PerformanceView } from "./components/inbox";
|
||||
import { ConnectorEditor } from "./components/connectors";
|
||||
import { Dashboard, KanbanBoard } from "./components/dashboard";
|
||||
import { EntityWorkspace } from "./components/EntityWorkspace";
|
||||
import { RunCenter } from "./components/runs";
|
||||
import {
|
||||
ActivityList,
|
||||
ApprovalInspector,
|
||||
@@ -94,6 +95,11 @@ import { formatMoney, formatTime } from "./lib/formatters";
|
||||
|
||||
type NavItem = { id: SectionId; label: string; icon: typeof SquaresFour };
|
||||
type NavGroup = { label: string; items: NavItem[] };
|
||||
type PaletteAction = {
|
||||
key: string;
|
||||
section: SectionId;
|
||||
entityId?: string;
|
||||
};
|
||||
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
@@ -103,6 +109,7 @@ const navGroups: NavGroup[] = [
|
||||
{ id: "inbox", label: "Inbox", icon: Bell },
|
||||
{ id: "standup", label: "Standup", icon: Heartbeat },
|
||||
{ id: "performance", label: "Performance", icon: ChartBar },
|
||||
{ id: "runs", label: "Runs", icon: TerminalWindow },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -180,6 +187,7 @@ export function App() {
|
||||
const [, setLogRevision] = useState(0);
|
||||
const [badges, setBadges] = useState<SidebarBadges | null>(null);
|
||||
const [commandPaletteSearch, setCommandPaletteSearch] = useState("");
|
||||
const [selectedPaletteIndex, setSelectedPaletteIndex] = useState(0);
|
||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||
const [initialOnboardingLocked, setInitialOnboardingLocked] = useState(false);
|
||||
const [inboxItems, setInboxItems] = useState<InboxItem[]>([]);
|
||||
@@ -188,6 +196,23 @@ export function App() {
|
||||
const [socialAccounts, setSocialAccounts] = useState<SocialAccountRecord[]>([]);
|
||||
const [browserActions, setBrowserActions] = useState<BrowserActionRecord[]>([]);
|
||||
|
||||
const clearCompanyScopedUiState = useCallback(() => {
|
||||
setSelectedTaskId(null);
|
||||
setSelectedApprovalId(null);
|
||||
setSelectedRunId(null);
|
||||
setSelectedAgentId(null);
|
||||
setSelectedGoalId(null);
|
||||
setSelectedProjectId(null);
|
||||
setSelectedWorkspaceId(null);
|
||||
setSelectedCommunicationEntityId(null);
|
||||
setInboxItems([]);
|
||||
setCompanyMetrics(null);
|
||||
setStandupReport(null);
|
||||
setSocialAccounts([]);
|
||||
setBrowserActions([]);
|
||||
setBadges(null);
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const next = unwrap<ProfileSnapshot>(await window.agentCompany.loadSnapshot());
|
||||
@@ -200,6 +225,8 @@ export function App() {
|
||||
if (next.currentCompanyId) {
|
||||
const badgeResult = await window.agentCompany.getSidebarBadges(next.currentCompanyId);
|
||||
if (badgeResult.ok) setBadges(badgeResult.data);
|
||||
const inboxResult = await window.agentCompany.getInbox(next.currentCompanyId);
|
||||
if (inboxResult.ok) setInboxItems(inboxResult.data);
|
||||
setSocialAccounts(next.socialAccounts?.filter(a => a.companyId === next.currentCompanyId) ?? []);
|
||||
const browserActionResult = await window.agentCompany.listBrowserActions({ companyId: next.currentCompanyId, limit: 50 });
|
||||
if (browserActionResult.ok) {
|
||||
@@ -234,10 +261,19 @@ export function App() {
|
||||
void refreshRef.current();
|
||||
const unsubscribe = window.agentCompany.subscribe((event: DesktopEvent) => {
|
||||
if (event.type === "run-log") {
|
||||
logBufferRef.current = {
|
||||
...logBufferRef.current,
|
||||
[event.runId]: `${logBufferRef.current[event.runId] ?? ""}${event.chunk}`,
|
||||
};
|
||||
const MAX_LOG_BYTES_PER_RUN = 512_000;
|
||||
const MAX_TRACKED_RUNS = 50;
|
||||
const existing = logBufferRef.current[event.runId] ?? "";
|
||||
const updated = existing.length < MAX_LOG_BYTES_PER_RUN
|
||||
? existing + event.chunk
|
||||
: existing.slice(-MAX_LOG_BYTES_PER_RUN / 2) + event.chunk;
|
||||
logBufferRef.current[event.runId] = updated;
|
||||
const runIds = Object.keys(logBufferRef.current);
|
||||
if (runIds.length > MAX_TRACKED_RUNS) {
|
||||
for (const oldId of runIds.slice(0, runIds.length - MAX_TRACKED_RUNS)) {
|
||||
delete logBufferRef.current[oldId];
|
||||
}
|
||||
}
|
||||
setLogRevision((r) => r + 1);
|
||||
return;
|
||||
}
|
||||
@@ -257,19 +293,6 @@ export function App() {
|
||||
};
|
||||
}, [debouncedRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRunId) return;
|
||||
void (async () => {
|
||||
const result = await window.agentCompany.getRunLog({ runId: selectedRunId });
|
||||
if (result.ok) {
|
||||
logBufferRef.current = { ...logBufferRef.current, [selectedRunId]: result.data };
|
||||
setLogRevision((r) => r + 1);
|
||||
return;
|
||||
}
|
||||
setError(result.error.message);
|
||||
})();
|
||||
}, [selectedRunId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!snapshot) return;
|
||||
const root = document.documentElement;
|
||||
@@ -280,41 +303,50 @@ export function App() {
|
||||
useEffect(() => {
|
||||
if (!snapshot?.currentCompanyId) return;
|
||||
const companyId = snapshot.currentCompanyId;
|
||||
let cancelled = false;
|
||||
|
||||
if (section === "inbox") {
|
||||
setInboxItems([]);
|
||||
}
|
||||
|
||||
if (section === "performance") {
|
||||
setCompanyMetrics(null);
|
||||
}
|
||||
|
||||
if (section === "standup") {
|
||||
setStandupReport(null);
|
||||
}
|
||||
|
||||
if (section === "social") {
|
||||
setSocialAccounts([]);
|
||||
}
|
||||
|
||||
if (section === "inbox") {
|
||||
void window.agentCompany.getInbox(companyId).then((result) => {
|
||||
if (result.ok) setInboxItems(result.data);
|
||||
if (!cancelled && result.ok) setInboxItems(result.data);
|
||||
});
|
||||
}
|
||||
|
||||
if (section === "performance") {
|
||||
void window.agentCompany.getCompanyMetrics({ companyId, days: 30 }).then((result) => {
|
||||
if (result.ok) setCompanyMetrics(result.data);
|
||||
if (!cancelled && result.ok) setCompanyMetrics(result.data);
|
||||
});
|
||||
}
|
||||
|
||||
if (section === "standup") {
|
||||
void window.agentCompany.getStandupReport(companyId).then((result) => {
|
||||
if (result.ok) setStandupReport(result.data);
|
||||
if (!cancelled && result.ok) setStandupReport(result.data);
|
||||
});
|
||||
}
|
||||
|
||||
if (section === "social") {
|
||||
setSocialAccounts(snapshot.socialAccounts?.filter(a => a.companyId === companyId) ?? []);
|
||||
setSocialAccounts(snapshot.socialAccounts?.filter((a) => a.companyId === companyId) ?? []);
|
||||
}
|
||||
}, [section, snapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!commandPaletteOpen) return;
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
setCommandPaletteOpen(false);
|
||||
setCommandPaletteSearch("");
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [commandPaletteOpen]);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [section, snapshot]);
|
||||
|
||||
const paletteTrapRef = useFocusTrap(commandPaletteOpen);
|
||||
|
||||
@@ -327,10 +359,36 @@ export function App() {
|
||||
const selectedTask = selectedTaskId ? (tasks.find((task) => task.id === selectedTaskId) ?? null) : null;
|
||||
const selectedConnector = snapshot?.connectors.find((connector) => connector.id === selectedConnectorId) ?? snapshot?.connectors[0] ?? null;
|
||||
const approvals = useMemo(() => snapshot?.approvals.filter((approval) => !currentCompany || approval.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||
const pendingApprovals = useMemo(() => approvals.filter((approval) => approval.state === "pending"), [approvals]);
|
||||
const selectedApproval = selectedApprovalId ? (approvals.find((approval) => approval.id === selectedApprovalId) ?? null) : null;
|
||||
const runs = useMemo(() => snapshot?.runs.filter((run) => !currentCompany || run.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||
const selectedRun = runs.find((run) => run.id === selectedRunId) ?? runs[0] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRunId && !runs.some((run) => run.id === selectedRunId)) {
|
||||
setSelectedRunId(runs[0]?.id ?? null);
|
||||
}
|
||||
}, [runs, selectedRunId]);
|
||||
|
||||
const activeRunId = selectedRun?.id ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeRunId) return;
|
||||
void (async () => {
|
||||
const activeRun = runs.find((run) => run.id === activeRunId);
|
||||
if (!activeRun) {
|
||||
return;
|
||||
}
|
||||
const result = await window.agentCompany.getRunLog({ companyId: activeRun.companyId, runId: activeRunId });
|
||||
if (result.ok) {
|
||||
logBufferRef.current = { ...logBufferRef.current, [activeRunId]: result.data };
|
||||
setLogRevision((r) => r + 1);
|
||||
return;
|
||||
}
|
||||
setError(result.error.message);
|
||||
})();
|
||||
}, [activeRunId, runs]);
|
||||
|
||||
const companyAgents = useMemo(() => snapshot?.agents.filter((a) => !currentCompany || a.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||
const selectedAgent = companyAgents.find((a) => a.id === selectedAgentId) ?? null;
|
||||
const companyGoals = useMemo(() => snapshot?.goals.filter((g) => !currentCompany || g.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||
@@ -341,6 +399,12 @@ export function App() {
|
||||
const activeInboxCount = inboxItems.length;
|
||||
const navBadges = useMemo<Partial<Record<SectionId, { count: number; tone: "default" | "warn" | "danger" }>>>(() => badges ? {
|
||||
inbox: activeInboxCount > 0 ? { count: activeInboxCount, tone: "warn" } : undefined,
|
||||
runs:
|
||||
badges.failedRuns > 0
|
||||
? { count: badges.failedRuns, tone: "danger" }
|
||||
: badges.activeRuns > 0
|
||||
? { count: badges.activeRuns, tone: "default" }
|
||||
: undefined,
|
||||
tasks: badges.todoTasks > 0 ? { count: badges.todoTasks, tone: "default" } : undefined,
|
||||
approvals: badges.pendingApprovals > 0 ? { count: badges.pendingApprovals, tone: "warn" } : undefined,
|
||||
costs: badges.budgetWarnings > 0 ? { count: badges.budgetWarnings, tone: "danger" } : undefined,
|
||||
@@ -363,24 +427,99 @@ export function App() {
|
||||
: navItems;
|
||||
const matchedAgents = paletteQuery ? companyAgents.filter((a) => a.name.toLowerCase().includes(paletteQuery) || a.role.toLowerCase().includes(paletteQuery)).slice(0, 5) : [];
|
||||
const matchedTasks = paletteQuery ? tasks.filter((t) => t.title.toLowerCase().includes(paletteQuery)).slice(0, 5) : [];
|
||||
const matchedRuns = paletteQuery ? runs.filter((run) => {
|
||||
const matchingTask = tasks.find((task) => task.id === run.taskId);
|
||||
return [
|
||||
run.id,
|
||||
run.summary ?? "",
|
||||
run.errorMessage ?? "",
|
||||
matchingTask?.title ?? "",
|
||||
run.connectorId,
|
||||
].some((value) => value.toLowerCase().includes(paletteQuery));
|
||||
}).slice(0, 5) : [];
|
||||
const matchedGoals = paletteQuery ? companyGoals.filter((g) => g.title.toLowerCase().includes(paletteQuery)).slice(0, 5) : [];
|
||||
const matchedProjects = paletteQuery ? companyProjects.filter((p) => p.name.toLowerCase().includes(paletteQuery)).slice(0, 5) : [];
|
||||
const hasEntityResults = matchedAgents.length > 0 || matchedTasks.length > 0 || matchedGoals.length > 0 || matchedProjects.length > 0;
|
||||
return { filteredNavItems, matchedAgents, matchedTasks, matchedGoals, matchedProjects, hasEntityResults };
|
||||
}, [paletteQuery, companyAgents, tasks, companyGoals, companyProjects]);
|
||||
const { filteredNavItems, matchedAgents, matchedTasks, matchedGoals, matchedProjects, hasEntityResults } = paletteResults;
|
||||
const hasEntityResults = matchedAgents.length > 0 || matchedTasks.length > 0 || matchedRuns.length > 0 || matchedGoals.length > 0 || matchedProjects.length > 0;
|
||||
return { filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects, hasEntityResults };
|
||||
}, [paletteQuery, companyAgents, companyGoals, companyProjects, runs, tasks]);
|
||||
const { filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects, hasEntityResults } = paletteResults;
|
||||
const paletteActions = useMemo<PaletteAction[]>(() => ([
|
||||
...filteredNavItems.map((item) => ({ key: `section:${item.id}`, section: item.id })),
|
||||
...matchedAgents.map((agent) => ({ key: `agent:${agent.id}`, section: "agents" as const, entityId: agent.id })),
|
||||
...matchedTasks.map((task) => ({ key: `task:${task.id}`, section: "tasks" as const, entityId: task.id })),
|
||||
...matchedRuns.map((run) => ({ key: `run:${run.id}`, section: "runs" as const, entityId: run.id })),
|
||||
...matchedGoals.map((goal) => ({ key: `goal:${goal.id}`, section: "goals" as const, entityId: goal.id })),
|
||||
...matchedProjects.map((project) => ({ key: `project:${project.id}`, section: "projects" as const, entityId: project.id })),
|
||||
]), [filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects]);
|
||||
const paletteActionIndex = useMemo(() => new Map(paletteActions.map((action, index) => [action.key, index])), [paletteActions]);
|
||||
|
||||
const navigateTo = useCallback((targetSection: SectionId, entityId?: string) => {
|
||||
setSection(targetSection);
|
||||
if (entityId) {
|
||||
if (targetSection === "agents") setSelectedAgentId(entityId);
|
||||
else if (targetSection === "tasks") setSelectedTaskId(entityId);
|
||||
else if (targetSection === "connectors") setSelectedConnectorId(entityId);
|
||||
else if (targetSection === "runs") {
|
||||
setSelectedRunId(entityId);
|
||||
setConsoleOpen(true);
|
||||
}
|
||||
else if (targetSection === "tasks") {
|
||||
const matchingTask = tasks.find((task) => task.id === entityId) ?? null;
|
||||
if (matchingTask) {
|
||||
setSelectedTaskId(matchingTask.id);
|
||||
} else {
|
||||
const matchingRun = runs.find((run) => run.id === entityId) ?? null;
|
||||
if (matchingRun) {
|
||||
setSelectedRunId(matchingRun.id);
|
||||
setSelectedTaskId(matchingRun.taskId);
|
||||
setConsoleOpen(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (targetSection === "approvals") setSelectedApprovalId(entityId);
|
||||
else if (targetSection === "goals") setSelectedGoalId(entityId);
|
||||
else if (targetSection === "projects") setSelectedProjectId(entityId);
|
||||
else if (targetSection === "communication") setSelectedCommunicationEntityId(entityId);
|
||||
}
|
||||
}, []);
|
||||
}, [runs, tasks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!commandPaletteOpen) return;
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
setCommandPaletteOpen(false);
|
||||
setCommandPaletteSearch("");
|
||||
setSelectedPaletteIndex(0);
|
||||
return;
|
||||
}
|
||||
if (paletteActions.length === 0) return;
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setSelectedPaletteIndex((current) => (current + 1) % paletteActions.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setSelectedPaletteIndex((current) => (current - 1 + paletteActions.length) % paletteActions.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const selectedAction = paletteActions[Math.min(selectedPaletteIndex, paletteActions.length - 1)];
|
||||
if (!selectedAction) return;
|
||||
navigateTo(selectedAction.section, selectedAction.entityId);
|
||||
setCommandPaletteOpen(false);
|
||||
setCommandPaletteSearch("");
|
||||
setSelectedPaletteIndex(0);
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [commandPaletteOpen, navigateTo, paletteActions, selectedPaletteIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!commandPaletteOpen) return;
|
||||
setSelectedPaletteIndex(0);
|
||||
}, [commandPaletteOpen, commandPaletteSearch]);
|
||||
|
||||
const setCompany = useCallback(async (companyId: string) => {
|
||||
if (companyId === "__new__") {
|
||||
@@ -389,12 +528,13 @@ export function App() {
|
||||
}
|
||||
try {
|
||||
unwrap(await window.agentCompany.setCurrentCompany(companyId));
|
||||
clearCompanyScopedUiState();
|
||||
await refresh();
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}, [refresh]);
|
||||
}, [clearCompanyScopedUiState, refresh]);
|
||||
|
||||
const deleteCompany = useCallback(async (companyId: string) => {
|
||||
try {
|
||||
@@ -408,7 +548,11 @@ export function App() {
|
||||
}, [refresh]);
|
||||
|
||||
const startRun = useCallback(async (taskId: string) => {
|
||||
const result = await window.agentCompany.startTaskRun({ taskId });
|
||||
if (!currentCompany) {
|
||||
setError("Select a company before starting a run.");
|
||||
return;
|
||||
}
|
||||
const result = await window.agentCompany.startTaskRun({ companyId: currentCompany.id, taskId });
|
||||
if (!result.ok) {
|
||||
setError(result.error.message);
|
||||
return;
|
||||
@@ -416,29 +560,49 @@ export function App() {
|
||||
setSelectedRunId(result.data);
|
||||
setConsoleOpen(true);
|
||||
await refresh();
|
||||
}, [refresh]);
|
||||
}, [currentCompany, refresh]);
|
||||
|
||||
const cancelRun = useCallback(async (runId: string) => {
|
||||
try {
|
||||
unwrap(await window.agentCompany.cancelRun({ runId }));
|
||||
const run = runs.find((entry) => entry.id === runId);
|
||||
if (!run) {
|
||||
throw new Error("Selected run is no longer available.");
|
||||
}
|
||||
unwrap(await window.agentCompany.cancelRun({ companyId: run.companyId, runId }));
|
||||
await refresh();
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}, [refresh, runs]);
|
||||
|
||||
const handleOnboardingDone = useCallback(async (summary: OnboardingBootstrapResult) => {
|
||||
setShowOnboarding(false);
|
||||
setInitialOnboardingLocked(false);
|
||||
await refresh();
|
||||
setError(null);
|
||||
if (summary.runId) {
|
||||
setSection("runs");
|
||||
setSelectedRunId(summary.runId);
|
||||
setConsoleOpen(true);
|
||||
return;
|
||||
}
|
||||
if (summary.taskId) {
|
||||
setSection("tasks");
|
||||
setSelectedTaskId(summary.taskId);
|
||||
return;
|
||||
}
|
||||
if (summary.projectId) {
|
||||
setSection("projects");
|
||||
setSelectedProjectId(summary.projectId);
|
||||
return;
|
||||
}
|
||||
if (summary.leadAgentId) {
|
||||
setSection("agents");
|
||||
setSelectedAgentId(summary.leadAgentId);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
const quickConnectorCheck = useCallback(async () => {
|
||||
if (!selectedConnector) return;
|
||||
try {
|
||||
unwrap(await window.agentCompany.testConnector(selectedConnector.id));
|
||||
await refresh();
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}, [selectedConnector, refresh]);
|
||||
|
||||
if (!snapshot) {
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] items-center justify-center text-[13px] text-[color:var(--muted)]">
|
||||
@@ -448,10 +612,10 @@ export function App() {
|
||||
}
|
||||
|
||||
if (snapshot.companies.length === 0 || showOnboarding || initialOnboardingLocked) {
|
||||
return <CompanyOnboarding connectors={snapshot.connectors} onDone={async () => {
|
||||
return <CompanyOnboarding connectors={snapshot.connectors} onDone={handleOnboardingDone} canCancel={snapshot.companies.length > 0 && !initialOnboardingLocked} onCancel={() => {
|
||||
setShowOnboarding(false);
|
||||
setInitialOnboardingLocked(false);
|
||||
await refresh();
|
||||
setError(null);
|
||||
}} />;
|
||||
}
|
||||
|
||||
@@ -688,6 +852,27 @@ export function App() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{section === "runs" ? (
|
||||
<RunCenter
|
||||
runs={runs}
|
||||
tasks={tasks}
|
||||
agents={companyAgents}
|
||||
workspaces={companyWorkspaces}
|
||||
selectedRunId={selectedRun?.id ?? null}
|
||||
onSelectRun={(runId: string) => {
|
||||
setSelectedRunId(runId);
|
||||
}}
|
||||
onOpenLogPath={async (path: string) => {
|
||||
unwrap(await window.agentCompany.openPath(path));
|
||||
}}
|
||||
onCancelRun={cancelRun}
|
||||
onOpenConsole={(runId: string) => {
|
||||
setSelectedRunId(runId);
|
||||
setConsoleOpen(true);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{section === "connectors" && selectedConnector ? (
|
||||
<div className="space-y-10">
|
||||
<Panel title="Connector registry">
|
||||
@@ -715,8 +900,6 @@ export function App() {
|
||||
</Panel>
|
||||
<Panel
|
||||
title={selectedConnector.label}
|
||||
|
||||
action={<ActionButton label="Run health check" onClick={() => void quickConnectorCheck()} icon={Wrench} tone="accent" />}
|
||||
>
|
||||
<ConnectorEditor connector={selectedConnector} onSaved={refresh} />
|
||||
</Panel>
|
||||
@@ -746,7 +929,7 @@ export function App() {
|
||||
workspaces={companyWorkspaces}
|
||||
agents={companyAgents}
|
||||
companyId={currentCompany!.id}
|
||||
onSaved={async () => { setSelectedAgentId(null); await refresh(); }}
|
||||
onSaved={refresh}
|
||||
onDeleted={async () => { setSelectedAgentId(null); await refresh(); }}
|
||||
/>
|
||||
) : null}
|
||||
@@ -861,6 +1044,8 @@ export function App() {
|
||||
projects={companyProjects}
|
||||
workspaces={companyWorkspaces}
|
||||
companyId={currentCompany!.id}
|
||||
comments={(snapshot.comments ?? []).filter((comment) => comment.taskId === selectedTask.id)}
|
||||
onRefresh={refresh}
|
||||
onRun={startRun}
|
||||
onCancelRun={cancelRun}
|
||||
onCreateApproval={async () => {
|
||||
@@ -880,6 +1065,11 @@ export function App() {
|
||||
setSelectedApprovalId(approvalId);
|
||||
await refresh();
|
||||
}}
|
||||
onOpenDiscussion={(taskId) => {
|
||||
setSelectedTaskId(null);
|
||||
setSection("communication");
|
||||
setSelectedCommunicationEntityId(taskId);
|
||||
}}
|
||||
onDeleted={async () => { setSelectedTaskId(null); await refresh(); }}
|
||||
/>
|
||||
) : null}
|
||||
@@ -893,7 +1083,7 @@ export function App() {
|
||||
<PendingReviewList tasks={tasks} agents={companyAgents} />
|
||||
</Panel>
|
||||
<Panel title="Pending approvals">
|
||||
<ApprovalList approvals={approvals} selectedApprovalId={selectedApproval?.id ?? null} onSelect={setSelectedApprovalId} />
|
||||
<ApprovalList approvals={pendingApprovals} selectedApprovalId={selectedApproval?.id ?? null} onSelect={setSelectedApprovalId} />
|
||||
</Panel>
|
||||
<Modal open={!!selectedApproval} title={selectedApproval?.payloadSummary ?? "Approval detail"} onClose={() => setSelectedApprovalId(null)}>
|
||||
{selectedApproval ? (
|
||||
@@ -943,33 +1133,60 @@ export function App() {
|
||||
agents={companyAgents.map(a => ({ id: a.id, name: a.name }))}
|
||||
companyId={snapshot.currentCompanyId}
|
||||
onSave={async (input) => {
|
||||
await window.agentCompany.saveSocialAccount({
|
||||
companyId: input.companyId,
|
||||
platform: input.platform,
|
||||
accountName: input.accountName,
|
||||
displayName: input.displayName,
|
||||
requireApproval: input.requireApproval,
|
||||
profileUrl: "",
|
||||
status: "login_required",
|
||||
metadataJson: "{}",
|
||||
});
|
||||
await refresh();
|
||||
try {
|
||||
unwrap(await window.agentCompany.saveSocialAccount({
|
||||
companyId: input.companyId,
|
||||
platform: input.platform,
|
||||
accountName: input.accountName,
|
||||
displayName: input.displayName,
|
||||
requireApproval: input.requireApproval,
|
||||
profileUrl: "",
|
||||
status: "login_required",
|
||||
metadataJson: "{}",
|
||||
}));
|
||||
await refresh();
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}}
|
||||
onDelete={async (id) => {
|
||||
if (!snapshot?.currentCompanyId) return;
|
||||
await window.agentCompany.deleteSocialAccount({ id, companyId: snapshot.currentCompanyId });
|
||||
await refresh();
|
||||
try {
|
||||
unwrap(await window.agentCompany.deleteSocialAccount({ id, companyId: snapshot.currentCompanyId }));
|
||||
await refresh();
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}}
|
||||
onLogin={async (accountId) => {
|
||||
await window.agentCompany.triggerBrowserLogin(accountId);
|
||||
await refresh();
|
||||
if (!snapshot?.currentCompanyId) return;
|
||||
try {
|
||||
unwrap(await window.agentCompany.triggerBrowserLogin({ companyId: snapshot.currentCompanyId, socialAccountId: accountId }));
|
||||
await refresh();
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}}
|
||||
onOpenPath={async (path) => {
|
||||
await window.agentCompany.openPath(path);
|
||||
try {
|
||||
unwrap(await window.agentCompany.openPath(path));
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}}
|
||||
onCancelAction={async (actionId) => {
|
||||
await window.agentCompany.cancelBrowserAction({ runId: actionId });
|
||||
await refresh();
|
||||
if (!snapshot?.currentCompanyId) return;
|
||||
try {
|
||||
unwrap(await window.agentCompany.cancelBrowserAction({ actionId, companyId: snapshot.currentCompanyId }));
|
||||
await refresh();
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}}
|
||||
onNavigate={(sectionName, entityId) => {
|
||||
setSection(sectionName);
|
||||
@@ -996,6 +1213,11 @@ export function App() {
|
||||
agents={companyAgents}
|
||||
companyId={currentCompany.id}
|
||||
selectedEntityId={selectedCommunicationEntityId}
|
||||
onEntityConsumed={() => setSelectedCommunicationEntityId(null)}
|
||||
onNavigateToTask={(taskId) => {
|
||||
setSection("tasks");
|
||||
setSelectedTaskId(taskId);
|
||||
}}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1202,11 +1424,12 @@ export function App() {
|
||||
{filteredNavItems.map((item) => (
|
||||
<ListRow
|
||||
key={item.id}
|
||||
className="py-3"
|
||||
className={`py-3 ${paletteActionIndex.get(`section:${item.id}`) === selectedPaletteIndex ? "bg-[color:var(--panel-soft)]" : ""}`}
|
||||
onClick={() => {
|
||||
setSection(item.id);
|
||||
navigateTo(item.id);
|
||||
setCommandPaletteOpen(false);
|
||||
setCommandPaletteSearch("");
|
||||
setSelectedPaletteIndex(0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -1231,12 +1454,12 @@ export function App() {
|
||||
{matchedAgents.map((agent) => (
|
||||
<ListRow
|
||||
key={agent.id}
|
||||
className="py-3"
|
||||
className={`py-3 ${paletteActionIndex.get(`agent:${agent.id}`) === selectedPaletteIndex ? "bg-[color:var(--panel-soft)]" : ""}`}
|
||||
onClick={() => {
|
||||
setSection("agents");
|
||||
setSelectedAgentId(agent.id);
|
||||
navigateTo("agents", agent.id);
|
||||
setCommandPaletteOpen(false);
|
||||
setCommandPaletteSearch("");
|
||||
setSelectedPaletteIndex(0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -1260,12 +1483,12 @@ export function App() {
|
||||
{matchedTasks.map((task) => (
|
||||
<ListRow
|
||||
key={task.id}
|
||||
className="py-3"
|
||||
className={`py-3 ${paletteActionIndex.get(`task:${task.id}`) === selectedPaletteIndex ? "bg-[color:var(--panel-soft)]" : ""}`}
|
||||
onClick={() => {
|
||||
setSection("tasks");
|
||||
setSelectedTaskId(task.id);
|
||||
navigateTo("tasks", task.id);
|
||||
setCommandPaletteOpen(false);
|
||||
setCommandPaletteSearch("");
|
||||
setSelectedPaletteIndex(0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -1281,6 +1504,36 @@ export function App() {
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{matchedRuns.length > 0 ? (
|
||||
<>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Runs</div>
|
||||
<ListFrame>
|
||||
{matchedRuns.map((run) => (
|
||||
<ListRow
|
||||
key={run.id}
|
||||
className={`py-3 ${paletteActionIndex.get(`run:${run.id}`) === selectedPaletteIndex ? "bg-[color:var(--panel-soft)]" : ""}`}
|
||||
onClick={() => {
|
||||
navigateTo("runs", run.id);
|
||||
setCommandPaletteOpen(false);
|
||||
setCommandPaletteSearch("");
|
||||
setSelectedPaletteIndex(0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex items-center gap-3 text-[color:var(--text)]">
|
||||
<TerminalWindow size={16} />
|
||||
<span className="truncate text-[13px] tracking-[0.005em]">
|
||||
{run.summary || runs.find((entry) => entry.id === run.id)?.id.slice(0, 8) || run.id.slice(0, 8)}
|
||||
</span>
|
||||
</div>
|
||||
<StatusPill status={run.status} />
|
||||
</div>
|
||||
</ListRow>
|
||||
))}
|
||||
</ListFrame>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{matchedGoals.length > 0 ? (
|
||||
<>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Goals</div>
|
||||
@@ -1288,12 +1541,12 @@ export function App() {
|
||||
{matchedGoals.map((goal) => (
|
||||
<ListRow
|
||||
key={goal.id}
|
||||
className="py-3"
|
||||
className={`py-3 ${paletteActionIndex.get(`goal:${goal.id}`) === selectedPaletteIndex ? "bg-[color:var(--panel-soft)]" : ""}`}
|
||||
onClick={() => {
|
||||
setSection("goals");
|
||||
setSelectedGoalId(goal.id);
|
||||
navigateTo("goals", goal.id);
|
||||
setCommandPaletteOpen(false);
|
||||
setCommandPaletteSearch("");
|
||||
setSelectedPaletteIndex(0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -1316,12 +1569,12 @@ export function App() {
|
||||
{matchedProjects.map((project) => (
|
||||
<ListRow
|
||||
key={project.id}
|
||||
className="py-3"
|
||||
className={`py-3 ${paletteActionIndex.get(`project:${project.id}`) === selectedPaletteIndex ? "bg-[color:var(--panel-soft)]" : ""}`}
|
||||
onClick={() => {
|
||||
setSection("projects");
|
||||
setSelectedProjectId(project.id);
|
||||
navigateTo("projects", project.id);
|
||||
setCommandPaletteOpen(false);
|
||||
setCommandPaletteSearch("");
|
||||
setSelectedPaletteIndex(0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -31,7 +31,8 @@ import type {
|
||||
} from "@shared/types"
|
||||
import { getAgentInitials, formatMessageTime } from "../lib/formatters"
|
||||
import { DEPARTMENT_COLORS } from "@shared/constants"
|
||||
import { Modal, Select, Input, TextArea } from "./ui"
|
||||
import { unwrap } from "../lib/desktop"
|
||||
import { InlineNotice, Modal, Select, Input, TextArea } from "./ui"
|
||||
|
||||
const TERRACOTTA = "#d97745"
|
||||
const TERRACOTTA_SOFT = "#d9774518"
|
||||
@@ -181,18 +182,32 @@ function deriveTaskDiscussionChannels(
|
||||
tasks: TaskRecord[],
|
||||
): TaskDiscussionDescriptor[] {
|
||||
const taskMap = new Map(tasks.map((task) => [task.id, task]))
|
||||
const discussedTaskIds = new Set(comments.map((comment) => comment.taskId))
|
||||
return Array.from(discussedTaskIds)
|
||||
const activeTaskIds = tasks
|
||||
.filter((task) => ["todo", "in_progress", "in_review", "blocked"].includes(task.status))
|
||||
.map((task) => task.id)
|
||||
const discussionTaskIds = new Set([...comments.map((comment) => comment.taskId), ...activeTaskIds])
|
||||
|
||||
return Array.from(discussionTaskIds)
|
||||
.map((taskId) => {
|
||||
const task = taskMap.get(taskId)
|
||||
return {
|
||||
key: `task-${taskId}`,
|
||||
kind: "task" as const,
|
||||
taskId,
|
||||
label: task?.title ?? `Task ${taskId.slice(0, 8)}`,
|
||||
label: task
|
||||
? `${task.title}${task.status === "blocked" ? " · blocked" : task.status === "in_review" ? " · review" : ""}`
|
||||
: `Task ${taskId.slice(0, 8)}`,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
.sort((left, right) => {
|
||||
const leftTask = taskMap.get(left.taskId)
|
||||
const rightTask = taskMap.get(right.taskId)
|
||||
const priorityWeight: Record<string, number> = { critical: 4, high: 3, medium: 2, low: 1 }
|
||||
const leftWeight = leftTask ? priorityWeight[leftTask.priority] ?? 0 : 0
|
||||
const rightWeight = rightTask ? priorityWeight[rightTask.priority] ?? 0 : 0
|
||||
if (leftWeight !== rightWeight) return rightWeight - leftWeight
|
||||
return left.label.localeCompare(right.label)
|
||||
})
|
||||
}
|
||||
|
||||
function getChannelMessages(
|
||||
@@ -509,19 +524,23 @@ function TaskDiscussionComposer({
|
||||
}) {
|
||||
const [body, setBody] = useState("")
|
||||
const [sending, setSending] = useState(false)
|
||||
const [sendError, setSendError] = useState<string | null>(null)
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!body.trim()) return
|
||||
setSending(true)
|
||||
setSendError(null)
|
||||
try {
|
||||
await window.agentCompany.addComment({
|
||||
unwrap(await window.agentCompany.addComment({
|
||||
companyId,
|
||||
taskId,
|
||||
authorName: "Board",
|
||||
body: body.trim(),
|
||||
})
|
||||
}))
|
||||
setBody("")
|
||||
onSent()
|
||||
} catch (error) {
|
||||
setSendError(error instanceof Error ? error.message : "Failed to send task discussion reply.")
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
@@ -529,6 +548,7 @@ function TaskDiscussionComposer({
|
||||
|
||||
return (
|
||||
<div className="border-t border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3">
|
||||
{sendError ? <div className="mb-3"><InlineNotice message={sendError} /></div> : null}
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
value={body}
|
||||
@@ -609,6 +629,7 @@ function MessageComposer({
|
||||
const [body, setBody] = useState("")
|
||||
const [subject, setSubject] = useState("")
|
||||
const [sending, setSending] = useState(false)
|
||||
const [sendError, setSendError] = useState<string | null>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -620,8 +641,9 @@ function MessageComposer({
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!body.trim() || !fromAgentId) return
|
||||
setSending(true)
|
||||
setSendError(null)
|
||||
try {
|
||||
await window.agentCompany.sendAgentMessage({
|
||||
unwrap(await window.agentCompany.sendAgentMessage({
|
||||
companyId,
|
||||
fromAgentId,
|
||||
toAgentId: replyTo ? replyTo.fromAgentId : null,
|
||||
@@ -632,12 +654,12 @@ function MessageComposer({
|
||||
priority: "normal",
|
||||
parentMessageId: replyTo?.id ?? null,
|
||||
attachmentsJson: "[]",
|
||||
})
|
||||
}))
|
||||
setBody("")
|
||||
setSubject("")
|
||||
onSent()
|
||||
} catch (err) {
|
||||
void err
|
||||
} catch (error) {
|
||||
setSendError(error instanceof Error ? error.message : "Failed to send message.")
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
@@ -657,6 +679,7 @@ function MessageComposer({
|
||||
|
||||
return (
|
||||
<div className="border-t border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3">
|
||||
{sendError ? <div className="mb-3"><InlineNotice message={sendError} /></div> : null}
|
||||
<AnimatePresence>
|
||||
{replyTo ? (
|
||||
<motion.div
|
||||
@@ -758,6 +781,7 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
||||
const [body, setBody] = useState("")
|
||||
const [priority, setPriority] = useState<MessagePriority>("normal")
|
||||
const [sending, setSending] = useState(false)
|
||||
const [sendError, setSendError] = useState<string | null>(null)
|
||||
|
||||
const departmentOptions = useMemo(() => {
|
||||
const depts = new Set<string>()
|
||||
@@ -768,10 +792,11 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
||||
}, [agents])
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!body.trim() || !fromAgentId || !subject.trim()) return
|
||||
if (!body.trim() || !fromAgentId || !subject.trim() || (channel === "direct" && !toAgentId)) return
|
||||
setSending(true)
|
||||
setSendError(null)
|
||||
try {
|
||||
await window.agentCompany.sendAgentMessage({
|
||||
unwrap(await window.agentCompany.sendAgentMessage({
|
||||
companyId,
|
||||
fromAgentId,
|
||||
toAgentId: channel === "direct" && toAgentId ? toAgentId : null,
|
||||
@@ -782,14 +807,14 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
||||
priority,
|
||||
parentMessageId: null,
|
||||
attachmentsJson: "[]",
|
||||
})
|
||||
}))
|
||||
setSubject("")
|
||||
setBody("")
|
||||
setPriority("normal")
|
||||
onSent()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
void err
|
||||
} catch (error) {
|
||||
setSendError(error instanceof Error ? error.message : "Failed to send message.")
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
@@ -798,6 +823,7 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
||||
return (
|
||||
<Modal open={open} title="Compose Message" onClose={onClose} width="560px">
|
||||
<div className="space-y-4">
|
||||
{sendError ? <InlineNotice message={sendError} /> : null}
|
||||
<Select
|
||||
label="From Agent"
|
||||
value={fromAgentId}
|
||||
@@ -827,7 +853,7 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
||||
value={toAgentId}
|
||||
onChange={setToAgentId}
|
||||
options={[
|
||||
{ value: "", label: "Broadcast to all" },
|
||||
{ value: "", label: "Select a recipient..." },
|
||||
...agents
|
||||
.filter((a) => a.id !== fromAgentId)
|
||||
.map((a) => ({
|
||||
@@ -895,7 +921,7 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSend()}
|
||||
disabled={!body.trim() || !subject.trim() || !fromAgentId || sending}
|
||||
disabled={!body.trim() || !subject.trim() || !fromAgentId || (channel === "direct" && !toAgentId) || sending}
|
||||
className="inline-flex items-center gap-2 rounded-[8px] px-4 py-2 text-[12px] font-semibold text-white transition hover:brightness-90 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none"
|
||||
style={{ backgroundColor: TERRACOTTA }}
|
||||
>
|
||||
@@ -1060,6 +1086,8 @@ interface CommunicationHubProps {
|
||||
agents: AgentRecord[]
|
||||
companyId: string
|
||||
selectedEntityId?: string | null
|
||||
onEntityConsumed?: () => void
|
||||
onNavigateToTask: (taskId: string) => void
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
@@ -1070,12 +1098,16 @@ export function CommunicationHub({
|
||||
agents,
|
||||
companyId,
|
||||
selectedEntityId,
|
||||
onEntityConsumed,
|
||||
onNavigateToTask,
|
||||
onRefresh,
|
||||
}: CommunicationHubProps) {
|
||||
const [selectedChannelKey, setSelectedChannelKey] = useState<string | null>(null)
|
||||
const [showSidebar, setShowSidebar] = useState(true)
|
||||
const [showCompose, setShowCompose] = useState(false)
|
||||
const [replyTo, setReplyTo] = useState<AgentMessageRecord | null>(null)
|
||||
const [optimisticReadIds, setOptimisticReadIds] = useState<Set<string>>(new Set())
|
||||
const pendingReadIdsRef = useRef<Set<string>>(new Set())
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
search: "",
|
||||
channelType: "all",
|
||||
@@ -1085,39 +1117,65 @@ export function CommunicationHub({
|
||||
|
||||
const messageEndRef = useRef<HTMLDivElement>(null)
|
||||
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents])
|
||||
const effectiveMessages = useMemo(
|
||||
() =>
|
||||
messages.map((message) =>
|
||||
optimisticReadIds.has(message.id)
|
||||
? { ...message, readAt: message.readAt ?? new Date().toISOString() }
|
||||
: message,
|
||||
),
|
||||
[messages, optimisticReadIds],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setOptimisticReadIds((current) => {
|
||||
if (current.size === 0) return current
|
||||
const next = new Set(Array.from(current).filter((messageId) => effectiveMessages.some((message) => message.id === messageId)))
|
||||
return next.size === current.size ? current : next
|
||||
})
|
||||
}, [effectiveMessages])
|
||||
|
||||
// Derive channels from messages
|
||||
const messageChannels = useMemo(() => deriveChannels(messages, agents), [messages, agents])
|
||||
const messageChannels = useMemo(() => deriveChannels(effectiveMessages, agents), [effectiveMessages, agents])
|
||||
const taskDiscussionChannels = useMemo(() => deriveTaskDiscussionChannels(comments, tasks), [comments, tasks])
|
||||
const channels = useMemo(() => [...messageChannels, ...taskDiscussionChannels], [messageChannels, taskDiscussionChannels])
|
||||
|
||||
// Auto-select first channel
|
||||
// Auto-select first valid channel
|
||||
useEffect(() => {
|
||||
if (!selectedChannelKey && channels.length > 0) {
|
||||
if (channels.length === 0) {
|
||||
if (selectedChannelKey) {
|
||||
setSelectedChannelKey(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!selectedChannelKey || !channels.some((channel) => channel.key === selectedChannelKey)) {
|
||||
setSelectedChannelKey(channels[0].key)
|
||||
}
|
||||
}, [channels, selectedChannelKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedEntityId) return
|
||||
const matchingMessage = messages.find((message) => message.id === selectedEntityId)
|
||||
const matchingMessage = effectiveMessages.find((message) => message.id === selectedEntityId)
|
||||
if (matchingMessage) {
|
||||
const matchingChannel = messageChannels.find((channel) => getChannelMessages(messages, channel).some((message) => message.id === selectedEntityId))
|
||||
const matchingChannel = messageChannels.find((channel) => getChannelMessages(effectiveMessages, channel).some((message) => message.id === selectedEntityId))
|
||||
if (matchingChannel) {
|
||||
setSelectedChannelKey(matchingChannel.key)
|
||||
onEntityConsumed?.()
|
||||
}
|
||||
return
|
||||
}
|
||||
const matchingComment = comments.find((comment) => comment.id === selectedEntityId)
|
||||
if (matchingComment) {
|
||||
setSelectedChannelKey(`task-${matchingComment.taskId}`)
|
||||
onEntityConsumed?.()
|
||||
return
|
||||
}
|
||||
const matchingTaskDiscussion = taskDiscussionChannels.find((discussion) => discussion.taskId === selectedEntityId)
|
||||
if (matchingTaskDiscussion) {
|
||||
setSelectedChannelKey(matchingTaskDiscussion.key)
|
||||
onEntityConsumed?.()
|
||||
}
|
||||
}, [selectedEntityId, messages, comments, messageChannels, taskDiscussionChannels])
|
||||
}, [selectedEntityId, effectiveMessages, comments, messageChannels, onEntityConsumed, taskDiscussionChannels])
|
||||
|
||||
const activeChannel = useMemo(
|
||||
() => channels.find((c) => c.key === selectedChannelKey) ?? null,
|
||||
@@ -1127,10 +1185,10 @@ export function CommunicationHub({
|
||||
// Messages for the selected channel
|
||||
const channelMessages = useMemo(() => {
|
||||
if (!activeChannel || activeChannel.kind !== "message") return []
|
||||
return getChannelMessages(messages, activeChannel).sort(
|
||||
return getChannelMessages(effectiveMessages, activeChannel).sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
)
|
||||
}, [messages, activeChannel])
|
||||
}, [effectiveMessages, activeChannel])
|
||||
|
||||
const taskDiscussionComments = useMemo(() => {
|
||||
if (!activeChannel || activeChannel.kind !== "task") return []
|
||||
@@ -1173,6 +1231,58 @@ export function CommunicationHub({
|
||||
return result
|
||||
}, [channelMessages, filters, agentMap])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeChannel || activeChannel.kind !== "message") return
|
||||
const unreadMessageIds = filteredMessages
|
||||
.filter((message) => !message.readAt && !pendingReadIdsRef.current.has(message.id))
|
||||
.map((message) => message.id)
|
||||
if (unreadMessageIds.length === 0) return
|
||||
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
for (const messageId of unreadMessageIds) {
|
||||
pendingReadIdsRef.current.add(messageId)
|
||||
}
|
||||
setOptimisticReadIds((current) => {
|
||||
const next = new Set(current)
|
||||
for (const messageId of unreadMessageIds) {
|
||||
next.add(messageId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
const results = await Promise.all(
|
||||
unreadMessageIds.map(async (messageId) => {
|
||||
try {
|
||||
unwrap(await window.agentCompany.markMessageRead({ messageId, companyId }))
|
||||
return { messageId, ok: true as const }
|
||||
} catch {
|
||||
return { messageId, ok: false as const }
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const failedIds = results.filter((result) => !result.ok).map((result) => result.messageId)
|
||||
for (const messageId of unreadMessageIds) {
|
||||
pendingReadIdsRef.current.delete(messageId)
|
||||
}
|
||||
|
||||
if (!cancelled && failedIds.length > 0) {
|
||||
setOptimisticReadIds((current) => {
|
||||
const next = new Set(current)
|
||||
for (const messageId of failedIds) {
|
||||
next.delete(messageId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [activeChannel, companyId, filteredMessages])
|
||||
|
||||
// Build thread structure: top-level messages + their replies
|
||||
const threadedMessages = useMemo(() => {
|
||||
const topLevel = filteredMessages.filter((m) => !m.parentMessageId)
|
||||
@@ -1195,22 +1305,23 @@ export function CommunicationHub({
|
||||
counts.set(ch.key, { unread: 0, hasUrgent: false })
|
||||
continue
|
||||
}
|
||||
const msgs = getChannelMessages(messages, ch)
|
||||
const unread = msgs.filter((m) => !m.readAt).length
|
||||
const hasUrgent = msgs.some((m) => !m.readAt && m.priority === "urgent")
|
||||
const visibleMessages = getChannelMessages(effectiveMessages, ch)
|
||||
const unread = visibleMessages.filter((m) => !m.readAt).length
|
||||
const hasUrgent = visibleMessages.some((m) => !m.readAt && m.priority === "urgent")
|
||||
counts.set(ch.key, { unread, hasUrgent })
|
||||
}
|
||||
return counts
|
||||
}, [channels, messages])
|
||||
}, [channels, effectiveMessages])
|
||||
|
||||
const totalUnread = useMemo(() => {
|
||||
return messages.filter((m) => !m.readAt).length
|
||||
}, [messages])
|
||||
return effectiveMessages.filter((m) => !m.readAt).length
|
||||
}, [effectiveMessages])
|
||||
|
||||
// Scroll to bottom when channel changes
|
||||
// Scroll to bottom when channel changes or new messages arrive
|
||||
const messageCount = useMemo(() => effectiveMessages.length, [effectiveMessages])
|
||||
useEffect(() => {
|
||||
messageEndRef.current?.scrollIntoView({ behavior: "auto" })
|
||||
}, [selectedChannelKey])
|
||||
}, [selectedChannelKey, messageCount])
|
||||
|
||||
// Group channels by type for sidebar sections
|
||||
const channelGroups = useMemo(() => {
|
||||
@@ -1405,6 +1516,15 @@ export function CommunicationHub({
|
||||
: `${taskDiscussionComments.length} comment${taskDiscussionComments.length !== 1 ? "s" : ""}${activeTask ? ` · ${activeTask.status.replaceAll("_", " ")}` : ""}`}
|
||||
</div>
|
||||
</div>
|
||||
{activeChannel.kind === "task" && activeTask ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigateToTask(activeTask.id)}
|
||||
className="rounded-[6px] border border-[color:var(--line)] px-2.5 py-1 text-[11px] font-medium text-[color:var(--muted-strong)] transition hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--text)]"
|
||||
>
|
||||
Open task
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[13px] text-[color:var(--muted)]">
|
||||
|
||||
@@ -1,21 +1,176 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { CheckCircle } from "@phosphor-icons/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CheckCircle, SignIn } from "@phosphor-icons/react";
|
||||
import type { ConnectorRecord } from "@shared/types";
|
||||
import { ActionButton, Input, StatusPill, TextArea } from "./ui";
|
||||
import { unwrap } from "../lib/desktop";
|
||||
import { ActionButton, InlineNotice, Input, Select, StatusPill, TextArea } from "./ui";
|
||||
|
||||
function getAuthActionCopy(connector: ConnectorRecord) {
|
||||
switch (connector.id) {
|
||||
case "codex_local":
|
||||
return {
|
||||
title: "Codex login required",
|
||||
message: "Codex is installed, but the CLI needs an authenticated session before AgentCompany can dispatch runs.",
|
||||
buttonLabel: "Open Terminal To Sign In",
|
||||
};
|
||||
case "claude_local":
|
||||
return {
|
||||
title: "Claude Code login required",
|
||||
message: "Claude Code is installed, but the local CLI is not authenticated for this desktop runtime.",
|
||||
buttonLabel: "Open Terminal To Sign In",
|
||||
};
|
||||
case "opencode_local":
|
||||
return {
|
||||
title: "OpenCode provider login required",
|
||||
message: "OpenCode is installed, but the provider login must be completed before runs can start.",
|
||||
buttonLabel: "Open Terminal To Sign In",
|
||||
};
|
||||
case "gemini_local":
|
||||
return {
|
||||
title: "Gemini authentication required",
|
||||
message: "Gemini CLI is available, but the runtime still needs an authenticated session. AgentCompany will open Gemini in a terminal and you can complete its interactive auth flow there.",
|
||||
buttonLabel: "Open Terminal",
|
||||
};
|
||||
case "kimi_local":
|
||||
return {
|
||||
title: "Kimi interactive login required",
|
||||
message: "Kimi Code is available in generic mode. AgentCompany can open the CLI in a terminal so you can complete the interactive login flow there if prompted.",
|
||||
buttonLabel: "Open Terminal",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function ConnectorEditor({ connector, onSaved }: { connector: ConnectorRecord; onSaved: () => Promise<void> }) {
|
||||
const [model, setModel] = useState(connector.model ?? "");
|
||||
const [envBindingText, setEnvBindingText] = useState(connector.envBindingText);
|
||||
const [notes, setNotes] = useState(connector.notes);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [openingAuthTerminal, setOpeningAuthTerminal] = useState(false);
|
||||
const [autoChecking, setAutoChecking] = useState(false);
|
||||
const [modelOptions, setModelOptions] = useState<Array<{ id: string; label: string }>>([]);
|
||||
const [loadingModelOptions, setLoadingModelOptions] = useState(false);
|
||||
const [modelOptionsError, setModelOptionsError] = useState<string | null>(null);
|
||||
const authPollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const authActionCopy = getAuthActionCopy(connector);
|
||||
const needsLogin = connector.authState === "required" || connector.status === "auth_required";
|
||||
const requiresExplicitModel = connector.id === "opencode_local";
|
||||
const shouldLoadModelOptions = connector.capabilityMatrix.modelDiscovery || connector.id === "opencode_local";
|
||||
|
||||
useEffect(() => {
|
||||
setModel(connector.model ?? "");
|
||||
setEnvBindingText(connector.envBindingText);
|
||||
setNotes(connector.notes);
|
||||
setSubmitError(null);
|
||||
}, [connector]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (authPollTimeoutRef.current) {
|
||||
clearTimeout(authPollTimeoutRef.current);
|
||||
authPollTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!shouldLoadModelOptions) {
|
||||
setModelOptions([]);
|
||||
setModelOptionsError(null);
|
||||
setLoadingModelOptions(false);
|
||||
return;
|
||||
}
|
||||
setLoadingModelOptions(true);
|
||||
setModelOptionsError(null);
|
||||
void window.agentCompany.listConnectorModels(connector.id).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (!result.ok) {
|
||||
setModelOptions([]);
|
||||
setModelOptionsError(result.error.message);
|
||||
setLoadingModelOptions(false);
|
||||
return;
|
||||
}
|
||||
setModelOptions(result.data);
|
||||
setLoadingModelOptions(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [connector.id, shouldLoadModelOptions]);
|
||||
|
||||
const modelSelectOptions = useMemo(() => {
|
||||
const options = [{ value: "", label: requiresExplicitModel ? "Please select a model..." : "Use connector default" }];
|
||||
const hasCurrentModel = modelOptions.some((option) => option.id === model);
|
||||
if (model && !hasCurrentModel) {
|
||||
options.push({ value: model, label: `${model} (saved)` });
|
||||
}
|
||||
options.push(...modelOptions.map((option) => ({ value: option.id, label: option.label || option.id })));
|
||||
return options;
|
||||
}, [model, modelOptions, requiresExplicitModel]);
|
||||
|
||||
const runHealthCheck = useCallback(async (quiet = false) => {
|
||||
setAutoChecking(true);
|
||||
try {
|
||||
unwrap(await window.agentCompany.testConnector(connector.id));
|
||||
await onSaved();
|
||||
if (!quiet) {
|
||||
setSubmitError(null);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!quiet) {
|
||||
setSubmitError(error instanceof Error ? error.message : "Automatic connector health check failed.");
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
setAutoChecking(false);
|
||||
}
|
||||
}, [connector.id, onSaved]);
|
||||
|
||||
const scheduleAuthPolling = useCallback((remainingAttempts: number) => {
|
||||
if (authPollTimeoutRef.current) {
|
||||
clearTimeout(authPollTimeoutRef.current);
|
||||
authPollTimeoutRef.current = null;
|
||||
}
|
||||
if (remainingAttempts <= 0) {
|
||||
return;
|
||||
}
|
||||
authPollTimeoutRef.current = setTimeout(() => {
|
||||
void (async () => {
|
||||
const ok = await runHealthCheck(true);
|
||||
if (!ok) {
|
||||
scheduleAuthPolling(remainingAttempts - 1);
|
||||
}
|
||||
})();
|
||||
}, 5000);
|
||||
}, [runHealthCheck]);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{submitError ? <InlineNotice message={submitError} /> : null}
|
||||
{needsLogin ? (
|
||||
<div className="rounded-[10px] border border-[color:var(--warn)] bg-[color:var(--warn-soft)] px-4 py-4 text-[12px] text-[color:var(--warn)]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em]">{authActionCopy.title}</div>
|
||||
<div className="mt-1.5 leading-relaxed">{authActionCopy.message}</div>
|
||||
<div className="mt-3">
|
||||
<ActionButton
|
||||
label={authActionCopy.buttonLabel}
|
||||
icon={SignIn}
|
||||
onClick={() => void (async () => {
|
||||
setOpeningAuthTerminal(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
unwrap(await window.agentCompany.openConnectorAuthTerminal(connector.id));
|
||||
scheduleAuthPolling(12);
|
||||
} catch (error) {
|
||||
setSubmitError(error instanceof Error ? error.message : "Could not open a terminal for connector login.");
|
||||
} finally {
|
||||
setOpeningAuthTerminal(false);
|
||||
}
|
||||
})()}
|
||||
loading={openingAuthTerminal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1.5 text-[12px] font-medium text-[color:var(--muted-strong)]">Status</div>
|
||||
@@ -41,29 +196,72 @@ export function ConnectorEditor({ connector, onSaved }: { connector: ConnectorRe
|
||||
AgentCompany pins connector execution to the audited binary for this runtime. Command overrides are not accepted from the renderer.
|
||||
</div>
|
||||
</div>
|
||||
<Input label="Model" value={model} onChange={setModel} placeholder="Leave empty to use connector default" />
|
||||
{shouldLoadModelOptions ? (
|
||||
<div className="grid gap-2">
|
||||
<Select
|
||||
label={requiresExplicitModel ? "Model (required)" : "Model"}
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
options={modelSelectOptions}
|
||||
/>
|
||||
{loadingModelOptions ? (
|
||||
<div className="text-[12px] text-[color:var(--muted)]">Loading available models...</div>
|
||||
) : null}
|
||||
{modelOptionsError ? (
|
||||
<InlineNotice message={modelOptionsError} />
|
||||
) : null}
|
||||
{requiresExplicitModel && !model.trim() ? (
|
||||
<div className="rounded-[8px] border border-[color:var(--warn)] bg-[color:var(--warn-soft)] px-4 py-3 text-[12px] text-[color:var(--warn)]">
|
||||
OpenCode is detected, but it still needs an explicit provider/model selection before AgentCompany can mark it ready.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<Input label="Model" value={model} onChange={setModel} placeholder="Leave empty to use connector default" />
|
||||
)}
|
||||
<TextArea label="Secret bindings" value={envBindingText} onChange={setEnvBindingText} rows={4} placeholder={"OPENAI_API_KEY=my-vault-key\nANTHROPIC_API_KEY=my-anthropic-key"} />
|
||||
<TextArea label="Operational notes" value={notes} onChange={setNotes} rows={4} />
|
||||
<div className="grid gap-3 md:grid-cols-[auto_1fr]">
|
||||
<div className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3 text-[12px] text-[color:var(--muted-strong)]">
|
||||
Connector health checks run automatically on app launch, after save, and after terminal login is opened.
|
||||
{autoChecking ? " Refreshing connector state..." : ""}
|
||||
</div>
|
||||
{connector.lastError ? (
|
||||
<div className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3 text-[12px] leading-relaxed text-[color:var(--muted-strong)]">
|
||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Latest health-check detail</div>
|
||||
<div>{connector.lastError}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex gap-3">
|
||||
<ActionButton
|
||||
label="Save connector"
|
||||
tone="accent"
|
||||
onClick={() => void (async () => {
|
||||
await window.agentCompany.saveConnector({
|
||||
id: connector.id,
|
||||
command: connector.command,
|
||||
model: model || null,
|
||||
envBindingText,
|
||||
notes,
|
||||
});
|
||||
await onSaved();
|
||||
setSaving(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
unwrap(await window.agentCompany.saveConnector({
|
||||
id: connector.id,
|
||||
command: connector.command,
|
||||
model: model || null,
|
||||
envBindingText,
|
||||
notes,
|
||||
}));
|
||||
await onSaved();
|
||||
await runHealthCheck(true);
|
||||
} catch (error) {
|
||||
setSubmitError(error instanceof Error ? error.message : "Connector could not be saved.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
})()}
|
||||
icon={CheckCircle}
|
||||
loading={saving}
|
||||
disabled={requiresExplicitModel && !model.trim()}
|
||||
/>
|
||||
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-4 text-[12px] leading-relaxed text-[color:var(--muted)]">
|
||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Configuration doc</div>
|
||||
<pre className="mono whitespace-pre-wrap">{connector.configurationDoc}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-4 text-[12px] leading-relaxed text-[color:var(--muted)]">
|
||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Configuration doc</div>
|
||||
<pre className="mono whitespace-pre-wrap">{connector.configurationDoc}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -819,7 +819,7 @@ export function Dashboard(props: DashboardProps) {
|
||||
<button
|
||||
key={connector.id}
|
||||
type="button"
|
||||
onClick={() => onNavigate("connectors")}
|
||||
onClick={() => onNavigate("connectors", connector.id)}
|
||||
className={`focus-ring flex items-center gap-2.5 rounded-[8px] border px-3 py-2.5 text-left transition duration-150 hover:bg-[color:var(--panel-soft)] ${
|
||||
isError ? "border-[color:var(--danger)] border-opacity-30" : "border-[color:var(--line)]"
|
||||
}`}
|
||||
@@ -870,7 +870,7 @@ export function Dashboard(props: DashboardProps) {
|
||||
<button
|
||||
key={run.id}
|
||||
type="button"
|
||||
onClick={() => onNavigate("tasks")}
|
||||
onClick={() => onNavigate("runs", run.id)}
|
||||
className="focus-ring flex w-full items-center gap-3 rounded-[8px] border border-[color:var(--danger)] border-opacity-20 bg-[color:var(--danger-soft)] px-4 py-3 text-left transition hover:border-opacity-40"
|
||||
>
|
||||
<XCircle size={18} className="shrink-0 text-[color:var(--danger)]" />
|
||||
@@ -890,7 +890,7 @@ export function Dashboard(props: DashboardProps) {
|
||||
<button
|
||||
key={message.id}
|
||||
type="button"
|
||||
onClick={() => onNavigate("communication")}
|
||||
onClick={() => onNavigate("communication", message.id)}
|
||||
className="focus-ring flex w-full items-center gap-3 rounded-[8px] border border-[color:var(--warn)] border-opacity-20 bg-[color:var(--warn-soft)] px-4 py-3 text-left transition hover:border-opacity-40"
|
||||
>
|
||||
<Users size={18} className="shrink-0 text-[color:var(--warn)]" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState, useMemo } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import {
|
||||
ArrowClockwise,
|
||||
ArrowSquareOut,
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
ActivityRecord,
|
||||
AgentRecord,
|
||||
ApprovalRecord,
|
||||
CommentRecord,
|
||||
DocumentRecord,
|
||||
GoalRecord,
|
||||
ProfileSnapshot,
|
||||
@@ -46,6 +47,21 @@ function describeActionError(error: unknown) {
|
||||
return error instanceof Error ? error.message : "The action could not be completed.";
|
||||
}
|
||||
|
||||
function parseApprovalPayloadJson(approval: ApprovalRecord): Record<string, unknown> | null {
|
||||
if (!approval.payloadJson) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(approval.payloadJson);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
const ENTITY_ICONS: Record<string, typeof Robot> = {
|
||||
agent: Robot,
|
||||
@@ -151,6 +167,9 @@ export function TaskInspector({
|
||||
onRun,
|
||||
onCancelRun,
|
||||
onCreateApproval,
|
||||
onOpenDiscussion,
|
||||
comments,
|
||||
onRefresh,
|
||||
onDeleted,
|
||||
}: {
|
||||
task: TaskRecord;
|
||||
@@ -164,6 +183,9 @@ export function TaskInspector({
|
||||
onRun: (taskId: string) => Promise<void>;
|
||||
onCancelRun: (runId: string) => Promise<void>;
|
||||
onCreateApproval: () => Promise<void>;
|
||||
onOpenDiscussion: (taskId: string) => void;
|
||||
comments: CommentRecord[];
|
||||
onRefresh: () => Promise<void>;
|
||||
onDeleted: () => Promise<void>;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -226,6 +248,7 @@ export function TaskInspector({
|
||||
projectId: editProject || null,
|
||||
goalId: editGoal || null,
|
||||
}));
|
||||
await onRefresh();
|
||||
setEditing(false);
|
||||
setActionNotice("Task changes saved.");
|
||||
} catch (error) {
|
||||
@@ -252,6 +275,7 @@ export function TaskInspector({
|
||||
projectId: task.projectId,
|
||||
goalId: task.goalId,
|
||||
}));
|
||||
await onRefresh();
|
||||
setActionNotice(`Task moved to ${status.replaceAll("_", " ")}.`);
|
||||
} catch (error) {
|
||||
setActionError(describeActionError(error));
|
||||
@@ -400,6 +424,7 @@ export function TaskInspector({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ActionButton label="Discussion" onClick={() => onOpenDiscussion(task.id)} icon={ArrowRight} />
|
||||
<ActionButton label="Edit" onClick={startEdit} icon={Pencil} />
|
||||
<ActionButton label="Delete" onClick={() => setConfirmDelete(true)} icon={Trash} tone="danger" />
|
||||
</div>
|
||||
@@ -454,7 +479,13 @@ export function TaskInspector({
|
||||
})()}
|
||||
|
||||
{/* Communication */}
|
||||
<TaskComments taskId={task.id} companyId={task.companyId} agents={agents} />
|
||||
<TaskComments
|
||||
taskId={task.id}
|
||||
companyId={task.companyId}
|
||||
agents={agents}
|
||||
comments={comments}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
|
||||
{/* Run history — only if there are runs */}
|
||||
{runs.length > 0 && (
|
||||
@@ -509,35 +540,32 @@ function TaskComments({
|
||||
taskId,
|
||||
companyId,
|
||||
agents,
|
||||
comments,
|
||||
onRefresh,
|
||||
}: {
|
||||
taskId: string;
|
||||
companyId: string;
|
||||
agents: ProfileSnapshot["agents"];
|
||||
comments: CommentRecord[];
|
||||
onRefresh: () => Promise<void>;
|
||||
}) {
|
||||
const [comments, setComments] = useState<Array<{ id: string; authorAgentId: string | null; authorName: string; body: string; createdAt: string }>>([]);
|
||||
const [newComment, setNewComment] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const loadComments = useCallback(async () => {
|
||||
const result = await window.agentCompany.listComments({ taskId });
|
||||
if (result.ok) setComments(result.data);
|
||||
setLoading(false);
|
||||
}, [taskId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadComments();
|
||||
const unsubscribe = window.agentCompany.subscribe((event) => {
|
||||
if (event.type === "agent-message" && event.taskId === taskId) {
|
||||
setComments((prev) => [...prev, event.comment]);
|
||||
void onRefresh();
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [taskId, loadComments]);
|
||||
}, [onRefresh, taskId]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!newComment.trim()) return;
|
||||
setSubmitError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
unwrap(await window.agentCompany.addComment({
|
||||
companyId,
|
||||
@@ -546,9 +574,11 @@ function TaskComments({
|
||||
body: newComment.trim(),
|
||||
}));
|
||||
setNewComment("");
|
||||
await loadComments();
|
||||
await onRefresh();
|
||||
} catch (error) {
|
||||
setSubmitError(describeActionError(error));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -559,9 +589,7 @@ function TaskComments({
|
||||
<span className="text-[13px] font-semibold text-[color:var(--text)]">Communication</span>
|
||||
<span className="text-[11px] text-[color:var(--muted)]">({comments.length})</span>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="text-[12px] text-[color:var(--muted)]">Loading...</div>
|
||||
) : comments.length === 0 ? (
|
||||
{comments.length === 0 ? (
|
||||
<div className="text-[12px] text-[color:var(--muted)]">No messages yet. Agents will post updates here during runs.</div>
|
||||
) : (
|
||||
<div className="max-h-[240px] space-y-2 overflow-y-auto rounded-lg border border-[color:var(--line)] p-3">
|
||||
@@ -592,7 +620,7 @@ function TaskComments({
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void handleSubmit(); } }}
|
||||
/>
|
||||
<ActionButton label="Send" tone="accent" onClick={() => void handleSubmit()} icon={ArrowRight} />
|
||||
<ActionButton label="Send" tone="accent" onClick={() => void handleSubmit()} icon={ArrowRight} loading={submitting} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -843,26 +871,36 @@ export function ApprovalInspector({
|
||||
|
||||
{/* Hire agent payload details */}
|
||||
{approval.type === "hire_agent" ? (() => {
|
||||
const proposedAgent = agents.find(
|
||||
(a) => a.status === "pending_approval" && a.reportsTo === approval.requestedByAgentId
|
||||
) ?? agents.find(
|
||||
(a) => a.name.toLowerCase().includes("pending") || a.status === "pending_approval"
|
||||
);
|
||||
if (!proposedAgent) return null;
|
||||
const payload = parseApprovalPayloadJson(approval);
|
||||
const payloadAgentId = typeof payload?.agentId === "string" ? payload.agentId : null;
|
||||
const requestedSnapshot =
|
||||
payload?.requestedConfigurationSnapshot &&
|
||||
typeof payload.requestedConfigurationSnapshot === "object" &&
|
||||
!Array.isArray(payload.requestedConfigurationSnapshot)
|
||||
? payload.requestedConfigurationSnapshot as Record<string, unknown>
|
||||
: null;
|
||||
const proposedAgent =
|
||||
(approval.relatedAgentId ? (agents.find((agent) => agent.id === approval.relatedAgentId) ?? null) : null) ??
|
||||
(payloadAgentId ? (agents.find((agent) => agent.id === payloadAgentId) ?? null) : null);
|
||||
const proposedManagerId =
|
||||
proposedAgent?.reportsTo ??
|
||||
(typeof requestedSnapshot?.reportsTo === "string" ? requestedSnapshot.reportsTo : null);
|
||||
const proposedManager = proposedManagerId
|
||||
? (agents.find((agent) => agent.id === proposedManagerId) ?? null)
|
||||
: null;
|
||||
|
||||
if (!proposedAgent && !requestedSnapshot) return null;
|
||||
return (
|
||||
<div className="rounded-[8px] border border-[color:var(--accent)] border-opacity-30 bg-[color:var(--accent-soft)] px-4 py-3">
|
||||
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--accent)]">Proposed Hire</div>
|
||||
<div className="grid gap-1.5 text-[12px]">
|
||||
<div><span className="text-[color:var(--muted)]">Name:</span> <span className="font-medium text-[color:var(--text)]">{proposedAgent.name}</span></div>
|
||||
<div><span className="text-[color:var(--muted)]">Role:</span> <span className="text-[color:var(--text)]">{proposedAgent.role}</span></div>
|
||||
{proposedAgent.title ? <div><span className="text-[color:var(--muted)]">Title:</span> <span className="text-[color:var(--text)]">{proposedAgent.title}</span></div> : null}
|
||||
{proposedAgent.capabilities ? <div><span className="text-[color:var(--muted)]">Capabilities:</span> <span className="text-[color:var(--text)]">{proposedAgent.capabilities}</span></div> : null}
|
||||
<div><span className="text-[color:var(--muted)]">Connector:</span> <span className="mono text-[color:var(--text)]">{proposedAgent.connectorId}</span></div>
|
||||
<div><span className="text-[color:var(--muted)]">Budget:</span> <span className="mono text-[color:var(--text)]">{formatMoney(proposedAgent.budgetMonthlyUsd)}/mo</span></div>
|
||||
{proposedAgent.reportsTo ? (() => {
|
||||
const manager = agents.find((a) => a.id === proposedAgent.reportsTo);
|
||||
return manager ? <div><span className="text-[color:var(--muted)]">Reports to:</span> <span className="text-[color:var(--text)]">{manager.name}</span></div> : null;
|
||||
})() : null}
|
||||
<div><span className="text-[color:var(--muted)]">Name:</span> <span className="font-medium text-[color:var(--text)]">{proposedAgent?.name ?? approval.payloadSummary.replace(/^Hire\s+/i, "").split(/\s+as\s+/i)[0] ?? "Pending hire"}</span></div>
|
||||
<div><span className="text-[color:var(--muted)]">Role:</span> <span className="text-[color:var(--text)]">{proposedAgent?.role ?? (typeof requestedSnapshot?.role === "string" ? requestedSnapshot.role : "Unspecified")}</span></div>
|
||||
{(proposedAgent?.title || typeof requestedSnapshot?.title === "string") ? <div><span className="text-[color:var(--muted)]">Title:</span> <span className="text-[color:var(--text)]">{proposedAgent?.title || requestedSnapshot?.title as string}</span></div> : null}
|
||||
{(proposedAgent?.capabilities || typeof requestedSnapshot?.capabilities === "string") ? <div><span className="text-[color:var(--muted)]">Capabilities:</span> <span className="text-[color:var(--text)]">{proposedAgent?.capabilities || requestedSnapshot?.capabilities as string}</span></div> : null}
|
||||
<div><span className="text-[color:var(--muted)]">Connector:</span> <span className="mono text-[color:var(--text)]">{proposedAgent?.connectorId ?? (typeof requestedSnapshot?.connectorId === "string" ? requestedSnapshot.connectorId : "Unknown")}</span></div>
|
||||
<div><span className="text-[color:var(--muted)]">Budget:</span> <span className="mono text-[color:var(--text)]">{formatMoney(proposedAgent?.budgetMonthlyUsd ?? (typeof requestedSnapshot?.budgetMonthlyUsd === "number" ? requestedSnapshot.budgetMonthlyUsd : 0))}/mo</span></div>
|
||||
{proposedManager ? <div><span className="text-[color:var(--muted)]">Reports to:</span> <span className="text-[color:var(--text)]">{proposedManager.name}</span></div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1236,7 +1274,7 @@ export function WorkspaceInspector({
|
||||
{workspace.repoRef ? <span> · Ref: <span className="text-[color:var(--text)]">{workspace.repoRef}</span></span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<ActionButton label="Reveal in Finder" onClick={() => void onOpen(workspace.localPath)} icon={ArrowSquareOut} />
|
||||
<ActionButton label="Open folder" onClick={() => void onOpen(workspace.localPath)} icon={ArrowSquareOut} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1542,6 +1580,7 @@ export function SettingsPane({
|
||||
type="checkbox"
|
||||
checked={company.autoApproveHires}
|
||||
onChange={async (e) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
unwrap(await window.agentCompany.saveCompany({
|
||||
id: company.id,
|
||||
@@ -1551,14 +1590,16 @@ export function SettingsPane({
|
||||
autoApproveHires: e.target.checked,
|
||||
}));
|
||||
await onSaved();
|
||||
} catch { /* ignore */ }
|
||||
} catch (error) {
|
||||
setActionError(describeActionError(error));
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 rounded accent-[color:var(--accent)]"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-[13px] font-medium text-[color:var(--text)]">Full Autonomy Mode</div>
|
||||
<div className="text-[13px] font-medium text-[color:var(--text)]">Auto-approve hire and strategy approvals</div>
|
||||
<div className="mt-0.5 text-[11px] text-[color:var(--muted)]">
|
||||
Auto-approve agent hires and CEO strategies. Agents will build the team and start operations without Board approval.
|
||||
Automatically approve agent hiring requests and CEO strategy approvals so the company can continue operating without waiting on the board for those specific approval types.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -61,7 +61,7 @@ export function Inbox({ items, onNavigate }: InboxProps) {
|
||||
const handleItemClick = (item: InboxItem) => {
|
||||
const sectionMap: Record<string, SectionId> = {
|
||||
approval: "approvals",
|
||||
run: "tasks",
|
||||
run: "runs",
|
||||
agent: "agents",
|
||||
task: "tasks",
|
||||
message: "communication",
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
ArrowClockwise,
|
||||
FolderOpen,
|
||||
Lightning,
|
||||
ListChecks,
|
||||
PlayCircle,
|
||||
WarningCircle,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { AgentRecord, RunRecord, TaskRecord, WorkspaceRecord } from "@shared/types";
|
||||
import { formatMoney, formatTime, timeAgo } from "../lib/formatters";
|
||||
import { ActionButton, EmptyState, Input, ListFrame, ListRow, Panel, Select, StatusPill } from "./ui";
|
||||
|
||||
const LOG_CHUNK_BYTES = 65_536;
|
||||
|
||||
interface RunCenterProps {
|
||||
runs: RunRecord[];
|
||||
tasks: TaskRecord[];
|
||||
agents: AgentRecord[];
|
||||
workspaces: WorkspaceRecord[];
|
||||
selectedRunId: string | null;
|
||||
onSelectRun: (runId: string) => void;
|
||||
onCancelRun: (runId: string) => Promise<void> | void;
|
||||
onOpenLogPath: (path: string) => Promise<void> | void;
|
||||
onOpenConsole: (runId: string) => void;
|
||||
}
|
||||
|
||||
interface RunLogState {
|
||||
content: string;
|
||||
offset: number;
|
||||
nextOffset: number;
|
||||
totalBytes: number;
|
||||
eof: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialLogState: RunLogState = {
|
||||
content: "",
|
||||
offset: 0,
|
||||
nextOffset: 0,
|
||||
totalBytes: 0,
|
||||
eof: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
function isActiveStatus(status: RunRecord["status"]) {
|
||||
return status === "queued" || status === "running";
|
||||
}
|
||||
|
||||
function isAttentionStatus(status: RunRecord["status"]) {
|
||||
return status === "failed" || status === "timed_out" || status === "interrupted";
|
||||
}
|
||||
|
||||
export function RunCenter({
|
||||
runs,
|
||||
tasks,
|
||||
agents,
|
||||
workspaces,
|
||||
selectedRunId,
|
||||
onSelectRun,
|
||||
onCancelRun,
|
||||
onOpenLogPath,
|
||||
onOpenConsole,
|
||||
}: RunCenterProps) {
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | RunRecord["status"]>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [logState, setLogState] = useState<RunLogState>(initialLogState);
|
||||
const [logReloadKey, setLogReloadKey] = useState(0);
|
||||
|
||||
const agentMap = useMemo(() => new Map(agents.map((agent) => [agent.id, agent])), [agents]);
|
||||
const taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]);
|
||||
const workspaceMap = useMemo(() => new Map(workspaces.map((workspace) => [workspace.id, workspace])), [workspaces]);
|
||||
|
||||
const selectedRun = useMemo(
|
||||
() => runs.find((run) => run.id === selectedRunId) ?? runs[0] ?? null,
|
||||
[runs, selectedRunId],
|
||||
);
|
||||
|
||||
const filteredRuns = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return runs.filter((run) => {
|
||||
if (statusFilter !== "all" && run.status !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
const task = taskMap.get(run.taskId);
|
||||
const agent = agentMap.get(run.agentId);
|
||||
const workspace = run.workspaceId ? workspaceMap.get(run.workspaceId) : null;
|
||||
return [
|
||||
task?.title ?? "",
|
||||
agent?.name ?? "",
|
||||
workspace?.name ?? "",
|
||||
run.summary ?? "",
|
||||
run.errorMessage ?? "",
|
||||
run.connectorId,
|
||||
run.id,
|
||||
].some((value) => value.toLowerCase().includes(query));
|
||||
});
|
||||
}, [agentMap, runs, search, statusFilter, taskMap, workspaceMap]);
|
||||
|
||||
const groupedCounts = useMemo(() => {
|
||||
let active = 0;
|
||||
let attention = 0;
|
||||
let completed = 0;
|
||||
|
||||
for (const run of runs) {
|
||||
if (isActiveStatus(run.status)) {
|
||||
active += 1;
|
||||
} else if (isAttentionStatus(run.status)) {
|
||||
attention += 1;
|
||||
} else {
|
||||
completed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { active, attention, completed };
|
||||
}, [runs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRunId && !runs.some((run) => run.id === selectedRunId)) {
|
||||
const fallbackRun = runs[0];
|
||||
if (fallbackRun) {
|
||||
onSelectRun(fallbackRun.id);
|
||||
}
|
||||
}
|
||||
}, [onSelectRun, runs, selectedRunId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadInitialChunk() {
|
||||
if (!selectedRun) {
|
||||
setLogState(initialLogState);
|
||||
return;
|
||||
}
|
||||
|
||||
setLogState((current) => ({ ...current, loading: true, error: null }));
|
||||
|
||||
const metaResult = await window.agentCompany.getRunLogChunk({
|
||||
companyId: selectedRun.companyId,
|
||||
runId: selectedRun.id,
|
||||
offset: 0,
|
||||
limit: 1024,
|
||||
});
|
||||
|
||||
if (!metaResult.ok) {
|
||||
if (!cancelled) {
|
||||
setLogState((current) => ({
|
||||
...current,
|
||||
loading: false,
|
||||
error: metaResult.error.message,
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const totalBytes = metaResult.data.totalBytes;
|
||||
const startOffset = Math.max(0, totalBytes - LOG_CHUNK_BYTES);
|
||||
const chunkResult =
|
||||
startOffset === 0 && totalBytes <= LOG_CHUNK_BYTES
|
||||
? metaResult
|
||||
: await window.agentCompany.getRunLogChunk({
|
||||
companyId: selectedRun.companyId,
|
||||
runId: selectedRun.id,
|
||||
offset: startOffset,
|
||||
limit: LOG_CHUNK_BYTES,
|
||||
});
|
||||
|
||||
if (!chunkResult.ok) {
|
||||
if (!cancelled) {
|
||||
setLogState((current) => ({
|
||||
...current,
|
||||
loading: false,
|
||||
error: chunkResult.error.message,
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setLogState({
|
||||
...chunkResult.data,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void loadInitialChunk();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [logReloadKey, selectedRun]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRun || !isActiveStatus(selectedRun.status)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const intervalId = window.setInterval(async () => {
|
||||
const latest = await window.agentCompany.getRunLogChunk({
|
||||
companyId: selectedRun.companyId,
|
||||
runId: selectedRun.id,
|
||||
offset: Math.max(0, logState.totalBytes - LOG_CHUNK_BYTES),
|
||||
limit: LOG_CHUNK_BYTES,
|
||||
});
|
||||
|
||||
if (!latest.ok || cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLogState({
|
||||
...latest.data,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
}, 2500);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [logState.totalBytes, selectedRun]);
|
||||
|
||||
async function loadOlderLogs() {
|
||||
if (!selectedRun || logState.offset <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextOffset = Math.max(0, logState.offset - LOG_CHUNK_BYTES);
|
||||
setLogState((current) => ({ ...current, loading: true, error: null }));
|
||||
|
||||
const result = await window.agentCompany.getRunLogChunk({
|
||||
companyId: selectedRun.companyId,
|
||||
runId: selectedRun.id,
|
||||
offset: nextOffset,
|
||||
limit: logState.offset - nextOffset,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
setLogState((current) => ({
|
||||
...current,
|
||||
loading: false,
|
||||
error: result.error.message,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setLogState({
|
||||
...result.data,
|
||||
content: `${result.data.content}${logState.content}`,
|
||||
nextOffset: logState.nextOffset,
|
||||
eof: logState.eof,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (runs.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="No runs yet"
|
||||
detail="Start a task run from the task queue or onboarding handoff. This surface becomes the operator console for queue state, failures, and live log tails."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<RunSummaryCard
|
||||
label="Active"
|
||||
value={groupedCounts.active}
|
||||
detail="Queued or running right now"
|
||||
icon={PlayCircle}
|
||||
/>
|
||||
<RunSummaryCard
|
||||
label="Needs attention"
|
||||
value={groupedCounts.attention}
|
||||
detail="Failed, timed out, or interrupted"
|
||||
icon={WarningCircle}
|
||||
tone="danger"
|
||||
/>
|
||||
<RunSummaryCard
|
||||
label="Completed"
|
||||
value={groupedCounts.completed}
|
||||
detail="Finished without current intervention"
|
||||
icon={ListChecks}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 xl:grid-cols-[minmax(0,420px)_minmax(0,1fr)]">
|
||||
<div className="space-y-5">
|
||||
<Panel
|
||||
title="Run queue"
|
||||
action={(
|
||||
<Select
|
||||
compact
|
||||
value={statusFilter}
|
||||
onChange={(value) => setStatusFilter(value as "all" | RunRecord["status"])}
|
||||
options={[
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "queued", label: "Queued" },
|
||||
{ value: "running", label: "Running" },
|
||||
{ value: "succeeded", label: "Succeeded" },
|
||||
{ value: "failed", label: "Failed" },
|
||||
{ value: "timed_out", label: "Timed out" },
|
||||
{ value: "cancelled", label: "Cancelled" },
|
||||
{ value: "interrupted", label: "Interrupted" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<Input
|
||||
ariaLabel="Search runs"
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search by task, agent, connector, workspace, or error"
|
||||
/>
|
||||
</div>
|
||||
<ListFrame>
|
||||
{filteredRuns.map((run) => {
|
||||
const task = taskMap.get(run.taskId);
|
||||
const agent = agentMap.get(run.agentId);
|
||||
const workspace = run.workspaceId ? workspaceMap.get(run.workspaceId) : null;
|
||||
const title = task?.title ?? (run.summary || `Run ${run.id.slice(0, 8)}`);
|
||||
const supportingText = run.errorMessage || run.summary || "No summary yet";
|
||||
return (
|
||||
<ListRow
|
||||
key={run.id}
|
||||
onClick={() => onSelectRun(run.id)}
|
||||
className={selectedRun?.id === run.id ? "bg-[color:var(--accent-soft)]" : "hover:bg-[color:var(--panel-soft)]"}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13px] font-semibold text-[color:var(--text)]">{title}</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-[color:var(--muted)]">
|
||||
{agent?.name ?? "Unknown agent"} via {run.connectorId} {workspace ? `· ${workspace.name}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill status={run.status} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 text-[11px] text-[color:var(--muted)]">
|
||||
<span className="truncate">{supportingText}</span>
|
||||
<span className="shrink-0">{timeAgo(run.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</ListRow>
|
||||
);
|
||||
})}
|
||||
</ListFrame>
|
||||
{filteredRuns.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No runs match this filter"
|
||||
detail="Widen the status filter or clear the search query to inspect the full run history."
|
||||
/>
|
||||
) : null}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{selectedRun ? (
|
||||
<>
|
||||
<Panel
|
||||
title="Run detail"
|
||||
action={(
|
||||
<div className="flex items-center gap-2">
|
||||
<ActionButton
|
||||
label="Open console"
|
||||
icon={Lightning}
|
||||
onClick={() => onOpenConsole(selectedRun.id)}
|
||||
/>
|
||||
{isActiveStatus(selectedRun.status) ? (
|
||||
<ActionButton
|
||||
label="Cancel run"
|
||||
tone="danger"
|
||||
icon={WarningCircle}
|
||||
onClick={() => void onCancelRun(selectedRun.id)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<RunMeta label="Status" value={<StatusPill status={selectedRun.status} />} />
|
||||
<RunMeta label="Connector" value={selectedRun.connectorId} />
|
||||
<RunMeta label="Task" value={taskMap.get(selectedRun.taskId)?.title ?? selectedRun.taskId} />
|
||||
<RunMeta label="Agent" value={agentMap.get(selectedRun.agentId)?.name ?? selectedRun.agentId} />
|
||||
<RunMeta label="Workspace" value={selectedRun.workspaceId ? (workspaceMap.get(selectedRun.workspaceId)?.name ?? selectedRun.workspaceId) : "No exclusive workspace lock"} />
|
||||
<RunMeta label="Model" value={selectedRun.model ?? "Connector default"} />
|
||||
<RunMeta label="Started" value={formatTime(selectedRun.startedAt)} />
|
||||
<RunMeta label="Finished" value={formatTime(selectedRun.finishedAt)} />
|
||||
<RunMeta label="Cost" value={formatMoney(selectedRun.costUsd)} />
|
||||
<RunMeta label="Log bytes" value={logState.totalBytes.toLocaleString()} />
|
||||
</div>
|
||||
{selectedRun.errorMessage ? (
|
||||
<div className="mt-5 rounded-[10px] border border-[color:var(--danger)] bg-[color:var(--danger-soft)] px-4 py-3 text-[13px] text-[color:var(--danger)]">
|
||||
{selectedRun.errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedRun.logPath ? (
|
||||
<div className="mt-5">
|
||||
<ActionButton
|
||||
label="Open log file"
|
||||
icon={FolderOpen}
|
||||
onClick={() => void onOpenLogPath(selectedRun.logPath!)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
title="Log tail"
|
||||
eyebrow="Incremental reader"
|
||||
action={(
|
||||
<div className="flex items-center gap-2">
|
||||
<ActionButton
|
||||
label="Reload tail"
|
||||
icon={ArrowClockwise}
|
||||
onClick={() => setLogReloadKey((current) => current + 1)}
|
||||
/>
|
||||
{logState.offset > 0 ? (
|
||||
<ActionButton
|
||||
label="Load older"
|
||||
icon={ListChecks}
|
||||
onClick={() => void loadOlderLogs()}
|
||||
disabled={logState.loading}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{logState.error ? (
|
||||
<div className="rounded-[10px] border border-[color:var(--danger)] bg-[color:var(--danger-soft)] px-4 py-3 text-[13px] text-[color:var(--danger)]">
|
||||
{logState.error}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mb-3 flex items-center justify-between text-[11px] text-[color:var(--muted)]">
|
||||
<span>
|
||||
Showing bytes {logState.offset.toLocaleString()} to {logState.nextOffset.toLocaleString()} of {logState.totalBytes.toLocaleString()}
|
||||
</span>
|
||||
<span>{logState.loading ? "Refreshing…" : logState.eof ? "At latest tail" : "Tail window loaded"}</span>
|
||||
</div>
|
||||
<div className="max-h-[420px] overflow-auto rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3">
|
||||
<pre className="mono whitespace-pre-wrap text-[12px] leading-relaxed text-[color:var(--muted-strong)]">
|
||||
{logState.content || "No log output captured yet."}
|
||||
</pre>
|
||||
</div>
|
||||
</Panel>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunSummaryCard({
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
icon: Icon,
|
||||
tone = "default",
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
detail: string;
|
||||
icon: React.ElementType;
|
||||
tone?: "default" | "danger";
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-[12px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)]">{label}</div>
|
||||
<div className={`mt-1 text-[28px] font-semibold tracking-[-0.03em] ${tone === "danger" ? "text-[color:var(--danger)]" : "text-[color:var(--text)]"}`}>
|
||||
{value}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-[color:var(--muted)]">{detail}</div>
|
||||
</div>
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-[10px] ${tone === "danger" ? "bg-[color:var(--danger-soft)] text-[color:var(--danger)]" : "bg-[color:var(--panel-soft)] text-[color:var(--muted-strong)]"}`}>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunMeta({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-3.5 py-3">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">{label}</div>
|
||||
<div className="mt-1 text-[13px] text-[color:var(--text)]">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { type ElementType, useCallback, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Browser,
|
||||
@@ -18,22 +18,30 @@ import {
|
||||
SignIn,
|
||||
Trash,
|
||||
UserPlus,
|
||||
FacebookLogo,
|
||||
GlobeHemisphereWest,
|
||||
InstagramLogo,
|
||||
LinkedinLogo,
|
||||
RedditLogo,
|
||||
TiktokLogo,
|
||||
XLogo,
|
||||
X,
|
||||
YoutubeLogo,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { BrowserActionRecord, SocialAccountRecord, SocialPlatform } from "@shared/types";
|
||||
import { timeAgo } from "../lib/formatters";
|
||||
import { ActionButton, Input } from "./ui";
|
||||
|
||||
const platformConfig: Record<SocialPlatform, { label: string; color: string; bgColor: string; icon: string }> = {
|
||||
twitter: { label: "Twitter / X", color: "#1DA1F2", bgColor: "#1DA1F210", icon: "𝕏" },
|
||||
linkedin: { label: "LinkedIn", color: "#0A66C2", bgColor: "#0A66C210", icon: "in" },
|
||||
instagram: { label: "Instagram", color: "#E4405F", bgColor: "#E4405F10", icon: "IG" },
|
||||
facebook: { label: "Facebook", color: "#1877F2", bgColor: "#1877F210", icon: "fb" },
|
||||
reddit: { label: "Reddit", color: "#FF4500", bgColor: "#FF450010", icon: "r/" },
|
||||
youtube: { label: "YouTube", color: "#FF0000", bgColor: "#FF000010", icon: "YT" },
|
||||
tiktok: { label: "TikTok", color: "#000000", bgColor: "#00000010", icon: "TT" },
|
||||
bluesky: { label: "Bluesky", color: "#0085FF", bgColor: "#0085FF10", icon: "🦋" },
|
||||
other: { label: "Other", color: "#6B7280", bgColor: "#6B728010", icon: "🌐" },
|
||||
const platformConfig: Record<SocialPlatform, { label: string; color: string; bgColor: string; icon: ElementType }> = {
|
||||
twitter: { label: "Twitter / X", color: "#1D9BF0", bgColor: "#1D9BF014", icon: XLogo },
|
||||
linkedin: { label: "LinkedIn", color: "#0A66C2", bgColor: "#0A66C214", icon: LinkedinLogo },
|
||||
instagram: { label: "Instagram", color: "#E4405F", bgColor: "#E4405F14", icon: InstagramLogo },
|
||||
facebook: { label: "Facebook", color: "#1877F2", bgColor: "#1877F214", icon: FacebookLogo },
|
||||
reddit: { label: "Reddit", color: "#FF4500", bgColor: "#FF450014", icon: RedditLogo },
|
||||
youtube: { label: "YouTube", color: "#FF0000", bgColor: "#FF000014", icon: YoutubeLogo },
|
||||
tiktok: { label: "TikTok", color: "#111315", bgColor: "#11131514", icon: TiktokLogo },
|
||||
bluesky: { label: "Bluesky", color: "#0085FF", bgColor: "#0085FF14", icon: GlobeHemisphereWest },
|
||||
other: { label: "Other", color: "#6B7280", bgColor: "#6B728014", icon: GlobeHemisphereWest },
|
||||
};
|
||||
|
||||
const actionTypeLabels: Record<string, { label: string; icon: React.ElementType }> = {
|
||||
@@ -58,41 +66,51 @@ function AccountCard({
|
||||
account: SocialAccountRecord;
|
||||
onLogin: () => void;
|
||||
onDelete: () => void;
|
||||
onClick: () => void;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const platform = platformConfig[account.platform] ?? platformConfig.other;
|
||||
const isLoggedIn = account.status === "logged_in" || account.status === "active";
|
||||
const PlatformIcon = platform.icon;
|
||||
const cardContent = (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-semibold text-[color:var(--text)]">{account.displayName || account.accountName}</span>
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.1em]"
|
||||
style={{ backgroundColor: platform.bgColor, color: platform.color }}
|
||||
>
|
||||
{platform.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-[color:var(--muted)]">
|
||||
<span>@{account.accountName}</span>
|
||||
{account.requireApproval ? (
|
||||
<span className="flex items-center gap-1 text-[color:var(--warn)]">
|
||||
<ShieldCheck size={10} />
|
||||
Approval required
|
||||
</span>
|
||||
) : null}
|
||||
{account.lastUsedAt ? <span>Last used {timeAgo(account.lastUsedAt)}</span> : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="group flex items-center gap-3 rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3 transition hover:border-[color:var(--line-strong)] hover:shadow-sm">
|
||||
<div
|
||||
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[10px] text-[14px] font-bold"
|
||||
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[10px]"
|
||||
style={{ backgroundColor: platform.bgColor, color: platform.color }}
|
||||
>
|
||||
{platform.icon}
|
||||
<PlatformIcon size={18} weight="fill" />
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={onClick} className="min-w-0 flex-1 text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-semibold text-[color:var(--text)]">{account.displayName || account.accountName}</span>
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.1em]"
|
||||
style={{ backgroundColor: platform.bgColor, color: platform.color }}
|
||||
>
|
||||
{platform.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-[color:var(--muted)]">
|
||||
<span>@{account.accountName}</span>
|
||||
{account.requireApproval ? (
|
||||
<span className="flex items-center gap-1 text-[color:var(--warn)]">
|
||||
<ShieldCheck size={10} />
|
||||
Approval required
|
||||
</span>
|
||||
) : null}
|
||||
{account.lastUsedAt ? <span>Last used {timeAgo(account.lastUsedAt)}</span> : null}
|
||||
</div>
|
||||
</button>
|
||||
{onClick ? (
|
||||
<button type="button" onClick={onClick} className="min-w-0 flex-1 text-left">
|
||||
{cardContent}
|
||||
</button>
|
||||
) : (
|
||||
<div className="min-w-0 flex-1 text-left">{cardContent}</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-semibold ${
|
||||
@@ -185,7 +203,9 @@ function AddAccountDialog({
|
||||
: "border-[color:var(--line)] text-[color:var(--muted)] hover:border-[color:var(--line-strong)]"
|
||||
}`}
|
||||
>
|
||||
<span style={{ color: config.color }}>{config.icon}</span>
|
||||
<span style={{ color: config.color }}>
|
||||
<config.icon size={14} />
|
||||
</span>
|
||||
{config.label}
|
||||
</button>
|
||||
))}
|
||||
@@ -492,7 +512,6 @@ export function SocialAccountsPanel({
|
||||
account={account}
|
||||
onLogin={() => void onLogin(account.id)}
|
||||
onDelete={() => void onDelete(account.id)}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -322,8 +322,8 @@ export function ActionButton({
|
||||
export function StatusPill({ status }: { status: string }) {
|
||||
const positive = ["ready", "active", "running", "succeeded", "approved", "achieved", "done"].includes(status);
|
||||
const negative = ["error", "failed", "cancelled", "blocked", "rejected", "interrupted"].includes(status);
|
||||
const warning = ["auth_required", "degraded", "pending", "in_review", "todo", "queued"].includes(status);
|
||||
const checking = status === "detected" || status === "not_installed";
|
||||
const warning = ["auth_required", "degraded", "pending", "in_review", "todo", "queued", "not_installed"].includes(status);
|
||||
const checking = status === "detected";
|
||||
const toneClass = positive
|
||||
? "text-[color:var(--success)]"
|
||||
: negative
|
||||
|
||||
+51
-11
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import type { AgentMessageRecord, AgentMetrics, BrowserActionRecord, CommentRecord, CompanyMetrics, CompanyTemplate, DesktopEvent, InboxItem, ProfileSnapshot, StandupReport } from "./types";
|
||||
import type { AgentMessageRecord, AgentMetrics, BrowserActionRecord, CommentRecord, CompanyMetrics, DesktopEvent, InboxItem, OnboardingBootstrapResult, ProfileSnapshot, RunLogChunk, StandupReport } from "./types";
|
||||
|
||||
export const IPC_CHANNELS = {
|
||||
loadSnapshot: "agent-company:load-snapshot",
|
||||
@@ -20,6 +20,7 @@ export const IPC_CHANNELS = {
|
||||
startTaskRun: "agent-company:start-task-run",
|
||||
cancelRun: "agent-company:cancel-run",
|
||||
getRunLog: "agent-company:get-run-log",
|
||||
getRunLogChunk: "agent-company:get-run-log-chunk",
|
||||
backupProfile: "agent-company:backup-profile",
|
||||
restoreProfile: "agent-company:restore-profile",
|
||||
updateSettings: "agent-company:update-settings",
|
||||
@@ -34,6 +35,7 @@ export const IPC_CHANNELS = {
|
||||
deleteSecret: "agent-company:delete-secret",
|
||||
getSidebarBadges: "agent-company:get-sidebar-badges",
|
||||
listConnectorModels: "agent-company:list-connector-models",
|
||||
openConnectorAuthTerminal: "agent-company:open-connector-auth-terminal",
|
||||
addComment: "agent-company:add-comment",
|
||||
listComments: "agent-company:list-comments",
|
||||
setHeartbeat: "agent-company:set-heartbeat",
|
||||
@@ -44,8 +46,7 @@ export const IPC_CHANNELS = {
|
||||
getCompanyMetrics: "agent-company:get-company-metrics",
|
||||
getAgentMetrics: "agent-company:get-agent-metrics",
|
||||
getStandupReport: "agent-company:get-standup-report",
|
||||
applyCompanyTemplate: "agent-company:apply-company-template",
|
||||
listCompanyTemplates: "agent-company:list-company-templates",
|
||||
bootstrapOnboarding: "agent-company:bootstrap-onboarding",
|
||||
saveSocialAccount: "agent-company:save-social-account",
|
||||
deleteSocialAccount: "agent-company:delete-social-account",
|
||||
listBrowserActions: "agent-company:list-browser-actions",
|
||||
@@ -181,17 +182,32 @@ export const secretInputSchema = z.object({
|
||||
});
|
||||
|
||||
export const startRunSchema = z.object({
|
||||
companyId: z.string().uuid(),
|
||||
taskId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const cancelRunSchema = z.object({
|
||||
companyId: z.string().uuid(),
|
||||
runId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const runLogSchema = z.object({
|
||||
companyId: z.string().uuid(),
|
||||
runId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const runLogChunkSchema = z.object({
|
||||
companyId: z.string().uuid(),
|
||||
runId: z.string().uuid(),
|
||||
offset: z.number().int().min(0).default(0),
|
||||
limit: z.number().int().min(1024).max(262_144).default(65_536),
|
||||
});
|
||||
|
||||
export const triggerBrowserLoginSchema = z.object({
|
||||
companyId: z.string().uuid(),
|
||||
socialAccountId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const deleteEntitySchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
companyId: z.string().uuid(),
|
||||
@@ -222,6 +238,7 @@ export const commentInputSchema = z.object({
|
||||
});
|
||||
|
||||
export const listCommentsSchema = z.object({
|
||||
companyId: z.string().uuid(),
|
||||
taskId: z.string().uuid(),
|
||||
});
|
||||
|
||||
@@ -244,11 +261,13 @@ export const metricsQuerySchema = z.object({
|
||||
days: z.number().int().min(1).max(90).default(30),
|
||||
});
|
||||
|
||||
export const templateApplySchema = z.object({
|
||||
templateId: z.string(),
|
||||
export const bootstrapOnboardingSchema = z.object({
|
||||
companyName: z.string().min(2).max(80),
|
||||
companyDescription: z.string().max(500).default(""),
|
||||
workspaceId: z.string().uuid().optional(),
|
||||
workspacePath: z.string().default(""),
|
||||
autoApproveHires: z.boolean().default(true),
|
||||
goalTitle: z.string().default(""),
|
||||
goalDescription: z.string().default(""),
|
||||
connectorId: z.enum(["codex_local", "claude_local", "gemini_local", "opencode_local", "kimi_local"]).optional(),
|
||||
});
|
||||
|
||||
@@ -306,7 +325,21 @@ export const knowledgeEntryInputSchema = z.object({
|
||||
authorAgentId: optionalId,
|
||||
importance: z.enum(["critical", "high", "medium", "low"]).default("medium"),
|
||||
tagsJson: z.string().default("[]"),
|
||||
referencedEntityType: z.string().nullable().optional(),
|
||||
referencedEntityType: z.enum([
|
||||
"agents",
|
||||
"goals",
|
||||
"projects",
|
||||
"tasks",
|
||||
"approvals",
|
||||
"runs",
|
||||
"secrets",
|
||||
"meetings",
|
||||
"documents",
|
||||
"knowledge_base",
|
||||
"sprints",
|
||||
"automation_rules",
|
||||
"workflow_pipelines",
|
||||
]).nullable().optional(),
|
||||
referencedEntityId: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
@@ -330,6 +363,11 @@ export const browserActionQuerySchema = z.object({
|
||||
limit: z.number().int().min(1).max(100).default(50),
|
||||
});
|
||||
|
||||
export const cancelBrowserActionSchema = z.object({
|
||||
actionId: z.string().uuid(),
|
||||
companyId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const automationRuleInputSchema = z.object({
|
||||
id: z.preprocess((v) => (v === "" ? null : v), z.string().uuid().nullable().optional()),
|
||||
companyId: z.string().uuid(),
|
||||
@@ -408,6 +446,7 @@ export const toggleAutomationRuleSchema = z.object({
|
||||
export const markMessageReadSchema = z.object({
|
||||
messageId: z.string().uuid(),
|
||||
companyId: z.string().uuid(),
|
||||
readerAgentId: optionalId,
|
||||
});
|
||||
|
||||
export const runWorkflowSchema = z.object({
|
||||
@@ -443,6 +482,7 @@ export type DesktopApi = {
|
||||
startTaskRun: (input: z.infer<typeof startRunSchema>) => Promise<DesktopResult<string>>;
|
||||
cancelRun: (input: z.infer<typeof cancelRunSchema>) => Promise<DesktopResult<boolean>>;
|
||||
getRunLog: (input: z.infer<typeof runLogSchema>) => Promise<DesktopResult<string>>;
|
||||
getRunLogChunk: (input: z.infer<typeof runLogChunkSchema>) => Promise<DesktopResult<RunLogChunk>>;
|
||||
backupProfile: () => Promise<DesktopResult<string | null>>;
|
||||
restoreProfile: () => Promise<DesktopResult<boolean>>;
|
||||
updateSettings: (input: z.infer<typeof settingsSchema>) => Promise<DesktopResult<boolean>>;
|
||||
@@ -457,6 +497,7 @@ export type DesktopApi = {
|
||||
deleteSecret: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
||||
getSidebarBadges: (companyId: string) => Promise<DesktopResult<SidebarBadges>>;
|
||||
listConnectorModels: (connectorId: string) => Promise<DesktopResult<Array<{ id: string; label: string }>>>;
|
||||
openConnectorAuthTerminal: (connectorId: z.infer<typeof connectorInputSchema.shape.id>) => Promise<DesktopResult<boolean>>;
|
||||
addComment: (input: z.infer<typeof commentInputSchema>) => Promise<DesktopResult<string>>;
|
||||
listComments: (input: z.infer<typeof listCommentsSchema>) => Promise<DesktopResult<CommentRecord[]>>;
|
||||
setHeartbeat: (input: z.infer<typeof heartbeatSettingsSchema>) => Promise<DesktopResult<boolean>>;
|
||||
@@ -466,13 +507,12 @@ export type DesktopApi = {
|
||||
getCompanyMetrics: (input: z.infer<typeof metricsQuerySchema>) => Promise<DesktopResult<CompanyMetrics>>;
|
||||
getAgentMetrics: (input: z.infer<typeof metricsQuerySchema>) => Promise<DesktopResult<AgentMetrics>>;
|
||||
getStandupReport: (companyId: string) => Promise<DesktopResult<StandupReport>>;
|
||||
applyCompanyTemplate: (input: z.infer<typeof templateApplySchema>) => Promise<DesktopResult<string>>;
|
||||
listCompanyTemplates: () => Promise<DesktopResult<CompanyTemplate[]>>;
|
||||
bootstrapOnboarding: (input: z.infer<typeof bootstrapOnboardingSchema>) => Promise<DesktopResult<OnboardingBootstrapResult>>;
|
||||
saveSocialAccount: (input: z.infer<typeof socialAccountInputSchema>) => Promise<DesktopResult<string>>;
|
||||
deleteSocialAccount: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
||||
listBrowserActions: (input: z.infer<typeof browserActionQuerySchema>) => Promise<DesktopResult<BrowserActionRecord[]>>;
|
||||
cancelBrowserAction: (input: z.infer<typeof cancelRunSchema>) => Promise<DesktopResult<boolean>>;
|
||||
triggerBrowserLogin: (socialAccountId: string) => Promise<DesktopResult<boolean>>;
|
||||
cancelBrowserAction: (input: z.infer<typeof cancelBrowserActionSchema>) => Promise<DesktopResult<boolean>>;
|
||||
triggerBrowserLogin: (input: z.infer<typeof triggerBrowserLoginSchema>) => Promise<DesktopResult<boolean>>;
|
||||
saveMeeting: (input: z.infer<typeof meetingInputSchema>) => Promise<DesktopResult<string>>;
|
||||
deleteMeeting: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
||||
saveDocument: (input: z.infer<typeof documentInputSchema>) => Promise<DesktopResult<string>>;
|
||||
|
||||
+32
-25
@@ -3,6 +3,7 @@ export type SectionId =
|
||||
| "inbox"
|
||||
| "standup"
|
||||
| "performance"
|
||||
| "runs"
|
||||
| "connectors"
|
||||
| "agents"
|
||||
| "orgchart"
|
||||
@@ -243,6 +244,14 @@ export interface RunRecord {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RunLogChunk {
|
||||
content: string;
|
||||
offset: number;
|
||||
nextOffset: number;
|
||||
totalBytes: number;
|
||||
eof: boolean;
|
||||
}
|
||||
|
||||
export interface CostRecord {
|
||||
id: string;
|
||||
companyId: string;
|
||||
@@ -422,30 +431,14 @@ export interface CompanyMetrics {
|
||||
agentMetrics: AgentMetrics[];
|
||||
}
|
||||
|
||||
export interface CompanyTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
agents: Array<{
|
||||
name: string;
|
||||
role: string;
|
||||
title: string;
|
||||
department: DepartmentId;
|
||||
capabilities: string;
|
||||
reportsToIndex: number | null; // index in this array, null = CEO
|
||||
connectorPreference: ConnectorId;
|
||||
budgetMonthlyUsd: number;
|
||||
}>;
|
||||
goals: Array<{
|
||||
title: string;
|
||||
description: string;
|
||||
}>;
|
||||
departments?: Array<{
|
||||
id: DepartmentId;
|
||||
name: string;
|
||||
description: string;
|
||||
}>;
|
||||
export interface OnboardingBootstrapResult {
|
||||
companyId: string;
|
||||
companyName: string;
|
||||
workspaceId: string | null;
|
||||
leadAgentId: string | null;
|
||||
projectId: string | null;
|
||||
taskId: string | null;
|
||||
runId: string | null;
|
||||
}
|
||||
|
||||
export type SocialPlatform = "twitter" | "linkedin" | "instagram" | "facebook" | "reddit" | "youtube" | "tiktok" | "bluesky" | "other";
|
||||
@@ -582,7 +575,21 @@ export interface KnowledgeEntryRecord {
|
||||
authorAgentId: string | null;
|
||||
importance: "critical" | "high" | "medium" | "low";
|
||||
tagsJson: string;
|
||||
referencedEntityType: string | null;
|
||||
referencedEntityType:
|
||||
| "agents"
|
||||
| "goals"
|
||||
| "projects"
|
||||
| "tasks"
|
||||
| "approvals"
|
||||
| "runs"
|
||||
| "secrets"
|
||||
| "meetings"
|
||||
| "documents"
|
||||
| "knowledge_base"
|
||||
| "sprints"
|
||||
| "automation_rules"
|
||||
| "workflow_pipelines"
|
||||
| null;
|
||||
referencedEntityId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
Reference in new issue
Block a user