Wire task runs to provider gateway

This commit is contained in:
ZacharyZhang-NY committed 2026-05-23 20:58:00 -04:00
1 parent 273f69eb05
commit a27658b56f
19 files changed
+591 -11

No files matched your search

@@ -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)));
@@ -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'));
`;
@@ -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;
@@ -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" },
@@ -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<string> {
async create(input: BackgroundCreateInput): Promise<string> {
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);
@@ -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<string> {
async createResponse(input: ProviderResponseCreateInput): Promise<string> {
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);
@@ -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 },
];
}
@@ -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<string> {
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");
}
+1
View File
@@ -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),
@@ -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 (
<div className="page-wide">
@@ -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" && <ListView tasks={tasks} agents={agents} projects={projects} />}
{view === "list" && <ListView tasks={tasks} agents={agents} projects={projects} providers={providers} onRun={runTask} />}
{view === "timeline" && <TimelineView tasks={tasks} goals={goals} />}
<NewTaskDialog open={newTaskOpen} orgId={orgId} snapshot={snapshot} actions={actions} onClose={() => 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)}
/>
))}
</div>
@@ -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 (
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div
className="table-row head"
style={{ gridTemplateColumns: "minmax(0, 3fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.2fr) 60px" }}
style={{ gridTemplateColumns: "minmax(0, 3fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.2fr) 88px" }}
>
<div>Title</div><div>Status</div><div>Priority</div><div>Assignee</div><div />
</div>
{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 (
<div
key={task.id}
className="table-row"
style={{ gridTemplateColumns: "minmax(0, 3fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.2fr) 60px", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}
style={{ gridTemplateColumns: "minmax(0, 3fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.2fr) 88px", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}
>
<div className="row" style={{ gap: 8, minWidth: 0 }}>
{task.risk === "high" && <span style={{ width: 3, height: 12, background: "var(--red)", borderRadius: 2, flexShrink: 0 }} />}
@@ -163,7 +195,8 @@ function ListView({ tasks, agents, projects }: { tasks: TaskRecord[]; agents: Ag
<span style={{ color: "var(--text-4)" }}>—</span>
)}
</div>
<div style={{ textAlign: "right" }}>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 4 }}>
{assignee && <IconButton icon="send" label={`Run ${task.title}`} onClick={() => onRun(task)} disabled={!canRun(provider)} />}
<IconButton icon="more" label="More" />
</div>
</div>
@@ -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 (
<div
@@ -246,8 +285,10 @@ function TaskCard({
{goal && <Pill tone="violet">{truncate(goal.title, 20)}</Pill>}
{workspace && <Pill>{workspace.name}</Pill>}
{task.requiresFilesystem && <Pill>workspace</Pill>}
{latestRun && <Pill tone={runTone(latestRun.state)}>{prettyRunState(latestRun.state)}</Pill>}
<div style={{ marginLeft: "auto", display: "flex", gap: 6, alignItems: "center" }}>
{assignee && <Avatar name={assignee.name} size="sm" bot />}
{assignee && <IconButton icon="send" label={`Run ${task.title}`} onClick={onRun} disabled={!canRun(provider)} />}
{nextStatus(task.status) && <IconButton icon="check" label="Advance" onClick={onAdvance} />}
</div>
</div>
@@ -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);
}
@@ -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 (
<button
@@ -128,6 +130,7 @@ export function IconButton({
aria-label={label}
title={label}
onClick={onClick}
disabled={disabled}
>
<Icon name={icon} size={size} />
</button>
@@ -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;
+5
View File
@@ -73,6 +73,11 @@ export interface UpdateTaskStatusInput {
status: TaskStatus;
}
export interface RunTaskInput {
orgId: string;
taskId: string;
}
export interface CreateApprovalInput {
orgId: string;
kind: string;
@@ -32,6 +32,7 @@ import type {
RunChaosDrillInput,
RunEvalSuiteInput,
RunSdlcReviewInput,
RunTaskInput,
RevokeProviderCredentialInput,
SaveProviderCredentialInput,
SearchInput,
@@ -64,6 +65,7 @@ export interface DesktopApi {
createAgent(input: CreateAgentInput): Promise<DesktopResult<AppSnapshot>>;
createTask(input: CreateTaskInput): Promise<DesktopResult<AppSnapshot>>;
updateTaskStatus(input: UpdateTaskStatusInput): Promise<DesktopResult<AppSnapshot>>;
runTask(input: RunTaskInput): Promise<DesktopResult<AppSnapshot>>;
createApproval(input: CreateApprovalInput): Promise<DesktopResult<AppSnapshot>>;
decideApproval(input: DecideApprovalInput): Promise<DesktopResult<AppSnapshot>>;
syncEmployeeIdentity(input: SyncEmployeeIdentityInput): Promise<DesktopResult<AppSnapshot>>;
+1
View File
@@ -139,6 +139,7 @@ export interface TaskRunRecord {
taskId: string;
agentId: string | null;
providerId: string | null;
providerRequestId: string | null;
trigger: string;
state: string;
createdAt: string;
+1
View File
@@ -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",
@@ -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();
});
@@ -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');
@@ -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();
});
});