Files
AgentCompany/src/main/onboarding-service.ts
T
ZacharyZhang-NY 15e3863cba saveTask: accept requires_filesystem; opt in for the onboarding kickoff task
- saveTask now takes an optional requiresFilesystem boolean. Omitted
  → column keeps its current value on updates, defaults to 0 on
  inserts. Explicit true/false → written through.
- Onboarding kickoff (the CEO's very first task) sets
  requiresFilesystem=true. The task description already tells the
  agent to list directories and read README/package.json — that is
  filesystem work, so the task must hold the workspace exclusive.
- Other saveTask call sites (automation reassign, routes-tasks patch,
  create_task, meetings) stay implicit — update paths preserve the
  existing column value, new user-created tasks default to 0 (safe
  cognitive default) and can be opted in later by the creator.
2026-04-20 15:36:08 -04:00

316 lines
13 KiB
TypeScript

import type { AppDatabase } from "./database";
import { getConnectorExecutionReadinessIssue, isConnectorExecutionReady } from "@shared/connector-policy";
import type { ConnectorId, ConnectorRecord } from "@shared/types";
import { validateConnectorModelSelection, connectorPolicyDepsFromDb } from "./connector-model-policy";
interface OnboardingBootstrapSummary {
companyId: string;
companyName: string;
workspaceId: string | null;
leadAgentId: string | null;
projectId: string | null;
taskId: string | null;
runId: string | null;
}
interface BootstrapInput {
companyName: string;
companyDescription: string;
workspacePath: string;
autoApproveHires: boolean;
reviewDeliverables?: boolean;
executionMode?: string;
goalTitle: string;
goalDescription: string;
connectorId?: ConnectorId;
connectorModel?: string;
connectorPoolSelections?: Array<{
connectorId: ConnectorId;
disabledModels: Array<{ id: string; label: string }>;
}>;
}
interface OnboardingDependencies {
db: AppDatabase;
seedDefaultAutomationRules: (companyId: string) => void;
queueTaskRun: (taskId: string, trigger: string, actor: string) => {
ok: boolean;
data?: string;
error?: { code: string; message: string };
};
publishDomainChanged: () => void;
/** Scan workspace directories for skills and import them. Best-effort — may be undefined during early init. */
scanAndAssignSkills?: (companyId: string, agentId: string) => Promise<void>;
/** Optional logger for non-fatal errors from best-effort operations (skill seeding, etc.). */
logger?: { warn: (...args: unknown[]) => void };
}
const DEFERRABLE_RUN_ERRORS = new Set([
"WORKER_UNAVAILABLE",
"CONNECTOR_NOT_READY",
"TASK_ALREADY_RUNNING",
"AGENT_ALREADY_RUNNING",
]);
function resolveOnboardingConnector(
connectors: Array<Pick<ConnectorRecord, "id" | "label" | "status" | "capabilityMatrix">>,
preferredConnectorId?: ConnectorId,
) {
if (preferredConnectorId) {
const preferredConnector = connectors.find((connector) => connector.id === preferredConnectorId);
if (!preferredConnector) {
throw new Error(`Selected connector ${preferredConnectorId} is not available.`);
}
const issue = getConnectorExecutionReadinessIssue(preferredConnector);
if (issue) {
throw new Error(issue);
}
return preferredConnector.id;
}
const defaultConnector = connectors.find((connector) => isConnectorExecutionReady(connector));
if (!defaultConnector) {
throw new Error("At least one execution-ready connector is required before onboarding can continue.");
}
return defaultConnector.id;
}
export function createOnboardingService({
db,
seedDefaultAutomationRules,
queueTaskRun,
publishDomainChanged,
scanAndAssignSkills,
logger,
}: OnboardingDependencies) {
function updateAgentWorkspaceBinding(agentId: string, workspaceId: string | null) {
if (!workspaceId) return;
const agent = db.entityCrud.getAgent(agentId);
if (agent.workspaceId === workspaceId) return;
db.agents.saveAgent({
id: agent.id,
companyId: agent.companyId,
name: agent.name,
role: agent.role,
title: agent.title,
department: agent.department,
status: agent.status,
reportsTo: agent.reportsTo,
connectorId: agent.connectorId,
workspaceId,
model: agent.model,
metadataJson: agent.metadataJson,
capabilities: agent.capabilities,
budgetMonthlyUsd: agent.budgetMonthlyUsd,
});
}
function getLocale(): string {
return db.queryAll<{ value: string }>("select value from settings where key = 'locale' limit 1")[0]?.value ?? "en";
}
function createDefaultWorkspace(companyId: string, workspacePath: string) {
const trimmedPath = workspacePath.trim();
if (!trimmedPath) return null;
const wsLocale = getLocale();
return db.workspacesMod.saveWorkspace({
companyId,
name: wsLocale === "zh" ? "默认工作区" : "Default Workspace",
localPath: trimmedPath,
repoUrl: "",
repoRef: "",
isPrimary: true,
});
}
function createInitialExecutionLoop(companyId: string, workspaceId: string | null): OnboardingBootstrapSummary {
const companyRows = db.queryAll<{ id: string; name: string; description: string }>(
"select id, name, description from companies where id = ? limit 1",
companyId,
);
const company = companyRows[0] ?? null;
if (!company) {
throw new Error("Company no longer exists during onboarding bootstrap.");
}
const leadAgentRows = db.queryAll<{ id: string; companyId: string; name: string; role: string; title: string; department: string | null; status: string; reportsTo: string | null; connectorId: string; workspaceId: string | null; model: string | null; metadataJson: string; capabilities: string; budgetMonthlyUsd: number; heartbeatEnabled: number; heartbeatIntervalSec: number }>(
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, metadata_json as metadataJson, capabilities, budget_monthly_usd as budgetMonthlyUsd, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec from agents where company_id = ? and reports_to is null limit 1",
companyId,
);
const leadAgent = leadAgentRows[0] ?? null;
if (!leadAgent) {
throw new Error("Unable to find the lead agent for the company bootstrap.");
}
updateAgentWorkspaceBinding(leadAgent.id, workspaceId);
const goalRows = db.queryAll<{ id: string; title: string; status: string }>(
"select id, title, status from goals where company_id = ? order by created_at asc",
companyId,
);
const activeGoal =
goalRows.find((goal) => goal.status === "active")
?? goalRows[0]
?? null;
const kickoffProjectName = activeGoal ? `Execution plan: ${activeGoal.title}` : "Initial operating plan";
const kickoffProjectDescription = activeGoal
? `Turn the founding goal "${activeGoal.title}" into an execution plan, delegation map, and the first wave of real work.`
: "Establish the first operating plan, assign work, and start the company execution loop.";
const projectId = db.entityCrud.saveProject({
companyId,
goalId: activeGoal?.id ?? null,
name: kickoffProjectName,
description: kickoffProjectDescription,
leadAgentId: leadAgent.id,
targetDate: null,
status: "in_progress",
});
const kickoffLocale = getLocale();
const kickoffTaskTitle = activeGoal
? (kickoffLocale === "zh" ? `落地目标:${activeGoal.title}` : `Operationalize goal: ${activeGoal.title}`)
: (kickoffLocale === "zh" ? "建立首个运营节奏" : "Establish the first operating cadence");
const kickoffTaskDescription = activeGoal
? [
`Read the founding goal "${activeGoal.title}" and convert it into a real operating plan.`,
`Before planning, explore the workspace codebase: list the top-level directory, read key files (README, package.json, config files), and understand the project structure, languages, and frameworks in use.`,
`Consider the full company context — the company name "${company.name}", its description ("${company.description}"), and the goal — when deciding what departments, roles, and execution strategy are needed.`,
`Create the first execution structure inside AgentCompany: projects, tasks, delegation, approvals, and coordination messages as needed.`,
`Use the bound workspace as the default execution boundary for the first round of work.`,
].join("\n\n")
: [
`Establish the first operating plan for the company.`,
`Before planning, explore the workspace codebase: list the top-level directory, read key files (README, package.json, config files), and understand the project structure, languages, and frameworks in use.`,
`Consider the full company context — the company name "${company.name}" and its description ("${company.description}") — when deciding what departments, roles, and execution strategy are needed.`,
`Review the current company state, create the first real tasks, delegate initial work, and report progress through tasks, approvals, and messages.`,
].join("\n\n");
const taskId = db.tasks.saveTask({
companyId,
projectId,
goalId: activeGoal?.id ?? null,
parentId: null,
title: kickoffTaskTitle,
description: kickoffTaskDescription,
assigneeAgentId: leadAgent.id,
workspaceId: workspaceId ?? leadAgent.workspaceId ?? null,
priority: "high",
status: "todo",
// Kickoff explicitly reads the workspace codebase (see
// description above — "list the top-level directory, read key
// files"), so this is a filesystem-touching task and must take
// an exclusive lock on the bound workspace.
requiresFilesystem: true,
});
const startRunResult = queueTaskRun(taskId, "assignment", "system");
if (!startRunResult.ok && !DEFERRABLE_RUN_ERRORS.has(startRunResult.error?.code ?? "")) {
throw new Error(startRunResult.error?.message ?? "Failed to queue the initial execution loop.");
}
return {
companyId,
companyName: company.name,
workspaceId,
leadAgentId: leadAgent.id,
projectId,
taskId,
runId: startRunResult.ok ? startRunResult.data ?? null : null,
};
}
function bootstrapOnboarding(input: BootstrapInput): OnboardingBootstrapSummary {
const connectors = db.snap.listSnapshot({ companyId: null }).connectors;
const selectedConnectorId = resolveOnboardingConnector(connectors, input.connectorId);
const companyId = db.company.saveCompany({
name: input.companyName.trim(),
description: input.companyDescription.trim(),
status: "active",
autoApproveHires: input.autoApproveHires,
reviewDeliverables: input.reviewDeliverables,
executionMode: input.executionMode ?? "ai_ceo",
});
// Persist the user's per-connector disabled-model selections
// against the freshly-created company id. Onboarding is entirely
// pre-commit; the pool settings arrive in the input payload and
// land in the DB only once a company exists to scope them.
for (const pool of input.connectorPoolSelections ?? []) {
db.settings.setDisabledModels(companyId, pool.connectorId, pool.disabledModels);
}
// Validate the CEO model selection against the now-persisted pool
// scoped to this new company. Must run AFTER the pool is stored,
// otherwise the validator sees an empty pool and accepts anything.
const modelSelection = validateConnectorModelSelection(connectorPolicyDepsFromDb(db), companyId, selectedConnectorId, input.connectorModel ?? null);
if (!modelSelection.ok) {
throw new Error(modelSelection.error);
}
const workspaceId = createDefaultWorkspace(companyId, input.workspacePath);
const goalTitle = input.goalTitle.trim();
if (goalTitle) {
db.entityCrud.saveGoal({
companyId,
title: goalTitle,
description: input.goalDescription.trim() || `Achieve: ${goalTitle}`,
status: "active",
parentId: null,
ownerAgentId: null,
});
}
const ceoLocale = getLocale();
const ceoId = db.agents.saveAgent({
companyId,
name: "CEO",
role: "CEO",
title: ceoLocale === "zh" ? "首席执行官" : "Chief Executive Officer",
reportsTo: null,
connectorId: selectedConnectorId,
workspaceId,
model: input.connectorModel?.trim() || null,
capabilities: ceoLocale === "zh"
? "高管领导力、战略规划、团队建设、目标分解、授权管理和组织运营。负责招募领导团队、制定战略,并协调公司所有运营活动。"
: "Executive leadership, strategic planning, team building, goal decomposition, delegation, and organizational management. Responsible for hiring the leadership team, setting strategy, and coordinating all company operations.",
budgetMonthlyUsd: 250,
status: "idle",
// CEO is pinned to the user's pick at onboarding. Never let the
// dispatcher re-route it.
modelStrategy: "explicit",
roleLevel: "c_suite",
});
db.agents.setHeartbeat(ceoId, true, 120);
db.settings.setCurrentCompany(companyId);
seedDefaultAutomationRules(companyId);
if (scanAndAssignSkills) {
void scanAndAssignSkills(companyId, ceoId).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
logger?.warn(`[onboarding] skill scan failed for agent ${ceoId}: ${message}`);
});
}
const kickoff = createInitialExecutionLoop(companyId, workspaceId);
db.activityMod.addActivity({
companyId,
actor: "board",
action: "company.bootstrapped",
entityType: "company",
entityId: companyId,
detail: `Bootstrapped from onboarding with connector ${selectedConnectorId}.`,
});
publishDomainChanged();
return kickoff;
}
return {
bootstrapOnboarding,
};
}