diff --git a/AgentCompany-New/src/main/app/ipc-handlers.ts b/AgentCompany-New/src/main/app/ipc-handlers.ts index aa86d6ee..af10edac 100644 --- a/AgentCompany-New/src/main/app/ipc-handlers.ts +++ b/AgentCompany-New/src/main/app/ipc-handlers.ts @@ -20,6 +20,7 @@ import { OperationsService } from "../services/operations-service"; import { SdlcService } from "../services/sdlc-service"; import { AuditService } from "../services/audit-service"; import { RollbackService } from "../services/rollback-service"; +import { TaskExecutionService } from "../services/task-execution-service"; export interface IpcDeps { connection: DatabaseConnection; @@ -33,6 +34,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { const approvals = new ApprovalService(deps.connection.db); const chat = new ChatService(deps.connection.db); const providers = new ProviderService(deps.connection.db); + const taskExecution = new TaskExecutionService(deps.connection.db, providers); const skills = new SkillService(deps.connection.db, deps.artifactRoot); const social = new SocialService(deps.connection.db); const files = new FileService(deps.connection.db, deps.artifactRoot); @@ -86,6 +88,16 @@ export function registerIpcHandlers(deps: IpcDeps): void { ipcMain.handle(IPC_CHANNELS.createAgent, (_event, input) => mutate(() => organization.createAgent(input))); ipcMain.handle(IPC_CHANNELS.createTask, (_event, input) => mutate(() => organization.createTask(input))); ipcMain.handle(IPC_CHANNELS.updateTaskStatus, (_event, input) => mutate(() => organization.updateTaskStatus(input.orgId, input.taskId, input.status))); + ipcMain.handle(IPC_CHANNELS.runTask, async (_event, input) => { + try { + await taskExecution.runTask(input); + deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" }); + return ok(snapshot.load()); + } catch (error) { + deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" }); + return fail("task_run_failed", error instanceof Error ? error.message : String(error)); + } + }); ipcMain.handle(IPC_CHANNELS.createApproval, (_event, input) => mutate(() => approvals.create(input))); ipcMain.handle(IPC_CHANNELS.decideApproval, (_event, input) => mutate(() => approvals.decide(input))); ipcMain.handle(IPC_CHANNELS.syncEmployeeIdentity, (_event, input) => mutate(() => identityAccess.syncEmployeeIdentity(input))); diff --git a/AgentCompany-New/src/main/database/migrations/024-task-run-provider-request.ts b/AgentCompany-New/src/main/database/migrations/024-task-run-provider-request.ts new file mode 100644 index 00000000..9c6ab8b4 --- /dev/null +++ b/AgentCompany-New/src/main/database/migrations/024-task-run-provider-request.ts @@ -0,0 +1,10 @@ +export const taskRunProviderRequestSchema = ` + alter table task_runs add column provider_request_id text references provider_requests(id) on delete set null; + + create index if not exists idx_task_runs_provider_request + on task_runs(provider_request_id) + where provider_request_id is not null; + + insert into schema_migrations(version, name, applied_at) + values (24, 'task_run_provider_request', datetime('now')); +`; diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index e0e12b71..f42fa5a4 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -22,6 +22,7 @@ import { rollbackSnapshotsSchema } from "./migrations/020-rollback-snapshots"; import { providerAuthBrokerSchema } from "./migrations/021-provider-auth-broker"; import { providerRateLimitObservationSchema } from "./migrations/022-provider-rate-limit-observations"; import { providerRequestRuntimeSchema } from "./migrations/023-provider-request-runtime"; +import { taskRunProviderRequestSchema } from "./migrations/024-task-run-provider-request"; interface Migration { version: number; @@ -53,6 +54,7 @@ const MIGRATIONS: Migration[] = [ { version: 21, name: "provider_auth_broker", sql: providerAuthBrokerSchema }, { version: 22, name: "provider_rate_limit_observations", sql: providerRateLimitObservationSchema }, { version: 23, name: "provider_request_runtime", sql: providerRequestRuntimeSchema }, + { version: 24, name: "task_run_provider_request", sql: taskRunProviderRequestSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/AgentCompany-New/src/main/services/migration-service.ts b/AgentCompany-New/src/main/services/migration-service.ts index 5dbe05f8..993af68e 100644 --- a/AgentCompany-New/src/main/services/migration-service.ts +++ b/AgentCompany-New/src/main/services/migration-service.ts @@ -139,6 +139,7 @@ const RELATION_CHECKS: RelationCheck[] = [ { table: "task_runs", column: "task_id", referencedTable: "tasks", referencedColumn: "id", reason: "dangling_task_id" }, { table: "task_runs", column: "agent_id", referencedTable: "agents", referencedColumn: "id", reason: "dangling_agent_id" }, { table: "task_runs", column: "provider_id", referencedTable: "providers", referencedColumn: "id", reason: "dangling_provider_id" }, + { table: "task_runs", column: "provider_request_id", referencedTable: "provider_requests", referencedColumn: "id", reason: "dangling_provider_request_id" }, { table: "approvals", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, { table: "approvals", column: "decided_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_decider_id" }, { table: "approval_routing_rules", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, diff --git a/AgentCompany-New/src/main/services/provider-background-service.ts b/AgentCompany-New/src/main/services/provider-background-service.ts index 468bc08e..aff62ef0 100644 --- a/AgentCompany-New/src/main/services/provider-background-service.ts +++ b/AgentCompany-New/src/main/services/provider-background-service.ts @@ -26,6 +26,10 @@ const ACTIVE_BACKGROUND_STATUSES = new Set(["queued", "in_progress"]); const FAILED_BACKGROUND_STATUSES = new Set(["failed", "incomplete"]); const CANCELLED_BACKGROUND_STATUSES = new Set(["cancelled"]); +type BackgroundCreateInput = CreateProviderResponseInput & { + onRequestCreated?: (requestId: string) => void; +}; + export class ProviderBackgroundService { private readonly audit: AuditService; private readonly authBroker: AuthBrokerService; @@ -47,7 +51,7 @@ export class ProviderBackgroundService { this.responseObserver = new ProviderResponseObserver(db, this.audit, this.operations, this.requestStore, this.rateLimitRegistry); } - async create(input: CreateProviderResponseInput): Promise { + async create(input: BackgroundCreateInput): Promise { const execution = this.prepareExecution(input); const requestId = this.requestStore.create({ orgId: input.orgId, @@ -58,6 +62,7 @@ export class ProviderBackgroundService { executionMode: PROVIDER_EXECUTION_MODE.BACKGROUND, createdAt: new Date().toISOString(), }); + input.onRequestCreated?.(requestId); const startedAt = Date.now(); try { const authProfile = execution.adapter.parseAuthProfile(execution.credential.secret); diff --git a/AgentCompany-New/src/main/services/provider-service.ts b/AgentCompany-New/src/main/services/provider-service.ts index f082f7e5..b3d7c87f 100644 --- a/AgentCompany-New/src/main/services/provider-service.ts +++ b/AgentCompany-New/src/main/services/provider-service.ts @@ -24,6 +24,10 @@ const HEALTH_STATE_FAILED = "failed"; const HEALTH_STATE_UNCHECKED = "unchecked"; const WORKSPACE_SCOPES = new Set(["organization", "workspace"]); +export type ProviderResponseCreateInput = CreateProviderResponseInput & { + onRequestCreated?: (requestId: string) => void; +}; + export class ProviderService { private readonly audit: AuditService; private readonly authBroker: AuthBrokerService; @@ -193,11 +197,12 @@ export class ProviderService { }); } - async createResponse(input: CreateProviderResponseInput): Promise { + async createResponse(input: ProviderResponseCreateInput): Promise { if (input.executionMode === PROVIDER_EXECUTION_MODE.BACKGROUND) return this.background.create(input); const execution = this.prepareExecution(input); if (!execution.adapter.createResponse) throw new Error(`Provider response execution is unavailable for ${execution.provider.provider_key}.`); const requestId = this.createRequestRecord(execution); + input.onRequestCreated?.(requestId); const startedAt = Date.now(); try { const authProfile = execution.adapter.parseAuthProfile(execution.credential.secret); diff --git a/AgentCompany-New/src/main/services/sdlc-service.ts b/AgentCompany-New/src/main/services/sdlc-service.ts index 31a595b4..1abb655f 100644 --- a/AgentCompany-New/src/main/services/sdlc-service.ts +++ b/AgentCompany-New/src/main/services/sdlc-service.ts @@ -262,7 +262,7 @@ export class SdlcService { { key: "provider_adapter_coverage", passed: missing.length === 0, value: `${adapterKeys.length}/${catalogKeys.length}` }, { key: "provider_rate_limit_registry", passed: rateLimitRegistry === 1, value: rateLimitRegistry }, { key: "provider_runtime_contract", passed: runtimeContractReady, value: runtimeContractReady }, - { key: "schema_version", passed: schemaVersion >= 23, value: schemaVersion }, + { key: "schema_version", passed: schemaVersion >= 24, value: schemaVersion }, ]; } diff --git a/AgentCompany-New/src/main/services/task-execution-service.ts b/AgentCompany-New/src/main/services/task-execution-service.ts new file mode 100644 index 00000000..2ee6567f --- /dev/null +++ b/AgentCompany-New/src/main/services/task-execution-service.ts @@ -0,0 +1,255 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { RunTaskInput } from "../../shared/commands"; +import { PROVIDER_EXECUTION_MODE } from "../../shared/domain"; +import { createId } from "../../shared/ids"; +import { AuditService } from "./audit-service"; +import { ProviderService } from "./provider-service"; + +const TASK_RUN_STATE = { + RUNNING: "running", + COMPLETED: "completed", + FAILED: "failed", +} as const; + +interface TaskExecutionTaskRow { + id: string; + org_id: string; + title: string; + description: string; + status: string; + assignee_agent_id: string | null; + risk: string; + project_id: string | null; + goal_id: string | null; + workspace_id: string | null; + priority: string; + requires_filesystem: number; +} + +interface TaskExecutionAgentRow { + id: string; + name: string; + role: string; + department: string; + title: string; + role_level: string; + model_provider_id: string | null; + model_id: string | null; +} + +interface TaskExecutionProviderRow { + id: string; + label: string; + provider_key: string; + model: string | null; +} + +interface ProjectRow { + name: string; + description: string; +} + +interface GoalRow { + title: string; + description: string; +} + +interface WorkspaceRow { + name: string; + local_path: string; + repo_url: string; + repo_ref: string; +} + +export class TaskExecutionService { + private readonly audit: AuditService; + + constructor( + private readonly db: DatabaseSync, + private readonly providers: ProviderService = new ProviderService(db), + ) { + this.audit = new AuditService(db); + } + + async runTask(input: RunTaskInput): Promise { + const task = this.requireTask(input.orgId, input.taskId); + const agent = this.requireAssignedAgent(input.orgId, task); + const provider = this.requireAgentProvider(input.orgId, agent); + const runId = this.createRun(input.orgId, task.id, agent.id, provider.id); + let providerRequestId: string | null = null; + + try { + const requestId = await this.providers.createResponse({ + orgId: input.orgId, + providerId: provider.id, + prompt: buildTaskPrompt({ + task, + agent, + provider, + project: this.project(task.project_id), + goal: this.goal(task.goal_id), + workspace: this.workspace(task.workspace_id), + }), + instructions: buildAgentInstructions(agent), + executionMode: PROVIDER_EXECUTION_MODE.FOREGROUND, + onRequestCreated: (requestId) => { + providerRequestId = requestId; + this.linkProviderRequest(input.orgId, runId, requestId); + }, + }); + this.completeRun(input.orgId, runId, requestId); + this.audit.record({ + orgId: input.orgId, + entityType: "task_run", + entityId: runId, + actorType: "system", + actorId: null, + action: "completed", + payload: { taskId: task.id, agentId: agent.id, providerId: provider.id, providerRequestId: requestId }, + }); + return runId; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.failRun(input.orgId, runId, providerRequestId, message); + this.audit.record({ + orgId: input.orgId, + entityType: "task_run", + entityId: runId, + actorType: "system", + actorId: null, + action: "failed", + payload: { taskId: task.id, agentId: agent.id, providerId: provider.id, providerRequestId, errorMessage: message }, + }); + throw error; + } + } + + private requireTask(orgId: string, taskId: string): TaskExecutionTaskRow { + const task = this.db.prepare("select * from tasks where id = ? and org_id = ?").get(taskId, orgId) as TaskExecutionTaskRow | undefined; + if (!task) throw new Error("Task was not found."); + return task; + } + + private requireAssignedAgent(orgId: string, task: TaskExecutionTaskRow): TaskExecutionAgentRow { + if (!task.assignee_agent_id) throw new Error("Task requires an assigned agent."); + const agent = this.db.prepare(` + select id, name, role, department, title, role_level, model_provider_id, model_id + from agents + where id = ? and org_id = ? + `).get(task.assignee_agent_id, orgId) as TaskExecutionAgentRow | undefined; + if (!agent) throw new Error("Task assignee was not found."); + return agent; + } + + private requireAgentProvider(orgId: string, agent: TaskExecutionAgentRow): TaskExecutionProviderRow { + if (!agent.model_provider_id) throw new Error("Task assignee requires a model provider."); + const provider = this.db.prepare(` + select id, label, provider_key, model + from providers + where id = ? and org_id = ? + `).get(agent.model_provider_id, orgId) as TaskExecutionProviderRow | undefined; + if (!provider) throw new Error("Task assignee provider was not found."); + return provider; + } + + private createRun(orgId: string, taskId: string, agentId: string, providerId: string): string { + const runId = createId("run"); + const now = new Date().toISOString(); + this.db.prepare(` + insert into task_runs( + id, org_id, task_id, agent_id, provider_id, provider_request_id, + trigger, state, created_at, started_at, completed_at, error_message + ) values (?, ?, ?, ?, ?, null, ?, ?, ?, ?, null, null) + `).run(runId, orgId, taskId, agentId, providerId, "manual", TASK_RUN_STATE.RUNNING, now, now); + this.audit.record({ + orgId, + entityType: "task_run", + entityId: runId, + actorType: "system", + actorId: null, + action: "started", + payload: { taskId, agentId, providerId, trigger: "manual" }, + }); + return runId; + } + + private linkProviderRequest(orgId: string, runId: string, providerRequestId: string): void { + this.db.prepare("update task_runs set provider_request_id = ? where id = ? and org_id = ?") + .run(providerRequestId, runId, orgId); + } + + private completeRun(orgId: string, runId: string, providerRequestId: string): void { + this.db.prepare(` + update task_runs + set state = ?, provider_request_id = ?, completed_at = ?, error_message = null + where id = ? and org_id = ? + `).run(TASK_RUN_STATE.COMPLETED, providerRequestId, new Date().toISOString(), runId, orgId); + } + + private failRun(orgId: string, runId: string, providerRequestId: string | null, errorMessage: string): void { + this.db.prepare(` + update task_runs + set state = ?, provider_request_id = coalesce(?, provider_request_id), completed_at = ?, error_message = ? + where id = ? and org_id = ? + `).run(TASK_RUN_STATE.FAILED, providerRequestId, new Date().toISOString(), errorMessage, runId, orgId); + } + + private project(projectId: string | null): ProjectRow | null { + if (!projectId) return null; + return (this.db.prepare("select name, description from projects where id = ?").get(projectId) as unknown as ProjectRow | undefined) ?? null; + } + + private goal(goalId: string | null): GoalRow | null { + if (!goalId) return null; + return (this.db.prepare("select title, description from goals where id = ?").get(goalId) as unknown as GoalRow | undefined) ?? null; + } + + private workspace(workspaceId: string | null): WorkspaceRow | null { + if (!workspaceId) return null; + return (this.db.prepare("select name, local_path, repo_url, repo_ref from workspaces where id = ?").get(workspaceId) as unknown as WorkspaceRow | undefined) ?? null; + } +} + +function buildAgentInstructions(agent: TaskExecutionAgentRow): string { + return [ + `You are ${agent.name}, ${agent.title || agent.role}.`, + `Department: ${agent.department}.`, + `Role level: ${agent.role_level}.`, + "Return an auditable execution result with concrete output and next action.", + ].join("\n"); +} + +function buildTaskPrompt(input: { + task: TaskExecutionTaskRow; + agent: TaskExecutionAgentRow; + provider: TaskExecutionProviderRow; + project: ProjectRow | null; + goal: GoalRow | null; + workspace: WorkspaceRow | null; +}): string { + const lines = [ + "Execute this AgentCompany task.", + "", + `Task: ${input.task.title}`, + `Description: ${input.task.description}`, + `Status: ${input.task.status}`, + `Priority: ${input.task.priority}`, + `Risk: ${input.task.risk}`, + `Requires filesystem: ${input.task.requires_filesystem === 1 ? "yes" : "no"}`, + `Assignee: ${input.agent.name} (${input.agent.role})`, + `Provider: ${input.provider.label} ${input.provider.model ?? input.agent.model_id ?? ""}`.trim(), + ]; + if (input.goal) lines.push("", `Goal: ${input.goal.title}`, `Goal detail: ${input.goal.description}`); + if (input.project) lines.push("", `Project: ${input.project.name}`, `Project detail: ${input.project.description}`); + if (input.workspace) { + lines.push( + "", + `Workspace: ${input.workspace.name}`, + `Local path: ${input.workspace.local_path}`, + `Repo: ${input.workspace.repo_url}`, + `Ref: ${input.workspace.repo_ref}`, + ); + } + lines.push("", "Deliverable: completed work, decisions made, evidence needed, and next action."); + return lines.join("\n"); +} diff --git a/AgentCompany-New/src/preload/index.ts b/AgentCompany-New/src/preload/index.ts index fb40560f..c30d04a1 100644 --- a/AgentCompany-New/src/preload/index.ts +++ b/AgentCompany-New/src/preload/index.ts @@ -15,6 +15,7 @@ const api: DesktopApi = { createAgent: (input) => invoke(IPC_CHANNELS.createAgent, input), createTask: (input) => invoke(IPC_CHANNELS.createTask, input), updateTaskStatus: (input) => invoke(IPC_CHANNELS.updateTaskStatus, input), + runTask: (input) => invoke(IPC_CHANNELS.runTask, input), createApproval: (input) => invoke(IPC_CHANNELS.createApproval, input), decideApproval: (input) => invoke(IPC_CHANNELS.decideApproval, input), syncEmployeeIdentity: (input) => invoke(IPC_CHANNELS.syncEmployeeIdentity, input), diff --git a/AgentCompany-New/src/renderer/src/pages/WorkPage.tsx b/AgentCompany-New/src/renderer/src/pages/WorkPage.tsx index ea67ea35..dfcbe461 100644 --- a/AgentCompany-New/src/renderer/src/pages/WorkPage.tsx +++ b/AgentCompany-New/src/renderer/src/pages/WorkPage.tsx @@ -4,8 +4,10 @@ import { TASK_STATUS, type AgentRecord, type GoalRecord, + type ProviderRecord, type ProjectRecord, type TaskRecord, + type TaskRunRecord, type TaskStatus, type WorkspaceRecord, } from "../../../shared/domain"; @@ -38,12 +40,17 @@ export function WorkPage({ snapshot, actions }: PageProps) { const projects = useMemo(() => snapshot.projects.filter((project) => project.orgId === orgId), [snapshot.projects, orgId]); const goals = useMemo(() => snapshot.goals.filter((goal) => goal.orgId === orgId), [snapshot.goals, orgId]); const workspaces = useMemo(() => snapshot.workspaces.filter((workspace) => workspace.orgId === orgId), [snapshot.workspaces, orgId]); + const providers = useMemo(() => snapshot.providers.filter((provider) => provider.orgId === orgId), [snapshot.providers, orgId]); + const taskRuns = useMemo(() => snapshot.taskRuns.filter((run) => run.orgId === orgId), [snapshot.taskRuns, orgId]); const advance = (task: TaskRecord) => { const next = nextStatus(task.status); if (!next) return; void actions.run(() => window.agentCompany.updateTaskStatus({ orgId, taskId: task.id, status: next })); }; + const runTask = (task: TaskRecord) => { + void actions.run(() => window.agentCompany.runTask({ orgId, taskId: task.id })); + }; return (
@@ -68,11 +75,14 @@ export function WorkPage({ snapshot, actions }: PageProps) { projects={projects} goals={goals} workspaces={workspaces} + providers={providers} + taskRuns={taskRuns} onAdvance={advance} + onRun={runTask} onCreate={() => setNewTaskOpen(true)} /> )} - {view === "list" && } + {view === "list" && } {view === "timeline" && } setNewTaskOpen(false)} /> @@ -86,7 +96,10 @@ function BoardView({ projects, goals, workspaces, + providers, + taskRuns, onAdvance, + onRun, onCreate, }: { tasks: TaskRecord[]; @@ -94,7 +107,10 @@ function BoardView({ projects: ProjectRecord[]; goals: GoalRecord[]; workspaces: WorkspaceRecord[]; + providers: ProviderRecord[]; + taskRuns: TaskRunRecord[]; onAdvance: (task: TaskRecord) => void; + onRun: (task: TaskRecord) => void; onCreate: () => void; }) { return ( @@ -117,7 +133,10 @@ function BoardView({ project={projects.find((project) => project.id === task.projectId) ?? null} goal={goals.find((goal) => goal.id === task.goalId) ?? null} workspace={workspaces.find((workspace) => workspace.id === task.workspaceId) ?? null} + provider={providerFor(agents.find((agent) => agent.id === task.assigneeAgentId) ?? null, providers)} + latestRun={latestTaskRun(task.id, taskRuns)} onAdvance={() => onAdvance(task)} + onRun={() => onRun(task)} /> ))}
@@ -127,24 +146,37 @@ function BoardView({ ); } -function ListView({ tasks, agents, projects }: { tasks: TaskRecord[]; agents: AgentRecord[]; projects: ProjectRecord[] }) { +function ListView({ + tasks, + agents, + projects, + providers, + onRun, +}: { + tasks: TaskRecord[]; + agents: AgentRecord[]; + projects: ProjectRecord[]; + providers: ProviderRecord[]; + onRun: (task: TaskRecord) => void; +}) { return (
Title
Status
Priority
Assignee
{tasks.map((task, index) => { const assignee = agents.find((agent) => agent.id === task.assigneeAgentId) ?? null; + const provider = providerFor(assignee, providers); const project = projects.find((p) => p.id === task.projectId) ?? null; const last = index === tasks.length - 1; return (
{task.risk === "high" && } @@ -163,7 +195,8 @@ function ListView({ tasks, agents, projects }: { tasks: TaskRecord[]; agents: Ag — )}
-
+
+ {assignee && onRun(task)} disabled={!canRun(provider)} />}
@@ -203,14 +236,20 @@ function TaskCard({ project, goal, workspace, + provider, + latestRun, onAdvance, + onRun, }: { task: TaskRecord; assignee: AgentRecord | null; project: ProjectRecord | null; goal: GoalRecord | null; workspace: WorkspaceRecord | null; + provider: ProviderRecord | null; + latestRun: TaskRunRecord | null; onAdvance: () => void; + onRun: () => void; }) { return (
{truncate(goal.title, 20)}} {workspace && {workspace.name}} {task.requiresFilesystem && workspace} + {latestRun && {prettyRunState(latestRun.state)}}
{assignee && } + {assignee && } {nextStatus(task.status) && }
@@ -266,6 +307,34 @@ function prettyStatus(status: TaskStatus): string { return status.replaceAll("_", " "); } +function prettyRunState(state: string): string { + return state.replaceAll("_", " "); +} + +function providerFor(assignee: AgentRecord | null, providers: ProviderRecord[]): ProviderRecord | null { + if (!assignee?.modelProviderId) return null; + return providers.find((provider) => provider.id === assignee.modelProviderId) ?? null; +} + +function canRun(provider: ProviderRecord | null): boolean { + return provider?.enabled === true && provider.authState === "configured"; +} + +function latestTaskRun(taskId: string, taskRuns: TaskRunRecord[]): TaskRunRecord | null { + for (let index = taskRuns.length - 1; index >= 0; index -= 1) { + const run = taskRuns[index]; + if (run?.taskId === taskId) return run; + } + return null; +} + +function runTone(state: string): "amber" | "green" | "red" | undefined { + if (state === "running" || state === "queued") return "amber"; + if (state === "completed") return "green"; + if (state === "failed") return "red"; + return undefined; +} + function capitalize(value: string): string { return value.charAt(0).toUpperCase() + value.slice(1); } diff --git a/AgentCompany-New/src/renderer/src/shared/ui.tsx b/AgentCompany-New/src/renderer/src/shared/ui.tsx index fcb27e2a..c7c810dc 100644 --- a/AgentCompany-New/src/renderer/src/shared/ui.tsx +++ b/AgentCompany-New/src/renderer/src/shared/ui.tsx @@ -114,12 +114,14 @@ export function IconButton({ label, variant = "ghost", size = 14, + disabled = false, }: { icon: IconName; onClick?: () => void; label: string; variant?: "ghost" | "solid"; size?: number; + disabled?: boolean; }) { return ( diff --git a/AgentCompany-New/src/renderer/src/styles.css b/AgentCompany-New/src/renderer/src/styles.css index ee93a492..0e8555d6 100644 --- a/AgentCompany-New/src/renderer/src/styles.css +++ b/AgentCompany-New/src/renderer/src/styles.css @@ -370,6 +370,8 @@ a { color: inherit; } .icon-button:hover { background: var(--panel-2); color: var(--text-1); } .icon-button.ghost { border-color: transparent; background: transparent; } .icon-button.ghost:hover { background: var(--hover); color: var(--text-1); } +.icon-button:disabled { opacity: 0.4; cursor: default; } +.icon-button:disabled:hover { background: transparent; color: var(--text-2); } .segmented { display: inline-flex; diff --git a/AgentCompany-New/src/shared/commands.ts b/AgentCompany-New/src/shared/commands.ts index bf0dd669..6711a60c 100644 --- a/AgentCompany-New/src/shared/commands.ts +++ b/AgentCompany-New/src/shared/commands.ts @@ -73,6 +73,11 @@ export interface UpdateTaskStatusInput { status: TaskStatus; } +export interface RunTaskInput { + orgId: string; + taskId: string; +} + export interface CreateApprovalInput { orgId: string; kind: string; diff --git a/AgentCompany-New/src/shared/desktop-api.ts b/AgentCompany-New/src/shared/desktop-api.ts index 20443959..40f04be6 100644 --- a/AgentCompany-New/src/shared/desktop-api.ts +++ b/AgentCompany-New/src/shared/desktop-api.ts @@ -32,6 +32,7 @@ import type { RunChaosDrillInput, RunEvalSuiteInput, RunSdlcReviewInput, + RunTaskInput, RevokeProviderCredentialInput, SaveProviderCredentialInput, SearchInput, @@ -64,6 +65,7 @@ export interface DesktopApi { createAgent(input: CreateAgentInput): Promise>; createTask(input: CreateTaskInput): Promise>; updateTaskStatus(input: UpdateTaskStatusInput): Promise>; + runTask(input: RunTaskInput): Promise>; createApproval(input: CreateApprovalInput): Promise>; decideApproval(input: DecideApprovalInput): Promise>; syncEmployeeIdentity(input: SyncEmployeeIdentityInput): Promise>; diff --git a/AgentCompany-New/src/shared/domain.ts b/AgentCompany-New/src/shared/domain.ts index 0d955985..8998b6cd 100644 --- a/AgentCompany-New/src/shared/domain.ts +++ b/AgentCompany-New/src/shared/domain.ts @@ -139,6 +139,7 @@ export interface TaskRunRecord { taskId: string; agentId: string | null; providerId: string | null; + providerRequestId: string | null; trigger: string; state: string; createdAt: string; diff --git a/AgentCompany-New/src/shared/ipc.ts b/AgentCompany-New/src/shared/ipc.ts index 31835afb..9a1bc278 100644 --- a/AgentCompany-New/src/shared/ipc.ts +++ b/AgentCompany-New/src/shared/ipc.ts @@ -7,6 +7,7 @@ export const IPC_CHANNELS = { createAgent: "agentcompany:create-agent", createTask: "agentcompany:create-task", updateTaskStatus: "agentcompany:update-task-status", + runTask: "agentcompany:run-task", createApproval: "agentcompany:create-approval", decideApproval: "agentcompany:decide-approval", syncEmployeeIdentity: "agentcompany:sync-employee-identity", diff --git a/AgentCompany-New/tests/unit/domain-services.test.ts b/AgentCompany-New/tests/unit/domain-services.test.ts index 44be863f..e4157601 100644 --- a/AgentCompany-New/tests/unit/domain-services.test.ts +++ b/AgentCompany-New/tests/unit/domain-services.test.ts @@ -23,7 +23,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24]); connection.close(); }); diff --git a/AgentCompany-New/tests/unit/migration-service.test.ts b/AgentCompany-New/tests/unit/migration-service.test.ts index 3d6d4ac9..7c7a2e56 100644 --- a/AgentCompany-New/tests/unit/migration-service.test.ts +++ b/AgentCompany-New/tests/unit/migration-service.test.ts @@ -104,7 +104,7 @@ describe("migration service", () => { create table workspaces(id text primary key, org_id text, project_id text); create table goals(id text primary key, org_id text, owner_agent_id text); create table projects(id text primary key, org_id text, goal_id text, lead_agent_id text); - create table task_runs(id text primary key, org_id text, task_id text, agent_id text, provider_id text); + create table task_runs(id text primary key, org_id text, task_id text, agent_id text, provider_id text, provider_request_id text); create table approvals(id text primary key, org_id text, decided_by_employee_id text); create table approval_routing_rules(id text primary key, org_id text); create table approval_assignments(id text primary key, org_id text, approval_id text, routing_rule_id text); @@ -173,7 +173,7 @@ describe("migration service", () => { insert into goals(id, org_id, owner_agent_id) values ('goal-1', 'org-1', 'agent-1'); insert into projects(id, org_id, goal_id, lead_agent_id) values ('prj-1', 'org-1', 'goal-1', 'agent-1'); insert into tasks(id, org_id, assignee_agent_id, project_id, goal_id, workspace_id) values ('task-1', 'org-1', 'agent-1', 'prj-1', 'goal-1', 'wsp-1'); - insert into task_runs(id, org_id, task_id, agent_id, provider_id) values ('run-1', 'org-1', 'task-1', 'agent-1', 'prov-1'); + insert into task_runs(id, org_id, task_id, agent_id, provider_id, provider_request_id) values ('run-1', 'org-1', 'task-1', 'agent-1', 'prov-1', 'req-1'); insert into approvals(id, org_id, decided_by_employee_id) values ('approval-1', 'org-1', 'emp-1'); insert into approval_routing_rules(id, org_id) values ('route-1', 'org-1'); insert into approval_assignments(id, org_id, approval_id, routing_rule_id) values ('assign-1', 'org-1', 'approval-1', 'route-1'); diff --git a/AgentCompany-New/tests/unit/task-execution-service.test.ts b/AgentCompany-New/tests/unit/task-execution-service.test.ts new file mode 100644 index 00000000..eaefb173 --- /dev/null +++ b/AgentCompany-New/tests/unit/task-execution-service.test.ts @@ -0,0 +1,206 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { ProviderService } from "../../src/main/services/provider-service"; +import { providerAdapters, type ProviderAdapterRegistry } from "../../src/main/services/provider-adapters"; +import { SnapshotService } from "../../src/main/services/snapshot-service"; +import { TaskExecutionService } from "../../src/main/services/task-execution-service"; +import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS } from "../../src/shared/domain"; + +function createConnection(): DatabaseConnection { + return new DatabaseConnection(join(tmpdir(), `agentcompany-new-task-execution-${randomUUID()}.db`)); +} + +function openAiProviderId(connection: DatabaseConnection, orgId: string): string { + return (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'") + .get(orgId) as { id: string }).id; +} + +const testVault = { + encryptSecret: (secret: string) => Buffer.from(secret, "utf8"), + decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8"), +}; + +describe("task execution service", () => { + test("runs an assigned task through the provider gateway", async () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const snapshot = new SnapshotService(connection.db); + let capturedPrompt = ""; + const adapters: ProviderAdapterRegistry = { + ...providerAdapters, + openai: { + ...providerAdapters.openai, + createResponse: async ({ prompt, model }) => { + capturedPrompt = prompt; + return { + text: `executed:${model}`, + rawResponseJson: JSON.stringify({ id: "resp-task-1" }), + rateLimitState: null, + }; + }, + }, + }; + const providers = new ProviderService(connection.db, adapters, testVault); + const execution = new TaskExecutionService(connection.db, providers); + let orgId = ""; + let providerId = ""; + let taskId = ""; + + connection.transaction(() => { + orgId = organization.createOrganization({ + organizationName: "Acme Operations", + employeeName: "Dana Lee", + mission: "Coordinate agents with auditable approvals.", + goal: "Ship a verified control plane.", + }); + providerId = openAiProviderId(connection, orgId); + providers.saveCredential({ orgId, providerId, secret: "sk-live-provider", model: "gpt-5.4" }); + const agentId = organization.createAgent({ + orgId, + name: "Ada", + role: "Execution lead", + department: "Engineering", + modelProviderId: providerId, + modelId: "gpt-5.4", + budgetCents: 10000, + managerAgentId: null, + }); + taskId = organization.createTask({ + orgId, + title: "Draft launch checklist", + description: "Prepare an auditable checklist for release readiness.", + status: TASK_STATUS.IN_PROGRESS, + assigneeAgentId: agentId, + risk: RISK_LEVEL.MEDIUM, + projectId: null, + goalId: null, + workspaceId: null, + priority: TASK_PRIORITY.HIGH, + requiresFilesystem: false, + }); + }); + + const runId = await execution.runTask({ orgId, taskId }); + const state = snapshot.load(); + const run = state.taskRuns.find((item) => item.id === runId); + const request = state.providerRequests.find((item) => item.id === run?.providerRequestId); + + expect(run).toMatchObject({ taskId, agentId: expect.any(String), providerId, state: "completed", errorMessage: null }); + expect(request).toMatchObject({ providerId, state: "succeeded", responseText: "executed:gpt-5.4" }); + expect(capturedPrompt).toContain("Task: Draft launch checklist"); + expect(capturedPrompt).toContain("Assignee: Ada (Execution lead)"); + expect(state.auditEvents.some((event) => event.entityType === "task_run" && event.action === "completed")).toBe(true); + connection.close(); + }); + + test("marks task runs failed when provider execution fails", async () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const snapshot = new SnapshotService(connection.db); + const adapters: ProviderAdapterRegistry = { + ...providerAdapters, + openai: { + ...providerAdapters.openai, + createResponse: async () => { + throw new Error("provider rejected task"); + }, + }, + }; + const providers = new ProviderService(connection.db, adapters, testVault); + const execution = new TaskExecutionService(connection.db, providers); + let orgId = ""; + let taskId = ""; + + connection.transaction(() => { + orgId = organization.createOrganization({ + organizationName: "Acme Operations", + employeeName: "Dana Lee", + mission: "Coordinate agents with auditable approvals.", + goal: "Ship a verified control plane.", + }); + const providerId = openAiProviderId(connection, orgId); + providers.saveCredential({ orgId, providerId, secret: "sk-live-provider", model: "gpt-5.4" }); + const agentId = organization.createAgent({ + orgId, + name: "Ada", + role: "Execution lead", + department: "Engineering", + modelProviderId: providerId, + modelId: "gpt-5.4", + budgetCents: 10000, + managerAgentId: null, + }); + taskId = organization.createTask({ + orgId, + title: "Draft launch checklist", + description: "Prepare an auditable checklist for release readiness.", + status: TASK_STATUS.IN_PROGRESS, + assigneeAgentId: agentId, + risk: RISK_LEVEL.MEDIUM, + projectId: null, + goalId: null, + workspaceId: null, + priority: TASK_PRIORITY.HIGH, + requiresFilesystem: false, + }); + }); + + await expect(execution.runTask({ orgId, taskId })).rejects.toThrow("provider rejected task"); + const state = snapshot.load(); + const run = state.taskRuns[0]; + const request = state.providerRequests.find((item) => item.id === run?.providerRequestId); + + expect(run).toMatchObject({ taskId, state: "failed", errorMessage: "provider rejected task" }); + expect(request).toMatchObject({ state: "failed", errorMessage: "provider rejected task" }); + expect(state.auditEvents.some((event) => event.entityType === "task_run" && event.action === "failed")).toBe(true); + connection.close(); + }); + + test("requires task assignees to have a model provider", async () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const execution = new TaskExecutionService(connection.db); + let orgId = ""; + let taskId = ""; + + connection.transaction(() => { + orgId = organization.createOrganization({ + organizationName: "Acme Operations", + employeeName: "Dana Lee", + mission: "Coordinate agents with auditable approvals.", + goal: "Ship a verified control plane.", + }); + const agentId = organization.createAgent({ + orgId, + name: "Ada", + role: "Execution lead", + department: "Engineering", + modelProviderId: null, + modelId: null, + budgetCents: 10000, + managerAgentId: null, + }); + taskId = organization.createTask({ + orgId, + title: "Draft launch checklist", + description: "Prepare an auditable checklist for release readiness.", + status: TASK_STATUS.IN_PROGRESS, + assigneeAgentId: agentId, + risk: RISK_LEVEL.MEDIUM, + projectId: null, + goalId: null, + workspaceId: null, + priority: TASK_PRIORITY.HIGH, + requiresFilesystem: false, + }); + }); + + await expect(execution.runTask({ orgId, taskId })).rejects.toThrow("Task assignee requires a model provider."); + expect(connection.db.prepare("select count(*) as count from task_runs").get()).toEqual({ count: 0 }); + connection.close(); + }); +});