Implement original onboarding bootstrap
This commit is contained in:
1 parent
49c1dd2888
commit
6992b3e70e
16 files changed
+873
-47
No files matched your search
@@ -12,6 +12,7 @@ import { SkillService } from "../services/skill-service";
|
||||
import { SocialService } from "../services/social-service";
|
||||
import { FileService } from "../services/file-service";
|
||||
import { MigrationService } from "../services/migration-service";
|
||||
import { OnboardingService } from "../services/onboarding-service";
|
||||
|
||||
export interface IpcDeps {
|
||||
connection: DatabaseConnection;
|
||||
@@ -29,6 +30,10 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
const social = new SocialService(deps.connection.db);
|
||||
const files = new FileService(deps.connection.db, deps.artifactRoot);
|
||||
const migrations = new MigrationService(deps.connection.db);
|
||||
const onboarding = new OnboardingService({
|
||||
db: deps.connection.db,
|
||||
saveProviderCredential: (input) => providers.saveCredential(input),
|
||||
});
|
||||
|
||||
const mutate = <T>(operation: () => T): DesktopResult<AppSnapshot> => {
|
||||
try {
|
||||
@@ -48,6 +53,19 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.bootstrapOnboarding, (_event, input) => {
|
||||
try {
|
||||
let summary;
|
||||
deps.connection.transaction(() => {
|
||||
summary = onboarding.bootstrap(input);
|
||||
});
|
||||
deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" });
|
||||
return ok(summary);
|
||||
} catch (error) {
|
||||
return fail("onboarding_failed", error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.createOrganization, (_event, input) => mutate(() => organization.createOrganization(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.createAgent, (_event, input) => mutate(() => organization.createAgent(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.createTask, (_event, input) => mutate(() => organization.createTask(input)));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
const SCHEMA_VERSION = 2;
|
||||
const SCHEMA_VERSION = 3;
|
||||
|
||||
export function installSchema(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
@@ -375,5 +375,85 @@ export function installSchema(db: DatabaseSync): void {
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (2, 'collaboration_provider_policy_schema', datetime('now'));
|
||||
`);
|
||||
currentVersion = 2;
|
||||
}
|
||||
|
||||
if (currentVersion < 3) {
|
||||
db.exec(`
|
||||
alter table organizations add column description text not null default '';
|
||||
alter table organizations add column execution_mode text not null default 'ai_ceo';
|
||||
alter table organizations add column auto_approve_hires integer not null default 1;
|
||||
alter table organizations add column review_deliverables integer not null default 0;
|
||||
|
||||
alter table agents add column title text not null default '';
|
||||
alter table agents add column role_level text not null default 'staff';
|
||||
alter table agents add column model_strategy text not null default 'pooled';
|
||||
alter table agents add column workspace_id text;
|
||||
alter table agents add column heartbeat_enabled integer not null default 0;
|
||||
alter table agents add column heartbeat_interval_sec integer not null default 0;
|
||||
|
||||
alter table tasks add column project_id text;
|
||||
alter table tasks add column goal_id text;
|
||||
alter table tasks add column workspace_id text;
|
||||
alter table tasks add column priority text not null default 'normal';
|
||||
alter table tasks add column requires_filesystem integer not null default 0;
|
||||
|
||||
create table if not exists workspaces (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
name text not null,
|
||||
local_path text not null,
|
||||
repo_url text not null,
|
||||
repo_ref text not null,
|
||||
project_id text,
|
||||
is_primary integer not null,
|
||||
created_at text not null,
|
||||
updated_at text not null
|
||||
);
|
||||
|
||||
create table if not exists goals (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
title text not null,
|
||||
description text not null,
|
||||
status text not null,
|
||||
owner_agent_id text references agents(id) on delete set null,
|
||||
created_at text not null,
|
||||
updated_at text not null
|
||||
);
|
||||
|
||||
create table if not exists projects (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
goal_id text references goals(id) on delete set null,
|
||||
name text not null,
|
||||
description text not null,
|
||||
lead_agent_id text references agents(id) on delete set null,
|
||||
status text not null,
|
||||
created_at text not null,
|
||||
updated_at text not null
|
||||
);
|
||||
|
||||
create table if not exists task_runs (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
task_id text not null references tasks(id) on delete cascade,
|
||||
agent_id text references agents(id) on delete set null,
|
||||
provider_id text references providers(id) on delete set null,
|
||||
trigger text not null,
|
||||
state text not null,
|
||||
created_at text not null,
|
||||
started_at text,
|
||||
completed_at text,
|
||||
error_message text
|
||||
);
|
||||
|
||||
create index if not exists idx_workspaces_org_primary on workspaces(org_id, is_primary);
|
||||
create index if not exists idx_goals_org_status on goals(org_id, status);
|
||||
create index if not exists idx_projects_org_status on projects(org_id, status);
|
||||
create index if not exists idx_task_runs_task_created on task_runs(task_id, created_at);
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (3, 'onboarding_execution_schema', datetime('now'));
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type {
|
||||
BootstrapOnboardingInput,
|
||||
BootstrapOnboardingSummary,
|
||||
SaveProviderCredentialInput,
|
||||
} from "../../shared/commands";
|
||||
import { PROVIDER_AUTH_TYPE, RISK_LEVEL, TASK_STATUS } from "../../shared/domain";
|
||||
import { PROVIDER_CATALOG } from "../../shared/provider-catalog";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { OrganizationService } from "./organization-service";
|
||||
|
||||
interface OnboardingServiceDeps {
|
||||
db: DatabaseSync;
|
||||
saveProviderCredential(input: SaveProviderCredentialInput): void;
|
||||
}
|
||||
|
||||
export class OnboardingService {
|
||||
private readonly audit: AuditService;
|
||||
private readonly organization: OrganizationService;
|
||||
|
||||
constructor(private readonly deps: OnboardingServiceDeps) {
|
||||
this.audit = new AuditService(deps.db);
|
||||
this.organization = new OrganizationService(deps.db);
|
||||
}
|
||||
|
||||
bootstrap(input: BootstrapOnboardingInput): BootstrapOnboardingSummary {
|
||||
const provider = this.resolveProvider(input.providerKey);
|
||||
const orgId = this.organization.createOrganization({
|
||||
organizationName: input.companyName,
|
||||
description: input.companyDescription,
|
||||
mission: input.companyDescription,
|
||||
goal: input.goalTitle,
|
||||
employeeName: input.employeeName,
|
||||
executionMode: input.executionMode,
|
||||
autoApproveHires: input.autoApproveHires,
|
||||
reviewDeliverables: input.reviewDeliverables,
|
||||
});
|
||||
const employeeId = this.requirePrimaryEmployee(orgId);
|
||||
const providerId = this.requireProvider(orgId, provider.key);
|
||||
this.deps.saveProviderCredential({
|
||||
orgId,
|
||||
providerId,
|
||||
secret: input.providerSecret,
|
||||
model: input.providerModel,
|
||||
});
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const workspaceId = this.createWorkspace(orgId, input.workspacePath.trim(), now);
|
||||
const ceoAgentId = this.organization.createAgent({
|
||||
orgId,
|
||||
name: "CEO",
|
||||
role: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
department: "Executive",
|
||||
modelProviderId: providerId,
|
||||
modelId: input.providerModel.trim(),
|
||||
budgetCents: 25000,
|
||||
managerAgentId: null,
|
||||
roleLevel: "c_suite",
|
||||
modelStrategy: "explicit",
|
||||
workspaceId,
|
||||
heartbeatEnabled: true,
|
||||
heartbeatIntervalSec: 120,
|
||||
});
|
||||
const goalId = this.createGoal(orgId, ceoAgentId, input, now);
|
||||
const projectId = this.createProject(orgId, goalId, ceoAgentId, input, now);
|
||||
if (workspaceId) this.bindWorkspaceToProject(workspaceId, projectId, now);
|
||||
const taskId = this.createKickoffTask(orgId, goalId, projectId, workspaceId, ceoAgentId, input, now);
|
||||
const runId = this.createTaskRun(orgId, taskId, ceoAgentId, providerId, now);
|
||||
|
||||
this.audit.record({
|
||||
orgId,
|
||||
entityType: "organization",
|
||||
entityId: orgId,
|
||||
actorType: "employee",
|
||||
actorId: employeeId,
|
||||
action: "bootstrapped",
|
||||
payload: { providerKey: provider.key, goalId, projectId, taskId, runId },
|
||||
});
|
||||
|
||||
return { orgId, employeeId, ceoAgentId, workspaceId, goalId, projectId, taskId, runId, providerId };
|
||||
}
|
||||
|
||||
private resolveProvider(providerKey: string) {
|
||||
const provider = PROVIDER_CATALOG.find((entry) => entry.key === providerKey);
|
||||
if (!provider) throw new Error(`Provider ${providerKey} is unavailable.`);
|
||||
if (provider.authType !== PROVIDER_AUTH_TYPE.API_KEY) {
|
||||
throw new Error("Onboarding requires an API key provider.");
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
private requirePrimaryEmployee(orgId: string): string {
|
||||
const row = this.deps.db.prepare("select id from employees where org_id = ? order by created_at asc limit 1")
|
||||
.get(orgId) as { id: string } | undefined;
|
||||
if (!row) throw new Error("Primary employee was not created.");
|
||||
return row.id;
|
||||
}
|
||||
|
||||
private requireProvider(orgId: string, providerKey: string): string {
|
||||
const row = this.deps.db.prepare("select id from providers where org_id = ? and provider_key = ?")
|
||||
.get(orgId, providerKey) as { id: string } | undefined;
|
||||
if (!row) throw new Error(`Provider ${providerKey} was not created.`);
|
||||
return row.id;
|
||||
}
|
||||
|
||||
private createWorkspace(orgId: string, workspacePath: string, now: string): string | null {
|
||||
if (!workspacePath) return null;
|
||||
const workspaceId = createId("wsp");
|
||||
this.deps.db.prepare(`
|
||||
insert into workspaces(
|
||||
id, org_id, name, local_path, repo_url, repo_ref, project_id, is_primary, created_at, updated_at
|
||||
)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(workspaceId, orgId, "Default Workspace", workspacePath, "", "", null, 1, now, now);
|
||||
return workspaceId;
|
||||
}
|
||||
|
||||
private createGoal(orgId: string, ceoAgentId: string, input: BootstrapOnboardingInput, now: string): string {
|
||||
const goalTitle = input.goalTitle.trim();
|
||||
if (!goalTitle) throw new Error("Goal title is required.");
|
||||
const goalId = createId("gol");
|
||||
this.deps.db.prepare(`
|
||||
insert into goals(id, org_id, title, description, status, owner_agent_id, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(goalId, orgId, goalTitle, input.goalDescription.trim(), "active", ceoAgentId, now, now);
|
||||
return goalId;
|
||||
}
|
||||
|
||||
private createProject(
|
||||
orgId: string,
|
||||
goalId: string,
|
||||
ceoAgentId: string,
|
||||
input: BootstrapOnboardingInput,
|
||||
now: string,
|
||||
): string {
|
||||
const projectId = createId("prj");
|
||||
this.deps.db.prepare(`
|
||||
insert into projects(id, org_id, goal_id, name, description, lead_agent_id, status, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
projectId,
|
||||
orgId,
|
||||
goalId,
|
||||
`Execution plan: ${input.goalTitle.trim()}`,
|
||||
input.companyDescription.trim(),
|
||||
ceoAgentId,
|
||||
"in_progress",
|
||||
now,
|
||||
now,
|
||||
);
|
||||
return projectId;
|
||||
}
|
||||
|
||||
private bindWorkspaceToProject(workspaceId: string, projectId: string, now: string): void {
|
||||
this.deps.db.prepare("update workspaces set project_id = ?, updated_at = ? where id = ?")
|
||||
.run(projectId, now, workspaceId);
|
||||
}
|
||||
|
||||
private createKickoffTask(
|
||||
orgId: string,
|
||||
goalId: string,
|
||||
projectId: string,
|
||||
workspaceId: string | null,
|
||||
ceoAgentId: string,
|
||||
input: BootstrapOnboardingInput,
|
||||
now: string,
|
||||
): string {
|
||||
const taskId = createId("tsk");
|
||||
this.deps.db.prepare(`
|
||||
insert into tasks(
|
||||
id, org_id, title, description, status, assignee_agent_id, risk,
|
||||
project_id, goal_id, workspace_id, priority, requires_filesystem, created_at, updated_at
|
||||
)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
taskId,
|
||||
orgId,
|
||||
`Operationalize goal: ${input.goalTitle.trim()}`,
|
||||
this.createKickoffTaskDescription(input),
|
||||
TASK_STATUS.BACKLOG,
|
||||
ceoAgentId,
|
||||
RISK_LEVEL.MEDIUM,
|
||||
projectId,
|
||||
goalId,
|
||||
workspaceId,
|
||||
"high",
|
||||
workspaceId ? 1 : 0,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
return taskId;
|
||||
}
|
||||
|
||||
private createTaskRun(orgId: string, taskId: string, ceoAgentId: string, providerId: string, now: string): string {
|
||||
const runId = createId("run");
|
||||
this.deps.db.prepare(`
|
||||
insert into task_runs(
|
||||
id, org_id, task_id, agent_id, provider_id, trigger, state, created_at, started_at, completed_at, error_message
|
||||
)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(runId, orgId, taskId, ceoAgentId, providerId, "bootstrap", "queued", now, null, null, null);
|
||||
return runId;
|
||||
}
|
||||
|
||||
private createKickoffTaskDescription(input: BootstrapOnboardingInput): string {
|
||||
return [
|
||||
`Company: ${input.companyName.trim()}`,
|
||||
`Goal: ${input.goalTitle.trim()}`,
|
||||
input.goalDescription.trim(),
|
||||
"Read the bound workspace, create the first operating plan, delegate work, and route decisions through approvals and messages.",
|
||||
].filter((line) => line.length > 0).join("\n\n");
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,22 @@ export class OrganizationService {
|
||||
const employeeId = createId("emp");
|
||||
const now = new Date().toISOString();
|
||||
|
||||
this.db.prepare("insert into organizations(id, name, mission, goal, created_at) values (?, ?, ?, ?, ?)")
|
||||
.run(orgId, input.organizationName.trim(), input.mission.trim(), input.goal.trim(), now);
|
||||
this.db.prepare(`
|
||||
insert into organizations(
|
||||
id, name, mission, goal, description, execution_mode, auto_approve_hires, review_deliverables, created_at
|
||||
)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
orgId,
|
||||
input.organizationName.trim(),
|
||||
input.mission.trim(),
|
||||
input.goal.trim(),
|
||||
input.description?.trim() ?? "",
|
||||
input.executionMode ?? "ai_ceo",
|
||||
input.autoApproveHires === false ? 0 : 1,
|
||||
input.reviewDeliverables === true ? 1 : 0,
|
||||
now,
|
||||
);
|
||||
this.db.prepare("insert into employees(id, org_id, display_name, role, created_at) values (?, ?, ?, ?, ?)")
|
||||
.run(employeeId, orgId, input.employeeName.trim(), "Board Chair", now);
|
||||
|
||||
@@ -78,8 +92,9 @@ export class OrganizationService {
|
||||
this.db.prepare(`
|
||||
insert into agents(
|
||||
id, org_id, name, role, department, model_provider_id, model_id,
|
||||
budget_cents, manager_agent_id, lifecycle, created_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
budget_cents, manager_agent_id, title, role_level, model_strategy,
|
||||
workspace_id, heartbeat_enabled, heartbeat_interval_sec, lifecycle, created_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
input.orgId,
|
||||
@@ -90,6 +105,12 @@ export class OrganizationService {
|
||||
input.modelId,
|
||||
input.budgetCents,
|
||||
input.managerAgentId,
|
||||
input.title?.trim() || input.role.trim(),
|
||||
input.roleLevel ?? "staff",
|
||||
input.modelStrategy ?? "pooled",
|
||||
input.workspaceId ?? null,
|
||||
input.heartbeatEnabled === true ? 1 : 0,
|
||||
input.heartbeatIntervalSec ?? 0,
|
||||
"active",
|
||||
now,
|
||||
);
|
||||
@@ -109,8 +130,11 @@ export class OrganizationService {
|
||||
const id = createId("tsk");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into tasks(id, org_id, title, description, status, assignee_agent_id, risk, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
insert into tasks(
|
||||
id, org_id, title, description, status, assignee_agent_id, risk,
|
||||
project_id, goal_id, workspace_id, priority, requires_filesystem, created_at, updated_at
|
||||
)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
input.orgId,
|
||||
@@ -119,6 +143,11 @@ export class OrganizationService {
|
||||
input.status,
|
||||
input.assigneeAgentId,
|
||||
input.risk,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"normal",
|
||||
0,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
|
||||
@@ -28,8 +28,19 @@ export class SnapshotService {
|
||||
constructor(private readonly db: DatabaseSync) {}
|
||||
|
||||
load(): AppSnapshot {
|
||||
const organizations = list<AppSnapshot["organizations"][number]>(this.db, "organizations", "created_at asc");
|
||||
const organizations = list<AppSnapshot["organizations"][number]>(this.db, "organizations", "created_at asc")
|
||||
.map((org) => ({
|
||||
...org,
|
||||
autoApproveHires: toBool(org.autoApproveHires),
|
||||
reviewDeliverables: toBool(org.reviewDeliverables),
|
||||
}));
|
||||
const activeOrgId = organizations[0]?.id ?? null;
|
||||
const agents = list<AppSnapshot["agents"][number]>(this.db, "agents", "created_at asc")
|
||||
.map((agent) => ({ ...agent, heartbeatEnabled: toBool(agent.heartbeatEnabled) }));
|
||||
const tasks = list<AppSnapshot["tasks"][number]>(this.db, "tasks", "created_at asc")
|
||||
.map((task) => ({ ...task, requiresFilesystem: toBool(task.requiresFilesystem) }));
|
||||
const workspaces = list<AppSnapshot["workspaces"][number]>(this.db, "workspaces", "created_at asc")
|
||||
.map((workspace) => ({ ...workspace, isPrimary: toBool(workspace.isPrimary) }));
|
||||
const providers = list<AppSnapshot["providers"][number]>(this.db, "providers", "label asc")
|
||||
.map((provider) => ({ ...provider, enabled: toBool(provider.enabled) }));
|
||||
const skillBindings = list<AppSnapshot["skillBindings"][number]>(this.db, "skill_bindings", "priority asc")
|
||||
@@ -39,8 +50,12 @@ export class SnapshotService {
|
||||
activeOrgId,
|
||||
organizations,
|
||||
employees: list(this.db, "employees", "created_at asc"),
|
||||
agents: list(this.db, "agents", "created_at asc"),
|
||||
tasks: list(this.db, "tasks", "created_at asc"),
|
||||
agents,
|
||||
tasks,
|
||||
workspaces,
|
||||
goals: list(this.db, "goals", "created_at asc"),
|
||||
projects: list(this.db, "projects", "created_at asc"),
|
||||
taskRuns: list(this.db, "task_runs", "created_at asc"),
|
||||
approvals: list(this.db, "approvals", "created_at desc"),
|
||||
chatSpaces: list(this.db, "chat_spaces", "created_at asc"),
|
||||
chatMessages: list(this.db, "chat_messages", "created_at asc"),
|
||||
|
||||
@@ -8,6 +8,7 @@ function invoke<T>(channel: string, payload?: unknown): Promise<T> {
|
||||
|
||||
const api: DesktopApi = {
|
||||
loadSnapshot: () => invoke(IPC_CHANNELS.loadSnapshot),
|
||||
bootstrapOnboarding: (input) => invoke(IPC_CHANNELS.bootstrapOnboarding, input),
|
||||
createOrganization: (input) => invoke(IPC_CHANNELS.createOrganization, input),
|
||||
createAgent: (input) => invoke(IPC_CHANNELS.createAgent, input),
|
||||
createTask: (input) => invoke(IPC_CHANNELS.createTask, input),
|
||||
|
||||
@@ -19,7 +19,7 @@ export function App() {
|
||||
return <div className="app-shell" style={{ display: "grid", placeItems: "center" }} />;
|
||||
}
|
||||
if (snapshot.organizations.length === 0) {
|
||||
return <Onboarding actions={actions} />;
|
||||
return <Onboarding snapshot={snapshot} actions={actions} />;
|
||||
}
|
||||
|
||||
const pageProps = { snapshot, actions };
|
||||
|
||||
@@ -1,46 +1,307 @@
|
||||
import type { FormEvent } from "react";
|
||||
import { Button, Field } from "../shared/ui";
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import { PROVIDER_AUTH_TYPE } from "../../../shared/domain";
|
||||
import { Button, Field, Icon, Pill } from "../shared/ui";
|
||||
import type { SnapshotActions } from "./useSnapshot";
|
||||
import type { AppSnapshot, ProviderCatalogEntry } from "../../../shared/domain";
|
||||
|
||||
function value(form: FormData, key: string): string {
|
||||
return String(form.get(key) ?? "").trim();
|
||||
type Step = "welcome" | "provider" | "company" | "mode" | "mission" | "launch";
|
||||
|
||||
interface Draft {
|
||||
companyName: string;
|
||||
companyDescription: string;
|
||||
employeeName: string;
|
||||
workspacePath: string;
|
||||
executionMode: "ai_ceo" | "ai_cofounder";
|
||||
providerKey: string;
|
||||
providerSecret: string;
|
||||
providerModel: string;
|
||||
goalTitle: string;
|
||||
goalDescription: string;
|
||||
}
|
||||
|
||||
export function Onboarding({ actions }: { actions: SnapshotActions }) {
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
const steps: Array<{ id: Step; label: string }> = [
|
||||
{ id: "welcome", label: "Start" },
|
||||
{ id: "provider", label: "Provider" },
|
||||
{ id: "company", label: "Company" },
|
||||
{ id: "mode", label: "Mode" },
|
||||
{ id: "mission", label: "Goal" },
|
||||
{ id: "launch", label: "Launch" },
|
||||
];
|
||||
|
||||
const initialDraft: Draft = {
|
||||
companyName: "",
|
||||
companyDescription: "",
|
||||
employeeName: "",
|
||||
workspacePath: "",
|
||||
executionMode: "ai_ceo",
|
||||
providerKey: "",
|
||||
providerSecret: "",
|
||||
providerModel: "",
|
||||
goalTitle: "",
|
||||
goalDescription: "",
|
||||
};
|
||||
|
||||
export function Onboarding({ snapshot, actions }: { snapshot: AppSnapshot; actions: SnapshotActions }) {
|
||||
const apiProviders = useMemo(
|
||||
() => snapshot.providerCatalog.filter((entry) => entry.authType === PROVIDER_AUTH_TYPE.API_KEY),
|
||||
[snapshot.providerCatalog],
|
||||
);
|
||||
const [step, setStep] = useState<Step>("welcome");
|
||||
const [draft, setDraft] = useState<Draft>(() => {
|
||||
const firstProvider = apiProviders[0];
|
||||
if (!firstProvider?.defaultModel) throw new Error("Provider catalog requires a default API model.");
|
||||
return { ...initialDraft, providerKey: firstProvider.key, providerModel: firstProvider.defaultModel };
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const selectedProvider = requireProvider(apiProviders, draft.providerKey);
|
||||
|
||||
const update = (patch: Partial<Draft>) => setDraft((current) => ({ ...current, ...patch }));
|
||||
const go = (next: Step) => {
|
||||
setError(null);
|
||||
setStep(next);
|
||||
};
|
||||
|
||||
const launch = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
void actions.run(() => window.agentCompany.createOrganization({
|
||||
organizationName: value(form, "organizationName"),
|
||||
mission: value(form, "mission"),
|
||||
goal: value(form, "goal"),
|
||||
employeeName: value(form, "employeeName"),
|
||||
}));
|
||||
const validation = validateDraft(draft);
|
||||
if (validation) {
|
||||
setError(validation);
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
const result = await window.agentCompany.bootstrapOnboarding({
|
||||
companyName: draft.companyName.trim(),
|
||||
companyDescription: draft.companyDescription.trim(),
|
||||
employeeName: draft.employeeName.trim(),
|
||||
workspacePath: draft.workspacePath.trim(),
|
||||
executionMode: draft.executionMode,
|
||||
autoApproveHires: draft.executionMode === "ai_ceo",
|
||||
reviewDeliverables: draft.executionMode === "ai_cofounder",
|
||||
goalTitle: draft.goalTitle.trim(),
|
||||
goalDescription: draft.goalDescription.trim(),
|
||||
providerKey: draft.providerKey,
|
||||
providerSecret: draft.providerSecret.trim(),
|
||||
providerModel: draft.providerModel.trim(),
|
||||
});
|
||||
setSubmitting(false);
|
||||
if (!result.ok) {
|
||||
setError(result.error.message);
|
||||
return;
|
||||
}
|
||||
await actions.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="app-shell" style={{ display: "grid", placeItems: "center", padding: 24 }}>
|
||||
<form className="card" style={{ width: "min(560px, 100%)", display: "grid", gap: 14 }} onSubmit={submit}>
|
||||
<main className="onboarding-shell">
|
||||
<section className="onboarding-hero">
|
||||
<div>
|
||||
<h1 className="page-h1">AgentCompany</h1>
|
||||
<p className="page-sub" style={{ marginBottom: 0 }}>Create the company control plane.</p>
|
||||
<div className="brand" style={{ padding: 0 }}>
|
||||
<div className="brand-mark">A</div>
|
||||
<div className="brand-name">AgentCompany</div>
|
||||
</div>
|
||||
<h1 className="page-h1" style={{ fontSize: 34, marginTop: 34 }}>Start your AI company.</h1>
|
||||
<p className="page-sub" style={{ maxWidth: 420 }}>
|
||||
Create the company, bind the CEO runtime, and queue the first operating loop.
|
||||
</p>
|
||||
<div className="onboarding-steps">
|
||||
{steps.map((item) => (
|
||||
<span key={item.id} className={`step-chip ${item.id === step ? "active" : ""}`}>{item.label}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Field label="Company name">
|
||||
<input className="input" name="organizationName" required autoFocus />
|
||||
</Field>
|
||||
<Field label="Your name">
|
||||
<input className="input" name="employeeName" required />
|
||||
</Field>
|
||||
<Field label="Mission">
|
||||
<textarea className="input" name="mission" required />
|
||||
</Field>
|
||||
<Field label="First goal">
|
||||
<textarea className="input" name="goal" required />
|
||||
</Field>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<Button tone="primary" type="submit">Create</Button>
|
||||
<div className="card" style={{ maxWidth: 440 }}>
|
||||
<div className="card-label">Selected runtime</div>
|
||||
<div className="row" style={{ marginTop: 12 }}>
|
||||
<Icon name="bot" />
|
||||
<strong>{selectedProvider.label}</strong>
|
||||
<Pill tone="violet">{draft.providerModel}</Pill>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form className="onboarding-panel" onSubmit={launch}>
|
||||
{step === "welcome" && <WelcomeStep onNext={() => go("provider")} />}
|
||||
{step === "provider" && (
|
||||
<ProviderStep
|
||||
providers={apiProviders}
|
||||
draft={draft}
|
||||
onChange={update}
|
||||
onBack={() => go("welcome")}
|
||||
onNext={() => validateProvider(draft) ? setError(validateProvider(draft)) : go("company")}
|
||||
/>
|
||||
)}
|
||||
{step === "company" && (
|
||||
<CompanyStep
|
||||
draft={draft}
|
||||
onChange={update}
|
||||
onBack={() => go("provider")}
|
||||
onNext={() => validateCompany(draft) ? setError(validateCompany(draft)) : go("mode")}
|
||||
/>
|
||||
)}
|
||||
{step === "mode" && (
|
||||
<ModeStep draft={draft} onChange={update} onBack={() => go("company")} onNext={() => go("mission")} />
|
||||
)}
|
||||
{step === "mission" && (
|
||||
<MissionStep
|
||||
draft={draft}
|
||||
submitting={submitting}
|
||||
onChange={update}
|
||||
onBack={() => go("mode")}
|
||||
/>
|
||||
)}
|
||||
{step === "launch" && null}
|
||||
{error && <div style={{ color: "var(--red)", marginTop: 12 }}>{error}</div>}
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function WelcomeStep({ onNext }: { onNext: () => void }) {
|
||||
return (
|
||||
<div className="form-grid">
|
||||
<h2 className="page-h1">Welcome</h2>
|
||||
<Button tone="primary" onClick={onNext}>Get started</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderStep({
|
||||
providers,
|
||||
draft,
|
||||
onChange,
|
||||
onBack,
|
||||
onNext,
|
||||
}: {
|
||||
providers: ProviderCatalogEntry[];
|
||||
draft: Draft;
|
||||
onChange: (patch: Partial<Draft>) => void;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="form-grid">
|
||||
<h2 className="page-h1">Provider</h2>
|
||||
<div className="option-grid">
|
||||
{providers.map((provider) => (
|
||||
<button
|
||||
key={provider.key}
|
||||
type="button"
|
||||
className={`option-card ${draft.providerKey === provider.key ? "active" : ""}`}
|
||||
onClick={() => onChange({ providerKey: provider.key, providerModel: provider.defaultModel ?? "" })}
|
||||
>
|
||||
<div className="row">
|
||||
<strong>{provider.label}</strong>
|
||||
<Pill>{provider.authType}</Pill>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Field label="API key">
|
||||
<input className="input" type="password" value={draft.providerSecret} onChange={(event) => onChange({ providerSecret: event.target.value })} />
|
||||
</Field>
|
||||
<Field label="Model">
|
||||
<input className="input" value={draft.providerModel} onChange={(event) => onChange({ providerModel: event.target.value })} />
|
||||
</Field>
|
||||
<FormActions onBack={onBack} onNext={onNext} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompanyStep({ draft, onChange, onBack, onNext }: StepProps) {
|
||||
return (
|
||||
<div className="form-grid">
|
||||
<h2 className="page-h1">Company</h2>
|
||||
<Field label="Company name">
|
||||
<input className="input" value={draft.companyName} onChange={(event) => onChange({ companyName: event.target.value })} autoFocus />
|
||||
</Field>
|
||||
<Field label="Your name">
|
||||
<input className="input" value={draft.employeeName} onChange={(event) => onChange({ employeeName: event.target.value })} />
|
||||
</Field>
|
||||
<Field label="Description">
|
||||
<textarea className="input" value={draft.companyDescription} onChange={(event) => onChange({ companyDescription: event.target.value })} />
|
||||
</Field>
|
||||
<Field label="Workspace path">
|
||||
<input className="input" value={draft.workspacePath} onChange={(event) => onChange({ workspacePath: event.target.value })} />
|
||||
</Field>
|
||||
<FormActions onBack={onBack} onNext={onNext} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeStep({ draft, onChange, onBack, onNext }: StepProps) {
|
||||
return (
|
||||
<div className="form-grid">
|
||||
<h2 className="page-h1">Mode</h2>
|
||||
{(["ai_ceo", "ai_cofounder"] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
className={`option-card ${draft.executionMode === mode ? "active" : ""}`}
|
||||
onClick={() => onChange({ executionMode: mode })}
|
||||
>
|
||||
<strong>{mode === "ai_ceo" ? "AI CEO" : "AI Co-Founder"}</strong>
|
||||
</button>
|
||||
))}
|
||||
<FormActions onBack={onBack} onNext={onNext} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MissionStep({ draft, submitting, onChange, onBack }: Omit<StepProps, "onNext"> & { submitting: boolean }) {
|
||||
return (
|
||||
<div className="form-grid">
|
||||
<h2 className="page-h1">Goal</h2>
|
||||
<Field label="Goal">
|
||||
<input className="input" value={draft.goalTitle} onChange={(event) => onChange({ goalTitle: event.target.value })} />
|
||||
</Field>
|
||||
<Field label="Acceptance criteria">
|
||||
<textarea className="input" value={draft.goalDescription} onChange={(event) => onChange({ goalDescription: event.target.value })} />
|
||||
</Field>
|
||||
<div className="modal-actions" style={{ marginTop: 4 }}>
|
||||
<Button onClick={onBack}>Back</Button>
|
||||
<Button tone="primary" type="submit" disabled={submitting}>Launch</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormActions({ onBack, onNext }: { onBack: () => void; onNext: () => void }) {
|
||||
return (
|
||||
<div className="modal-actions" style={{ marginTop: 4 }}>
|
||||
<Button onClick={onBack}>Back</Button>
|
||||
<Button tone="primary" onClick={onNext}>Continue</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StepProps {
|
||||
draft: Draft;
|
||||
onChange: (patch: Partial<Draft>) => void;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
function requireProvider(providers: ProviderCatalogEntry[], key: string): ProviderCatalogEntry {
|
||||
const provider = providers.find((entry) => entry.key === key);
|
||||
if (!provider) throw new Error("Provider selection is invalid.");
|
||||
return provider;
|
||||
}
|
||||
|
||||
function validateProvider(draft: Draft): string | null {
|
||||
if (!draft.providerKey) return "Select a provider.";
|
||||
if (!draft.providerSecret.trim()) return "API key is required.";
|
||||
if (!draft.providerModel.trim()) return "Model is required.";
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateCompany(draft: Draft): string | null {
|
||||
if (!draft.companyName.trim()) return "Company name is required.";
|
||||
if (!draft.employeeName.trim()) return "Your name is required.";
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateDraft(draft: Draft): string | null {
|
||||
return validateProvider(draft) ?? validateCompany(draft) ?? (!draft.goalTitle.trim() ? "Goal is required." : null);
|
||||
}
|
||||
@@ -113,15 +113,17 @@ export function Button({
|
||||
size,
|
||||
onClick,
|
||||
type = "button",
|
||||
disabled = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: "primary" | "ghost";
|
||||
size?: "small";
|
||||
onClick?: () => void;
|
||||
type?: "button" | "submit";
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button type={type} onClick={onClick} className={`button ${tone ?? ""} ${size ?? ""}`}>
|
||||
<button type={type} onClick={onClick} disabled={disabled} className={`button ${tone ?? ""} ${size ?? ""}`}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -149,6 +149,7 @@ button { cursor: pointer; }
|
||||
.button.primary { background: var(--text-1); color: white; border-color: var(--text-1); }
|
||||
.button.ghost { border-color: transparent; background: transparent; color: var(--text-2); }
|
||||
.button.small { height: 24px; padding: 0 8px; font-size: 12px; }
|
||||
.button:disabled { opacity: 0.45; cursor: default; }
|
||||
.icon-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
@@ -219,6 +220,52 @@ button { cursor: pointer; }
|
||||
outline: none;
|
||||
}
|
||||
textarea.input { min-height: 84px; resize: vertical; }
|
||||
.onboarding-shell {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 520px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.onboarding-hero {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 58px;
|
||||
border-right: 1px solid var(--border-soft);
|
||||
}
|
||||
.onboarding-panel {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 42px;
|
||||
background: var(--panel);
|
||||
}
|
||||
.onboarding-steps { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 28px; }
|
||||
.step-chip {
|
||||
height: 24px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 0 9px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--text-3);
|
||||
background: var(--panel);
|
||||
font-size: 11px;
|
||||
}
|
||||
.step-chip.active { color: var(--text-1); border-color: var(--text-1); }
|
||||
.option-grid { display: grid; gap: 8px; }
|
||||
.option-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--panel);
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
.option-card.active { border-color: var(--text-1); box-shadow: inset 0 0 0 1px var(--text-1); }
|
||||
.form-grid { display: grid; gap: 12px; }
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -258,4 +305,7 @@ textarea.input { min-height: 84px; resize: vertical; }
|
||||
.grid-3, .grid-4 { grid-template-columns: 1fr; }
|
||||
.page-wide { min-width: 0; }
|
||||
.secondary-pane { width: 180px; }
|
||||
.onboarding-shell { grid-template-columns: 1fr; }
|
||||
.onboarding-hero { padding: 28px; border-right: 0; border-bottom: 1px solid var(--border-soft); }
|
||||
.onboarding-panel { padding: 24px; justify-content: flex-start; }
|
||||
}
|
||||
@@ -7,9 +7,13 @@ import type {
|
||||
|
||||
export interface CreateOrganizationInput {
|
||||
organizationName: string;
|
||||
description?: string;
|
||||
mission: string;
|
||||
goal: string;
|
||||
employeeName: string;
|
||||
executionMode?: "ai_ceo" | "ai_cofounder";
|
||||
autoApproveHires?: boolean;
|
||||
reviewDeliverables?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateAgentInput {
|
||||
@@ -21,6 +25,12 @@ export interface CreateAgentInput {
|
||||
modelId: string | null;
|
||||
budgetCents: number;
|
||||
managerAgentId: string | null;
|
||||
title?: string;
|
||||
roleLevel?: string;
|
||||
modelStrategy?: string;
|
||||
workspaceId?: string | null;
|
||||
heartbeatEnabled?: boolean;
|
||||
heartbeatIntervalSec?: number;
|
||||
}
|
||||
|
||||
export interface CreateTaskInput {
|
||||
@@ -119,6 +129,33 @@ export interface CreateMigrationReportInput {
|
||||
sourceDatabasePath: string;
|
||||
}
|
||||
|
||||
export interface BootstrapOnboardingInput {
|
||||
companyName: string;
|
||||
companyDescription: string;
|
||||
employeeName: string;
|
||||
workspacePath: string;
|
||||
executionMode: "ai_ceo" | "ai_cofounder";
|
||||
autoApproveHires: boolean;
|
||||
reviewDeliverables: boolean;
|
||||
goalTitle: string;
|
||||
goalDescription: string;
|
||||
providerKey: string;
|
||||
providerSecret: string;
|
||||
providerModel: string;
|
||||
}
|
||||
|
||||
export interface BootstrapOnboardingSummary {
|
||||
orgId: string;
|
||||
employeeId: string;
|
||||
ceoAgentId: string;
|
||||
workspaceId: string | null;
|
||||
goalId: string;
|
||||
projectId: string;
|
||||
taskId: string;
|
||||
runId: string;
|
||||
providerId: string;
|
||||
}
|
||||
|
||||
export interface NativeDialogResult {
|
||||
canceled: boolean;
|
||||
filePaths: string[];
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { DesktopResult } from "./result";
|
||||
import type { AppSnapshot } from "./domain";
|
||||
import type {
|
||||
BindSkillInput,
|
||||
BootstrapOnboardingInput,
|
||||
BootstrapOnboardingSummary,
|
||||
CreateAgentInput,
|
||||
CreateApprovalInput,
|
||||
CreateMigrationReportInput,
|
||||
@@ -25,6 +27,7 @@ export interface RendererEvent {
|
||||
|
||||
export interface DesktopApi {
|
||||
loadSnapshot(): Promise<DesktopResult<AppSnapshot>>;
|
||||
bootstrapOnboarding(input: BootstrapOnboardingInput): Promise<DesktopResult<BootstrapOnboardingSummary>>;
|
||||
createOrganization(input: CreateOrganizationInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createAgent(input: CreateAgentInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createTask(input: CreateTaskInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
|
||||
@@ -50,6 +50,10 @@ export interface OrganizationRecord {
|
||||
name: string;
|
||||
mission: string;
|
||||
goal: string;
|
||||
description: string;
|
||||
executionMode: string;
|
||||
autoApproveHires: boolean;
|
||||
reviewDeliverables: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -71,6 +75,12 @@ export interface AgentRecord {
|
||||
modelId: string | null;
|
||||
budgetCents: number;
|
||||
managerAgentId: string | null;
|
||||
title: string;
|
||||
roleLevel: string;
|
||||
modelStrategy: string;
|
||||
workspaceId: string | null;
|
||||
heartbeatEnabled: boolean;
|
||||
heartbeatIntervalSec: number;
|
||||
lifecycle: string;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -83,10 +93,65 @@ export interface TaskRecord {
|
||||
status: TaskStatus;
|
||||
assigneeAgentId: string | null;
|
||||
risk: RiskLevel;
|
||||
projectId: string | null;
|
||||
goalId: string | null;
|
||||
workspaceId: string | null;
|
||||
priority: string;
|
||||
requiresFilesystem: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceRecord {
|
||||
id: string;
|
||||
orgId: string;
|
||||
name: string;
|
||||
localPath: string;
|
||||
repoUrl: string;
|
||||
repoRef: string;
|
||||
projectId: string | null;
|
||||
isPrimary: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface GoalRecord {
|
||||
id: string;
|
||||
orgId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
ownerAgentId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProjectRecord {
|
||||
id: string;
|
||||
orgId: string;
|
||||
goalId: string | null;
|
||||
name: string;
|
||||
description: string;
|
||||
leadAgentId: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TaskRunRecord {
|
||||
id: string;
|
||||
orgId: string;
|
||||
taskId: string;
|
||||
agentId: string | null;
|
||||
providerId: string | null;
|
||||
trigger: string;
|
||||
state: string;
|
||||
createdAt: string;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
export interface ApprovalRecord {
|
||||
id: string;
|
||||
orgId: string;
|
||||
@@ -289,6 +354,10 @@ export interface AppSnapshot {
|
||||
employees: EmployeeRecord[];
|
||||
agents: AgentRecord[];
|
||||
tasks: TaskRecord[];
|
||||
workspaces: WorkspaceRecord[];
|
||||
goals: GoalRecord[];
|
||||
projects: ProjectRecord[];
|
||||
taskRuns: TaskRunRecord[];
|
||||
approvals: ApprovalRecord[];
|
||||
chatSpaces: ChatSpaceRecord[];
|
||||
chatMessages: ChatMessageRecord[];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const IPC_CHANNELS = {
|
||||
loadSnapshot: "agentcompany:load-snapshot",
|
||||
bootstrapOnboarding: "agentcompany:bootstrap-onboarding",
|
||||
createOrganization: "agentcompany:create-organization",
|
||||
createAgent: "agentcompany:create-agent",
|
||||
createTask: "agentcompany:create-task",
|
||||
|
||||
@@ -17,12 +17,18 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
|
||||
|
||||
try {
|
||||
const page = await app.firstWindow();
|
||||
await expect(page.getByRole("heading", { name: "AgentCompany" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Get started" }).click();
|
||||
await page.getByLabel("API key").fill("e2e-secret");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await page.getByLabel("Company name").fill("Acme Operations");
|
||||
await page.getByLabel("Your name").fill("Dana Lee");
|
||||
await page.getByLabel("Mission").fill("Coordinate agents with auditable approvals.");
|
||||
await page.getByLabel("First goal").fill("Ship a verified control plane.");
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
await page.getByLabel("Description").fill("Coordinate agents with auditable approvals.");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await page.getByLabel("Goal").fill("Ship a verified control plane.");
|
||||
await page.getByLabel("Acceptance criteria").fill("The CEO creates the kickoff operating loop.");
|
||||
await page.getByRole("button", { name: "Launch" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Acme Operations" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Team" }).click();
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SnapshotService } from "../../src/main/services/snapshot-service";
|
||||
import { SkillService } from "../../src/main/services/skill-service";
|
||||
import { ChatService } from "../../src/main/services/chat-service";
|
||||
import { MigrationService } from "../../src/main/services/migration-service";
|
||||
import { OnboardingService } from "../../src/main/services/onboarding-service";
|
||||
import { RISK_LEVEL, TASK_STATUS } from "../../src/shared/domain";
|
||||
import { createId } from "../../src/shared/ids";
|
||||
|
||||
@@ -188,4 +189,42 @@ describe("domain services", () => {
|
||||
expect(state.migrationReports[0]?.reportJson).toContain("\"agents\":1");
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("bootstraps the original onboarding chain with company, CEO, goal, project, task, and run", () => {
|
||||
const connection = createConnection();
|
||||
const onboarding = new OnboardingService({
|
||||
db: connection.db,
|
||||
saveProviderCredential: ({ orgId, providerId, model }) => {
|
||||
connection.db.prepare("update providers set credential_id = ?, model = ?, enabled = 1 where id = ? and org_id = ?")
|
||||
.run("cred_test", model, providerId, orgId);
|
||||
},
|
||||
});
|
||||
const snapshot = new SnapshotService(connection.db);
|
||||
|
||||
connection.transaction(() => onboarding.bootstrap({
|
||||
companyName: "Acme Operations",
|
||||
companyDescription: "Coordinate agents with auditable approvals.",
|
||||
employeeName: "Dana Lee",
|
||||
workspacePath: join(tmpdir(), `agentcompany-workspace-${randomUUID()}`),
|
||||
executionMode: "ai_ceo",
|
||||
autoApproveHires: true,
|
||||
reviewDeliverables: false,
|
||||
goalTitle: "Ship a verified control plane",
|
||||
goalDescription: "Bring the desktop operating loop online.",
|
||||
providerKey: "openai",
|
||||
providerSecret: "test-secret",
|
||||
providerModel: "gpt-5.1",
|
||||
}));
|
||||
|
||||
const state = snapshot.load();
|
||||
expect(state.organizations[0]?.executionMode).toBe("ai_ceo");
|
||||
expect(state.agents[0]?.roleLevel).toBe("c_suite");
|
||||
expect(state.agents[0]?.modelStrategy).toBe("explicit");
|
||||
expect(state.workspaces).toHaveLength(1);
|
||||
expect(state.goals[0]?.status).toBe("active");
|
||||
expect(state.projects[0]?.leadAgentId).toBe(state.agents[0]?.id);
|
||||
expect(state.tasks[0]?.assigneeAgentId).toBe(state.agents[0]?.id);
|
||||
expect(state.taskRuns[0]?.trigger).toBe("bootstrap");
|
||||
connection.close();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user