Wire task runs to provider gateway
This commit is contained in:
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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -139,6 +139,7 @@ export interface TaskRunRecord {
|
||||
taskId: string;
|
||||
agentId: string | null;
|
||||
providerId: string | null;
|
||||
providerRequestId: string | null;
|
||||
trigger: string;
|
||||
state: string;
|
||||
createdAt: string;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user