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 { SdlcService } from "../services/sdlc-service";
|
||||||
import { AuditService } from "../services/audit-service";
|
import { AuditService } from "../services/audit-service";
|
||||||
import { RollbackService } from "../services/rollback-service";
|
import { RollbackService } from "../services/rollback-service";
|
||||||
|
import { TaskExecutionService } from "../services/task-execution-service";
|
||||||
|
|
||||||
export interface IpcDeps {
|
export interface IpcDeps {
|
||||||
connection: DatabaseConnection;
|
connection: DatabaseConnection;
|
||||||
@@ -33,6 +34,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
|||||||
const approvals = new ApprovalService(deps.connection.db);
|
const approvals = new ApprovalService(deps.connection.db);
|
||||||
const chat = new ChatService(deps.connection.db);
|
const chat = new ChatService(deps.connection.db);
|
||||||
const providers = new ProviderService(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 skills = new SkillService(deps.connection.db, deps.artifactRoot);
|
||||||
const social = new SocialService(deps.connection.db);
|
const social = new SocialService(deps.connection.db);
|
||||||
const files = new FileService(deps.connection.db, deps.artifactRoot);
|
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.createAgent, (_event, input) => mutate(() => organization.createAgent(input)));
|
||||||
ipcMain.handle(IPC_CHANNELS.createTask, (_event, input) => mutate(() => organization.createTask(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.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.createApproval, (_event, input) => mutate(() => approvals.create(input)));
|
||||||
ipcMain.handle(IPC_CHANNELS.decideApproval, (_event, input) => mutate(() => approvals.decide(input)));
|
ipcMain.handle(IPC_CHANNELS.decideApproval, (_event, input) => mutate(() => approvals.decide(input)));
|
||||||
ipcMain.handle(IPC_CHANNELS.syncEmployeeIdentity, (_event, input) => mutate(() => identityAccess.syncEmployeeIdentity(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 { providerAuthBrokerSchema } from "./migrations/021-provider-auth-broker";
|
||||||
import { providerRateLimitObservationSchema } from "./migrations/022-provider-rate-limit-observations";
|
import { providerRateLimitObservationSchema } from "./migrations/022-provider-rate-limit-observations";
|
||||||
import { providerRequestRuntimeSchema } from "./migrations/023-provider-request-runtime";
|
import { providerRequestRuntimeSchema } from "./migrations/023-provider-request-runtime";
|
||||||
|
import { taskRunProviderRequestSchema } from "./migrations/024-task-run-provider-request";
|
||||||
|
|
||||||
interface Migration {
|
interface Migration {
|
||||||
version: number;
|
version: number;
|
||||||
@@ -53,6 +54,7 @@ const MIGRATIONS: Migration[] = [
|
|||||||
{ version: 21, name: "provider_auth_broker", sql: providerAuthBrokerSchema },
|
{ version: 21, name: "provider_auth_broker", sql: providerAuthBrokerSchema },
|
||||||
{ version: 22, name: "provider_rate_limit_observations", sql: providerRateLimitObservationSchema },
|
{ version: 22, name: "provider_rate_limit_observations", sql: providerRateLimitObservationSchema },
|
||||||
{ version: 23, name: "provider_request_runtime", sql: providerRequestRuntimeSchema },
|
{ 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;
|
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: "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: "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_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: "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: "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" },
|
{ 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 FAILED_BACKGROUND_STATUSES = new Set(["failed", "incomplete"]);
|
||||||
const CANCELLED_BACKGROUND_STATUSES = new Set(["cancelled"]);
|
const CANCELLED_BACKGROUND_STATUSES = new Set(["cancelled"]);
|
||||||
|
|
||||||
|
type BackgroundCreateInput = CreateProviderResponseInput & {
|
||||||
|
onRequestCreated?: (requestId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export class ProviderBackgroundService {
|
export class ProviderBackgroundService {
|
||||||
private readonly audit: AuditService;
|
private readonly audit: AuditService;
|
||||||
private readonly authBroker: AuthBrokerService;
|
private readonly authBroker: AuthBrokerService;
|
||||||
@@ -47,7 +51,7 @@ export class ProviderBackgroundService {
|
|||||||
this.responseObserver = new ProviderResponseObserver(db, this.audit, this.operations, this.requestStore, this.rateLimitRegistry);
|
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 execution = this.prepareExecution(input);
|
||||||
const requestId = this.requestStore.create({
|
const requestId = this.requestStore.create({
|
||||||
orgId: input.orgId,
|
orgId: input.orgId,
|
||||||
@@ -58,6 +62,7 @@ export class ProviderBackgroundService {
|
|||||||
executionMode: PROVIDER_EXECUTION_MODE.BACKGROUND,
|
executionMode: PROVIDER_EXECUTION_MODE.BACKGROUND,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
input.onRequestCreated?.(requestId);
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
try {
|
try {
|
||||||
const authProfile = execution.adapter.parseAuthProfile(execution.credential.secret);
|
const authProfile = execution.adapter.parseAuthProfile(execution.credential.secret);
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ const HEALTH_STATE_FAILED = "failed";
|
|||||||
const HEALTH_STATE_UNCHECKED = "unchecked";
|
const HEALTH_STATE_UNCHECKED = "unchecked";
|
||||||
const WORKSPACE_SCOPES = new Set(["organization", "workspace"]);
|
const WORKSPACE_SCOPES = new Set(["organization", "workspace"]);
|
||||||
|
|
||||||
|
export type ProviderResponseCreateInput = CreateProviderResponseInput & {
|
||||||
|
onRequestCreated?: (requestId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export class ProviderService {
|
export class ProviderService {
|
||||||
private readonly audit: AuditService;
|
private readonly audit: AuditService;
|
||||||
private readonly authBroker: AuthBrokerService;
|
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);
|
if (input.executionMode === PROVIDER_EXECUTION_MODE.BACKGROUND) return this.background.create(input);
|
||||||
const execution = this.prepareExecution(input);
|
const execution = this.prepareExecution(input);
|
||||||
if (!execution.adapter.createResponse) throw new Error(`Provider response execution is unavailable for ${execution.provider.provider_key}.`);
|
if (!execution.adapter.createResponse) throw new Error(`Provider response execution is unavailable for ${execution.provider.provider_key}.`);
|
||||||
const requestId = this.createRequestRecord(execution);
|
const requestId = this.createRequestRecord(execution);
|
||||||
|
input.onRequestCreated?.(requestId);
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
try {
|
try {
|
||||||
const authProfile = execution.adapter.parseAuthProfile(execution.credential.secret);
|
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_adapter_coverage", passed: missing.length === 0, value: `${adapterKeys.length}/${catalogKeys.length}` },
|
||||||
{ key: "provider_rate_limit_registry", passed: rateLimitRegistry === 1, value: rateLimitRegistry },
|
{ key: "provider_rate_limit_registry", passed: rateLimitRegistry === 1, value: rateLimitRegistry },
|
||||||
{ key: "provider_runtime_contract", passed: runtimeContractReady, value: runtimeContractReady },
|
{ 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),
|
createAgent: (input) => invoke(IPC_CHANNELS.createAgent, input),
|
||||||
createTask: (input) => invoke(IPC_CHANNELS.createTask, input),
|
createTask: (input) => invoke(IPC_CHANNELS.createTask, input),
|
||||||
updateTaskStatus: (input) => invoke(IPC_CHANNELS.updateTaskStatus, input),
|
updateTaskStatus: (input) => invoke(IPC_CHANNELS.updateTaskStatus, input),
|
||||||
|
runTask: (input) => invoke(IPC_CHANNELS.runTask, input),
|
||||||
createApproval: (input) => invoke(IPC_CHANNELS.createApproval, input),
|
createApproval: (input) => invoke(IPC_CHANNELS.createApproval, input),
|
||||||
decideApproval: (input) => invoke(IPC_CHANNELS.decideApproval, input),
|
decideApproval: (input) => invoke(IPC_CHANNELS.decideApproval, input),
|
||||||
syncEmployeeIdentity: (input) => invoke(IPC_CHANNELS.syncEmployeeIdentity, input),
|
syncEmployeeIdentity: (input) => invoke(IPC_CHANNELS.syncEmployeeIdentity, input),
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import {
|
|||||||
TASK_STATUS,
|
TASK_STATUS,
|
||||||
type AgentRecord,
|
type AgentRecord,
|
||||||
type GoalRecord,
|
type GoalRecord,
|
||||||
|
type ProviderRecord,
|
||||||
type ProjectRecord,
|
type ProjectRecord,
|
||||||
type TaskRecord,
|
type TaskRecord,
|
||||||
|
type TaskRunRecord,
|
||||||
type TaskStatus,
|
type TaskStatus,
|
||||||
type WorkspaceRecord,
|
type WorkspaceRecord,
|
||||||
} from "../../../shared/domain";
|
} 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 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 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 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 advance = (task: TaskRecord) => {
|
||||||
const next = nextStatus(task.status);
|
const next = nextStatus(task.status);
|
||||||
if (!next) return;
|
if (!next) return;
|
||||||
void actions.run(() => window.agentCompany.updateTaskStatus({ orgId, taskId: task.id, status: next }));
|
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 (
|
return (
|
||||||
<div className="page-wide">
|
<div className="page-wide">
|
||||||
@@ -68,11 +75,14 @@ export function WorkPage({ snapshot, actions }: PageProps) {
|
|||||||
projects={projects}
|
projects={projects}
|
||||||
goals={goals}
|
goals={goals}
|
||||||
workspaces={workspaces}
|
workspaces={workspaces}
|
||||||
|
providers={providers}
|
||||||
|
taskRuns={taskRuns}
|
||||||
onAdvance={advance}
|
onAdvance={advance}
|
||||||
|
onRun={runTask}
|
||||||
onCreate={() => setNewTaskOpen(true)}
|
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} />}
|
{view === "timeline" && <TimelineView tasks={tasks} goals={goals} />}
|
||||||
|
|
||||||
<NewTaskDialog open={newTaskOpen} orgId={orgId} snapshot={snapshot} actions={actions} onClose={() => setNewTaskOpen(false)} />
|
<NewTaskDialog open={newTaskOpen} orgId={orgId} snapshot={snapshot} actions={actions} onClose={() => setNewTaskOpen(false)} />
|
||||||
@@ -86,7 +96,10 @@ function BoardView({
|
|||||||
projects,
|
projects,
|
||||||
goals,
|
goals,
|
||||||
workspaces,
|
workspaces,
|
||||||
|
providers,
|
||||||
|
taskRuns,
|
||||||
onAdvance,
|
onAdvance,
|
||||||
|
onRun,
|
||||||
onCreate,
|
onCreate,
|
||||||
}: {
|
}: {
|
||||||
tasks: TaskRecord[];
|
tasks: TaskRecord[];
|
||||||
@@ -94,7 +107,10 @@ function BoardView({
|
|||||||
projects: ProjectRecord[];
|
projects: ProjectRecord[];
|
||||||
goals: GoalRecord[];
|
goals: GoalRecord[];
|
||||||
workspaces: WorkspaceRecord[];
|
workspaces: WorkspaceRecord[];
|
||||||
|
providers: ProviderRecord[];
|
||||||
|
taskRuns: TaskRunRecord[];
|
||||||
onAdvance: (task: TaskRecord) => void;
|
onAdvance: (task: TaskRecord) => void;
|
||||||
|
onRun: (task: TaskRecord) => void;
|
||||||
onCreate: () => void;
|
onCreate: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -117,7 +133,10 @@ function BoardView({
|
|||||||
project={projects.find((project) => project.id === task.projectId) ?? null}
|
project={projects.find((project) => project.id === task.projectId) ?? null}
|
||||||
goal={goals.find((goal) => goal.id === task.goalId) ?? null}
|
goal={goals.find((goal) => goal.id === task.goalId) ?? null}
|
||||||
workspace={workspaces.find((workspace) => workspace.id === task.workspaceId) ?? 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)}
|
onAdvance={() => onAdvance(task)}
|
||||||
|
onRun={() => onRun(task)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||||
<div
|
<div
|
||||||
className="table-row head"
|
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>Title</div><div>Status</div><div>Priority</div><div>Assignee</div><div />
|
||||||
</div>
|
</div>
|
||||||
{tasks.map((task, index) => {
|
{tasks.map((task, index) => {
|
||||||
const assignee = agents.find((agent) => agent.id === task.assigneeAgentId) ?? null;
|
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 project = projects.find((p) => p.id === task.projectId) ?? null;
|
||||||
const last = index === tasks.length - 1;
|
const last = index === tasks.length - 1;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={task.id}
|
key={task.id}
|
||||||
className="table-row"
|
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 }}>
|
<div className="row" style={{ gap: 8, minWidth: 0 }}>
|
||||||
{task.risk === "high" && <span style={{ width: 3, height: 12, background: "var(--red)", borderRadius: 2, flexShrink: 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>
|
<span style={{ color: "var(--text-4)" }}>—</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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" />
|
<IconButton icon="more" label="More" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -203,14 +236,20 @@ function TaskCard({
|
|||||||
project,
|
project,
|
||||||
goal,
|
goal,
|
||||||
workspace,
|
workspace,
|
||||||
|
provider,
|
||||||
|
latestRun,
|
||||||
onAdvance,
|
onAdvance,
|
||||||
|
onRun,
|
||||||
}: {
|
}: {
|
||||||
task: TaskRecord;
|
task: TaskRecord;
|
||||||
assignee: AgentRecord | null;
|
assignee: AgentRecord | null;
|
||||||
project: ProjectRecord | null;
|
project: ProjectRecord | null;
|
||||||
goal: GoalRecord | null;
|
goal: GoalRecord | null;
|
||||||
workspace: WorkspaceRecord | null;
|
workspace: WorkspaceRecord | null;
|
||||||
|
provider: ProviderRecord | null;
|
||||||
|
latestRun: TaskRunRecord | null;
|
||||||
onAdvance: () => void;
|
onAdvance: () => void;
|
||||||
|
onRun: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -246,8 +285,10 @@ function TaskCard({
|
|||||||
{goal && <Pill tone="violet">{truncate(goal.title, 20)}</Pill>}
|
{goal && <Pill tone="violet">{truncate(goal.title, 20)}</Pill>}
|
||||||
{workspace && <Pill>{workspace.name}</Pill>}
|
{workspace && <Pill>{workspace.name}</Pill>}
|
||||||
{task.requiresFilesystem && <Pill>workspace</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" }}>
|
<div style={{ marginLeft: "auto", display: "flex", gap: 6, alignItems: "center" }}>
|
||||||
{assignee && <Avatar name={assignee.name} size="sm" bot />}
|
{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} />}
|
{nextStatus(task.status) && <IconButton icon="check" label="Advance" onClick={onAdvance} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -266,6 +307,34 @@ function prettyStatus(status: TaskStatus): string {
|
|||||||
return status.replaceAll("_", " ");
|
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 {
|
function capitalize(value: string): string {
|
||||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,12 +114,14 @@ export function IconButton({
|
|||||||
label,
|
label,
|
||||||
variant = "ghost",
|
variant = "ghost",
|
||||||
size = 14,
|
size = 14,
|
||||||
|
disabled = false,
|
||||||
}: {
|
}: {
|
||||||
icon: IconName;
|
icon: IconName;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
label: string;
|
label: string;
|
||||||
variant?: "ghost" | "solid";
|
variant?: "ghost" | "solid";
|
||||||
size?: number;
|
size?: number;
|
||||||
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -128,6 +130,7 @@ export function IconButton({
|
|||||||
aria-label={label}
|
aria-label={label}
|
||||||
title={label}
|
title={label}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<Icon name={icon} size={size} />
|
<Icon name={icon} size={size} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -370,6 +370,8 @@ a { color: inherit; }
|
|||||||
.icon-button:hover { background: var(--panel-2); color: var(--text-1); }
|
.icon-button:hover { background: var(--panel-2); color: var(--text-1); }
|
||||||
.icon-button.ghost { border-color: transparent; background: transparent; }
|
.icon-button.ghost { border-color: transparent; background: transparent; }
|
||||||
.icon-button.ghost:hover { background: var(--hover); color: var(--text-1); }
|
.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 {
|
.segmented {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ export interface UpdateTaskStatusInput {
|
|||||||
status: TaskStatus;
|
status: TaskStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RunTaskInput {
|
||||||
|
orgId: string;
|
||||||
|
taskId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateApprovalInput {
|
export interface CreateApprovalInput {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import type {
|
|||||||
RunChaosDrillInput,
|
RunChaosDrillInput,
|
||||||
RunEvalSuiteInput,
|
RunEvalSuiteInput,
|
||||||
RunSdlcReviewInput,
|
RunSdlcReviewInput,
|
||||||
|
RunTaskInput,
|
||||||
RevokeProviderCredentialInput,
|
RevokeProviderCredentialInput,
|
||||||
SaveProviderCredentialInput,
|
SaveProviderCredentialInput,
|
||||||
SearchInput,
|
SearchInput,
|
||||||
@@ -64,6 +65,7 @@ export interface DesktopApi {
|
|||||||
createAgent(input: CreateAgentInput): Promise<DesktopResult<AppSnapshot>>;
|
createAgent(input: CreateAgentInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
createTask(input: CreateTaskInput): Promise<DesktopResult<AppSnapshot>>;
|
createTask(input: CreateTaskInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
updateTaskStatus(input: UpdateTaskStatusInput): Promise<DesktopResult<AppSnapshot>>;
|
updateTaskStatus(input: UpdateTaskStatusInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
|
runTask(input: RunTaskInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
createApproval(input: CreateApprovalInput): Promise<DesktopResult<AppSnapshot>>;
|
createApproval(input: CreateApprovalInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
decideApproval(input: DecideApprovalInput): Promise<DesktopResult<AppSnapshot>>;
|
decideApproval(input: DecideApprovalInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
syncEmployeeIdentity(input: SyncEmployeeIdentityInput): Promise<DesktopResult<AppSnapshot>>;
|
syncEmployeeIdentity(input: SyncEmployeeIdentityInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ export interface TaskRunRecord {
|
|||||||
taskId: string;
|
taskId: string;
|
||||||
agentId: string | null;
|
agentId: string | null;
|
||||||
providerId: string | null;
|
providerId: string | null;
|
||||||
|
providerRequestId: string | null;
|
||||||
trigger: string;
|
trigger: string;
|
||||||
state: string;
|
state: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const IPC_CHANNELS = {
|
|||||||
createAgent: "agentcompany:create-agent",
|
createAgent: "agentcompany:create-agent",
|
||||||
createTask: "agentcompany:create-task",
|
createTask: "agentcompany:create-task",
|
||||||
updateTaskStatus: "agentcompany:update-task-status",
|
updateTaskStatus: "agentcompany:update-task-status",
|
||||||
|
runTask: "agentcompany:run-task",
|
||||||
createApproval: "agentcompany:create-approval",
|
createApproval: "agentcompany:create-approval",
|
||||||
decideApproval: "agentcompany:decide-approval",
|
decideApproval: "agentcompany:decide-approval",
|
||||||
syncEmployeeIdentity: "agentcompany:sync-employee-identity",
|
syncEmployeeIdentity: "agentcompany:sync-employee-identity",
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ describe("domain services", () => {
|
|||||||
const connection = createConnection();
|
const connection = createConnection();
|
||||||
|
|
||||||
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
|
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();
|
connection.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ describe("migration service", () => {
|
|||||||
create table workspaces(id text primary key, org_id text, project_id text);
|
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 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 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 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_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);
|
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 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 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 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 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_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');
|
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