fix: make onboarding bootstrap atomic
This commit is contained in:
1 parent
d21edb244f
commit
c779fc2858
6 files changed
+286
-104
No files matched your search
@@ -13,6 +13,10 @@ interface OnboardingBootstrapSummary {
|
|||||||
runId: string | null;
|
runId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface InitialExecutionLoopResult extends OnboardingBootstrapSummary {
|
||||||
|
dispatchAfterCommit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface BootstrapInput {
|
interface BootstrapInput {
|
||||||
companyName: string;
|
companyName: string;
|
||||||
companyDescription: string;
|
companyDescription: string;
|
||||||
@@ -33,9 +37,9 @@ interface BootstrapInput {
|
|||||||
interface OnboardingDependencies {
|
interface OnboardingDependencies {
|
||||||
db: AppDatabase;
|
db: AppDatabase;
|
||||||
seedDefaultAutomationRules: (companyId: string) => void;
|
seedDefaultAutomationRules: (companyId: string) => void;
|
||||||
queueTaskRun: (taskId: string, trigger: string, actor: string) => {
|
createTaskRunForDeferredDispatch: (taskId: string, trigger: string, actor: string) => {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
data?: string;
|
data?: { runId: string; dispatchAfterCommit: () => void };
|
||||||
error?: { code: string; message: string };
|
error?: { code: string; message: string };
|
||||||
};
|
};
|
||||||
publishDomainChanged: () => void;
|
publishDomainChanged: () => void;
|
||||||
@@ -78,7 +82,7 @@ function resolveOnboardingConnector(
|
|||||||
export function createOnboardingService({
|
export function createOnboardingService({
|
||||||
db,
|
db,
|
||||||
seedDefaultAutomationRules,
|
seedDefaultAutomationRules,
|
||||||
queueTaskRun,
|
createTaskRunForDeferredDispatch,
|
||||||
publishDomainChanged,
|
publishDomainChanged,
|
||||||
scanAndAssignSkills,
|
scanAndAssignSkills,
|
||||||
logger,
|
logger,
|
||||||
@@ -123,7 +127,7 @@ export function createOnboardingService({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createInitialExecutionLoop(companyId: string, workspaceId: string | null): OnboardingBootstrapSummary {
|
function createInitialExecutionLoop(companyId: string, workspaceId: string | null): InitialExecutionLoopResult {
|
||||||
const companyRows = db.queryAll<{ id: string; name: string; description: string }>(
|
const companyRows = db.queryAll<{ id: string; name: string; description: string }>(
|
||||||
"select id, name, description from companies where id = ? limit 1",
|
"select id, name, description from companies where id = ? limit 1",
|
||||||
companyId,
|
companyId,
|
||||||
@@ -205,7 +209,7 @@ export function createOnboardingService({
|
|||||||
requiresFilesystem: true,
|
requiresFilesystem: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const startRunResult = queueTaskRun(taskId, "assignment", "system");
|
const startRunResult = createTaskRunForDeferredDispatch(taskId, "assignment", "system");
|
||||||
if (!startRunResult.ok && !DEFERRABLE_RUN_ERRORS.has(startRunResult.error?.code ?? "")) {
|
if (!startRunResult.ok && !DEFERRABLE_RUN_ERRORS.has(startRunResult.error?.code ?? "")) {
|
||||||
throw new Error(startRunResult.error?.message ?? "Failed to queue the initial execution loop.");
|
throw new Error(startRunResult.error?.message ?? "Failed to queue the initial execution loop.");
|
||||||
}
|
}
|
||||||
@@ -217,96 +221,104 @@ export function createOnboardingService({
|
|||||||
leadAgentId: leadAgent.id,
|
leadAgentId: leadAgent.id,
|
||||||
projectId,
|
projectId,
|
||||||
taskId,
|
taskId,
|
||||||
runId: startRunResult.ok ? startRunResult.data ?? null : null,
|
runId: startRunResult.ok ? startRunResult.data?.runId ?? null : null,
|
||||||
|
dispatchAfterCommit: startRunResult.ok ? startRunResult.data?.dispatchAfterCommit ?? (() => {}) : () => {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function bootstrapOnboarding(input: BootstrapInput): OnboardingBootstrapSummary {
|
function bootstrapOnboarding(input: BootstrapInput): OnboardingBootstrapSummary {
|
||||||
const connectors = db.projection.snapshot.listSnapshot({ companyId: null }).connectors;
|
const result = db.withTransaction(() => {
|
||||||
const selectedConnectorId = resolveOnboardingConnector(connectors, input.connectorId);
|
const connectors = db.projection.snapshot.listSnapshot({ companyId: null }).connectors;
|
||||||
|
const selectedConnectorId = resolveOnboardingConnector(connectors, input.connectorId);
|
||||||
|
|
||||||
const companyId = db.core.company.saveCompany({
|
const companyId = db.core.company.saveCompany({
|
||||||
name: input.companyName.trim(),
|
name: input.companyName.trim(),
|
||||||
description: input.companyDescription.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.core.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.core.entity.saveGoal({
|
|
||||||
companyId,
|
|
||||||
title: goalTitle,
|
|
||||||
description: input.goalDescription.trim() || `Achieve: ${goalTitle}`,
|
|
||||||
status: "active",
|
status: "active",
|
||||||
parentId: null,
|
autoApproveHires: input.autoApproveHires,
|
||||||
ownerAgentId: null,
|
reviewDeliverables: input.reviewDeliverables,
|
||||||
|
executionMode: input.executionMode ?? "ai_ceo",
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const ceoLocale = getLocale();
|
// Persist the user's per-connector disabled-model selections
|
||||||
const ceoId = db.core.agents.saveAgent({
|
// against the freshly-created company id. Onboarding is entirely
|
||||||
companyId,
|
// pre-commit; the pool settings arrive in the input payload and
|
||||||
name: "CEO",
|
// land in the DB only once a company exists to scope them.
|
||||||
role: "CEO",
|
for (const pool of input.connectorPoolSelections ?? []) {
|
||||||
title: ceoLocale === "zh" ? "首席执行官" : "Chief Executive Officer",
|
db.core.settings.setDisabledModels(companyId, pool.connectorId, pool.disabledModels);
|
||||||
reportsTo: null,
|
}
|
||||||
connectorId: selectedConnectorId,
|
|
||||||
workspaceId,
|
// Validate the CEO model selection against the now-persisted pool
|
||||||
model: input.connectorModel?.trim() || null,
|
// scoped to this new company. Must run AFTER the pool is stored,
|
||||||
capabilities: ceoLocale === "zh"
|
// otherwise the validator sees an empty pool and accepts anything.
|
||||||
? "高管领导力、战略规划、团队建设、目标分解、授权管理和组织运营。负责招募领导团队、制定战略,并协调公司所有运营活动。"
|
const modelSelection = validateConnectorModelSelection(connectorPolicyDepsFromDb(db), companyId, selectedConnectorId, input.connectorModel ?? null);
|
||||||
: "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.",
|
if (!modelSelection.ok) {
|
||||||
budgetMonthlyUsd: 250,
|
throw new Error(modelSelection.error);
|
||||||
status: "idle",
|
}
|
||||||
// CEO is pinned to the user's pick at onboarding. Never let the
|
|
||||||
// dispatcher re-route it.
|
const workspaceId = createDefaultWorkspace(companyId, input.workspacePath);
|
||||||
modelStrategy: "explicit",
|
|
||||||
roleLevel: "c_suite",
|
const goalTitle = input.goalTitle.trim();
|
||||||
|
if (goalTitle) {
|
||||||
|
db.core.entity.saveGoal({
|
||||||
|
companyId,
|
||||||
|
title: goalTitle,
|
||||||
|
description: input.goalDescription.trim() || `Achieve: ${goalTitle}`,
|
||||||
|
status: "active",
|
||||||
|
parentId: null,
|
||||||
|
ownerAgentId: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ceoLocale = getLocale();
|
||||||
|
const ceoId = db.core.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.core.agents.setHeartbeat(ceoId, true, 120);
|
||||||
|
db.core.settings.setCurrentCompany(companyId);
|
||||||
|
seedDefaultAutomationRules(companyId);
|
||||||
|
|
||||||
|
const kickoff = createInitialExecutionLoop(companyId, workspaceId);
|
||||||
|
|
||||||
|
db.core.activity.addActivity({
|
||||||
|
companyId,
|
||||||
|
actor: "board",
|
||||||
|
action: "company.bootstrapped",
|
||||||
|
entityType: "company",
|
||||||
|
entityId: companyId,
|
||||||
|
detail: `Bootstrapped from onboarding with connector ${selectedConnectorId}.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { kickoff, ceoId };
|
||||||
});
|
});
|
||||||
db.core.agents.setHeartbeat(ceoId, true, 120);
|
|
||||||
db.core.settings.setCurrentCompany(companyId);
|
|
||||||
seedDefaultAutomationRules(companyId);
|
|
||||||
|
|
||||||
|
result.kickoff.dispatchAfterCommit();
|
||||||
if (scanAndAssignSkills) {
|
if (scanAndAssignSkills) {
|
||||||
void scanAndAssignSkills(companyId, ceoId).catch((error) => {
|
void scanAndAssignSkills(result.kickoff.companyId, result.ceoId).catch((error) => {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
logger?.warn(`[onboarding] skill scan failed for agent ${ceoId}: ${message}`);
|
logger?.warn(`[onboarding] skill scan failed for agent ${result.ceoId}: ${message}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const kickoff = createInitialExecutionLoop(companyId, workspaceId);
|
|
||||||
|
|
||||||
db.core.activity.addActivity({
|
|
||||||
companyId,
|
|
||||||
actor: "board",
|
|
||||||
action: "company.bootstrapped",
|
|
||||||
entityType: "company",
|
|
||||||
entityId: companyId,
|
|
||||||
detail: `Bootstrapped from onboarding with connector ${selectedConnectorId}.`,
|
|
||||||
});
|
|
||||||
publishDomainChanged();
|
publishDomainChanged();
|
||||||
return kickoff;
|
const { dispatchAfterCommit: _dispatchAfterCommit, ...summary } = result.kickoff;
|
||||||
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export function createOrchestrationService(deps: OrchestrationDependencies) {
|
|||||||
wakeAgentIfPossible: heartbeat.wakeAgentIfPossible,
|
wakeAgentIfPossible: heartbeat.wakeAgentIfPossible,
|
||||||
dispatchRunToWorker: runDispatch.dispatchRunToWorker,
|
dispatchRunToWorker: runDispatch.dispatchRunToWorker,
|
||||||
queueTaskRun: taskRunner.queueTaskRun,
|
queueTaskRun: taskRunner.queueTaskRun,
|
||||||
|
createTaskRunForDeferredDispatch: taskRunner.createTaskRunForDeferredDispatch,
|
||||||
resumeQueuedRuns: taskRunner.resumeQueuedRuns,
|
resumeQueuedRuns: taskRunner.resumeQueuedRuns,
|
||||||
startAgentHeartbeatRun: heartbeat.startAgentHeartbeatRun,
|
startAgentHeartbeatRun: heartbeat.startAgentHeartbeatRun,
|
||||||
startAllSchedulers: scheduler.startAllSchedulers,
|
startAllSchedulers: scheduler.startAllSchedulers,
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import type { ResolveWorkspaceFn } from "./run-dispatch";
|
|||||||
import { WorkspaceConfigurationError } from "./resolve-workspace";
|
import { WorkspaceConfigurationError } from "./resolve-workspace";
|
||||||
import { pickModelForAgent } from "../model-dispatch";
|
import { pickModelForAgent } from "../model-dispatch";
|
||||||
|
|
||||||
|
interface PreparedTaskRun {
|
||||||
|
runId: string;
|
||||||
|
taskTitle: string;
|
||||||
|
dispatchParams: RunDispatchParams;
|
||||||
|
}
|
||||||
|
|
||||||
export function createTaskRunner(
|
export function createTaskRunner(
|
||||||
deps: OrchestrationDependencies,
|
deps: OrchestrationDependencies,
|
||||||
resolveWorkspace: ResolveWorkspaceFn,
|
resolveWorkspace: ResolveWorkspaceFn,
|
||||||
@@ -29,11 +35,15 @@ export function createTaskRunner(
|
|||||||
return { ok: false, error: { code, message } };
|
return { ok: false, error: { code, message } };
|
||||||
}
|
}
|
||||||
|
|
||||||
function queueTaskRun(taskId: string, trigger: string, actor: string): DesktopResult<string> {
|
function prepareTaskRun(
|
||||||
|
taskId: string,
|
||||||
|
trigger: string,
|
||||||
|
options: { requireWorkerReady: boolean },
|
||||||
|
): DesktopResult<PreparedTaskRun> {
|
||||||
if (isQuitting()) {
|
if (isQuitting()) {
|
||||||
return fail("APP_SHUTTING_DOWN", "Application is shutting down.");
|
return fail("APP_SHUTTING_DOWN", "Application is shutting down.");
|
||||||
}
|
}
|
||||||
if (!hasWorkerReady()) {
|
if (options.requireWorkerReady && !hasWorkerReady()) {
|
||||||
logger.warn(`[run] Worker not ready — cannot queue run for task ${taskId} (trigger: ${trigger})`);
|
logger.warn(`[run] Worker not ready — cannot queue run for task ${taskId} (trigger: ${trigger})`);
|
||||||
return fail("WORKER_UNAVAILABLE", "Orchestration worker is not ready.");
|
return fail("WORKER_UNAVAILABLE", "Orchestration worker is not ready.");
|
||||||
}
|
}
|
||||||
@@ -112,34 +122,69 @@ export function createTaskRunner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const companyBasic = db.core.company.getCompanyBasic(task.companyId);
|
const companyBasic = db.core.company.getCompanyBasic(task.companyId);
|
||||||
dispatchRunToWorker({
|
return ok({
|
||||||
runId: run.id,
|
runId: run.id,
|
||||||
companyId: task.companyId,
|
taskTitle: task.title,
|
||||||
agent,
|
dispatchParams: {
|
||||||
task,
|
runId: run.id,
|
||||||
connector,
|
companyId: task.companyId,
|
||||||
workspace,
|
agent,
|
||||||
workspaceMode: workspaceResolution.lockMode,
|
task,
|
||||||
observationPath: workspaceResolution.observationPath,
|
connector,
|
||||||
companyName: companyBasic?.name ?? "Unknown",
|
workspace,
|
||||||
companyDescription: companyBasic?.description ?? "",
|
workspaceMode: workspaceResolution.lockMode,
|
||||||
snapshot: db.projection.snapshot.listSnapshot(),
|
observationPath: workspaceResolution.observationPath,
|
||||||
trigger,
|
companyName: companyBasic?.name ?? "Unknown",
|
||||||
resolvedModel: assignment.modelId,
|
companyDescription: companyBasic?.description ?? "",
|
||||||
|
snapshot: db.projection.snapshot.listSnapshot(),
|
||||||
|
trigger,
|
||||||
|
resolvedModel: assignment.modelId,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const postDispatchRun = db.core.runs.getRun(run.id);
|
}
|
||||||
|
|
||||||
|
function recordQueuedRun(prepared: PreparedTaskRun, actor: string) {
|
||||||
|
const postDispatchRun = db.core.runs.getRun(prepared.runId);
|
||||||
if (postDispatchRun.status === "queued" || postDispatchRun.status === "running") {
|
if (postDispatchRun.status === "queued" || postDispatchRun.status === "running") {
|
||||||
db.core.activity.addActivity({
|
db.core.activity.addActivity({
|
||||||
companyId: task.companyId,
|
companyId: prepared.dispatchParams.companyId,
|
||||||
actor,
|
actor,
|
||||||
action: "run.queued",
|
action: "run.queued",
|
||||||
entityType: "run",
|
entityType: "run",
|
||||||
entityId: run.id,
|
entityId: prepared.runId,
|
||||||
detail: task.title,
|
detail: prepared.taskTitle,
|
||||||
});
|
});
|
||||||
emitEvent({ type: "run-updated", runId: run.id, action: "created" });
|
emitEvent({ type: "run-updated", runId: prepared.runId, action: "created" });
|
||||||
}
|
}
|
||||||
return ok(run.id);
|
}
|
||||||
|
|
||||||
|
function dispatchPreparedTaskRun(prepared: PreparedTaskRun, actor: string) {
|
||||||
|
dispatchRunToWorker(prepared.dispatchParams);
|
||||||
|
recordQueuedRun(prepared, actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueTaskRun(taskId: string, trigger: string, actor: string): DesktopResult<string> {
|
||||||
|
const prepared = prepareTaskRun(taskId, trigger, { requireWorkerReady: true });
|
||||||
|
if (!prepared.ok) return prepared;
|
||||||
|
dispatchPreparedTaskRun(prepared.data, actor);
|
||||||
|
return ok(prepared.data.runId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTaskRunForDeferredDispatch(taskId: string, trigger: string, actor: string): DesktopResult<{ runId: string; dispatchAfterCommit: () => void }> {
|
||||||
|
const prepared = prepareTaskRun(taskId, trigger, { requireWorkerReady: false });
|
||||||
|
if (!prepared.ok) return prepared;
|
||||||
|
return ok({
|
||||||
|
runId: prepared.data.runId,
|
||||||
|
dispatchAfterCommit: () => {
|
||||||
|
if (isQuitting()) return;
|
||||||
|
if (!hasWorkerReady()) {
|
||||||
|
logger.warn(`[run] Worker not ready — leaving queued run ${prepared.data.runId} for recovery (trigger: ${trigger})`);
|
||||||
|
recordQueuedRun(prepared.data, actor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatchPreparedTaskRun(prepared.data, actor);
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function resumeQueuedRuns() {
|
function resumeQueuedRuns() {
|
||||||
@@ -236,5 +281,5 @@ export function createTaskRunner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { queueTaskRun, resumeQueuedRuns };
|
return { queueTaskRun, createTaskRunForDeferredDispatch, resumeQueuedRuns };
|
||||||
}
|
}
|
||||||
@@ -75,8 +75,8 @@ export function createBackgroundServices({
|
|||||||
const onboardingService = createOnboardingService({
|
const onboardingService = createOnboardingService({
|
||||||
db,
|
db,
|
||||||
seedDefaultAutomationRules,
|
seedDefaultAutomationRules,
|
||||||
queueTaskRun: (taskId, trigger, actor) =>
|
createTaskRunForDeferredDispatch: (taskId, trigger, actor) =>
|
||||||
orchestrationService.queueTaskRun(taskId, trigger, actor),
|
orchestrationService.createTaskRunForDeferredDispatch(taskId, trigger, actor),
|
||||||
publishDomainChanged,
|
publishDomainChanged,
|
||||||
scanAndAssignSkills: async (companyId: string, _agentId: string) => {
|
scanAndAssignSkills: async (companyId: string, _agentId: string) => {
|
||||||
// All company skills are visible to every agent via progressive disclosure.
|
// All company skills are visible to every agent via progressive disclosure.
|
||||||
|
|||||||
@@ -43,7 +43,10 @@ function createBootstrapHarness(db: AppDatabase) {
|
|||||||
db,
|
db,
|
||||||
seedDefaultAutomationRules: noop,
|
seedDefaultAutomationRules: noop,
|
||||||
publishDomainChanged: noop,
|
publishDomainChanged: noop,
|
||||||
queueTaskRun: () => ({ ok: true, data: "run-1" }),
|
createTaskRunForDeferredDispatch: () => ({
|
||||||
|
ok: true,
|
||||||
|
data: { runId: "run-1", dispatchAfterCommit: noop },
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { getModels } from "@mariozechner/pi-ai";
|
||||||
|
import { AppDatabase } from "../../src/main/database";
|
||||||
|
import { getConnectorDefinition } from "../../src/main/connectors";
|
||||||
|
import { createOnboardingService } from "../../src/main/onboarding-service";
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
async function createTempDir(prefix: string) {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), prefix));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
function markConnectorReady(db: AppDatabase, connectorId: "openai-codex") {
|
||||||
|
db.core.connectors.updateConnectorHealth({
|
||||||
|
id: connectorId,
|
||||||
|
status: "ready",
|
||||||
|
authState: "authenticated",
|
||||||
|
version: "test",
|
||||||
|
lastError: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBootstrapInput(workspacePath: string, overrides: Partial<Parameters<ReturnType<typeof createOnboardingService>["bootstrapOnboarding"]>[0]> = {}) {
|
||||||
|
return {
|
||||||
|
companyName: "Atomic Onboarding Co",
|
||||||
|
companyDescription: "Verifies onboarding transaction boundaries",
|
||||||
|
workspacePath,
|
||||||
|
autoApproveHires: true,
|
||||||
|
reviewDeliverables: false,
|
||||||
|
executionMode: "ai_ceo",
|
||||||
|
goalTitle: "Create one atomic company",
|
||||||
|
goalDescription: "Every onboarding write commits together.",
|
||||||
|
connectorId: "openai-codex" as const,
|
||||||
|
connectorModel: "",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectNoOnboardingState(db: AppDatabase) {
|
||||||
|
expect(db.projection.snapshot.listSnapshot().companies).toHaveLength(0);
|
||||||
|
expect(db.core.settings.getCurrentCompanyId()).toBeNull();
|
||||||
|
expect(db.queryAll("select id from agents")).toHaveLength(0);
|
||||||
|
expect(db.queryAll("select id from goals")).toHaveLength(0);
|
||||||
|
expect(db.queryAll("select id from projects")).toHaveLength(0);
|
||||||
|
expect(db.queryAll("select id from tasks")).toHaveLength(0);
|
||||||
|
expect(db.queryAll("select id from runs")).toHaveLength(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("onboarding transaction", () => {
|
||||||
|
it("rolls back every persisted onboarding row when model validation rejects the selected model", async () => {
|
||||||
|
const profileDir = await createTempDir("agentcompany-onboarding-tx-model-");
|
||||||
|
const workspaceDir = await createTempDir("agentcompany-onboarding-tx-workspace-");
|
||||||
|
const db = new AppDatabase(profileDir);
|
||||||
|
db.init();
|
||||||
|
markConnectorReady(db, "openai-codex");
|
||||||
|
|
||||||
|
const definition = getConnectorDefinition("openai-codex");
|
||||||
|
const disabledModel = getModels(definition.provider as never)[0]?.id;
|
||||||
|
if (!disabledModel) {
|
||||||
|
throw new Error("openai-codex requires a model for onboarding transaction coverage.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = createOnboardingService({
|
||||||
|
db,
|
||||||
|
seedDefaultAutomationRules: () => {},
|
||||||
|
publishDomainChanged: () => {},
|
||||||
|
createTaskRunForDeferredDispatch: () => ({
|
||||||
|
ok: true,
|
||||||
|
data: { runId: "run-1", dispatchAfterCommit: () => {} },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => service.bootstrapOnboarding(buildBootstrapInput(workspaceDir, {
|
||||||
|
connectorModel: disabledModel,
|
||||||
|
connectorPoolSelections: [
|
||||||
|
{ connectorId: "openai-codex", disabledModels: [{ id: disabledModel, label: disabledModel }] },
|
||||||
|
],
|
||||||
|
}))).toThrow(`${definition.label} does not allow model "${disabledModel}".`);
|
||||||
|
|
||||||
|
expectNoOnboardingState(db);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back every persisted onboarding row and skips external side effects when initial run creation fails", async () => {
|
||||||
|
const profileDir = await createTempDir("agentcompany-onboarding-tx-run-");
|
||||||
|
const workspaceDir = await createTempDir("agentcompany-onboarding-tx-run-workspace-");
|
||||||
|
const db = new AppDatabase(profileDir);
|
||||||
|
db.init();
|
||||||
|
markConnectorReady(db, "openai-codex");
|
||||||
|
|
||||||
|
let publishCount = 0;
|
||||||
|
let scanCount = 0;
|
||||||
|
const service = createOnboardingService({
|
||||||
|
db,
|
||||||
|
seedDefaultAutomationRules: () => {},
|
||||||
|
publishDomainChanged: () => {
|
||||||
|
publishCount += 1;
|
||||||
|
},
|
||||||
|
scanAndAssignSkills: async () => {
|
||||||
|
scanCount += 1;
|
||||||
|
},
|
||||||
|
createTaskRunForDeferredDispatch: () => ({ ok: false, error: { code: "RUN_CREATE_FAILED", message: "run insert failed" } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => service.bootstrapOnboarding(buildBootstrapInput(workspaceDir))).toThrow("run insert failed");
|
||||||
|
|
||||||
|
expectNoOnboardingState(db);
|
||||||
|
expect(publishCount).toBe(0);
|
||||||
|
expect(scanCount).toBe(0);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user