From fd79f059ca8593dda639c6c4a160aa7af493bbc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 9 Oct 2026 05:25:01 -0400 Subject: [PATCH] Q176: onboarding progress survives a restart, and an unfinished setup reopens Co-Authored-By: Claude Opus 5.5 --- src/main/app/ipc-contracts/organization.ts | 8 ++ src/main/app/ipc-handlers.ts | 10 ++ .../migrations/105-onboarding-progress.ts | 9 ++ src/main/database/schema.ts | 2 + src/main/services/onboarding-progress.ts | 22 +++++ src/main/services/onboarding-service.ts | 3 + src/main/services/snapshot-service.ts | 2 + src/preload/index.ts | 2 + src/renderer/src/app/App.tsx | 17 ++-- src/renderer/src/app/Onboarding.tsx | 97 ++++++++++++------- src/renderer/src/i18n/zh.ts | 2 + .../src/pages/settings/LanguageField.tsx | 55 ++++++----- src/shared/commands/onboarding.ts | 15 +++ src/shared/desktop-api.ts | 3 + src/shared/domain.ts | 2 + src/shared/ipc.ts | 2 + src/shared/snapshot-domain.ts | 3 + tests/e2e/onboarding-resume.spec.ts | 97 +++++++++++++++++++ tests/unit/domain-services.test.ts | 2 +- tests/unit/onboarding-progress.test.ts | 59 +++++++++++ 20 files changed, 341 insertions(+), 71 deletions(-) create mode 100644 src/main/database/migrations/105-onboarding-progress.ts create mode 100644 src/main/services/onboarding-progress.ts create mode 100644 tests/e2e/onboarding-resume.spec.ts create mode 100644 tests/unit/onboarding-progress.test.ts diff --git a/src/main/app/ipc-contracts/organization.ts b/src/main/app/ipc-contracts/organization.ts index db1515de..8b3ac25f 100644 --- a/src/main/app/ipc-contracts/organization.ts +++ b/src/main/app/ipc-contracts/organization.ts @@ -7,6 +7,14 @@ const id = z.string(); const nullableId = z.string().nullable(); export const organizationContracts = { + saveOnboardingDraft: z.strictObject({ + draft: z.strictObject({ + step: z.enum(["welcome", "provider", "company", "mode", "mission"]), companyName: z.string(), companyDescription: z.string(), employeeName: z.string(), + workspacePath: z.string(), executionMode: z.enum(["ai_ceo", "ai_cofounder"]), providerKey: z.string(), providerBaseUrl: z.string(), providerModel: z.string(), + goalTitle: z.string(), goalDescription: z.string(), + }).nullable(), + }), + completeSetup: z.strictObject({ orgId: id }), bootstrapOnboarding: z.strictObject({ companyName: z.string(), companyDescription: z.string(), employeeName: z.string(), workspacePath: z.string(), executionMode: z.enum(["ai_ceo", "ai_cofounder"]), autoApproveHires: z.boolean(), reviewDeliverables: z.boolean(), diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index 95b70ae3..44241bee 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -41,6 +41,7 @@ import { cancelQueuedRun } from "../services/run-cancel"; import { runHistory } from "../services/run-history"; import { deleteWorkspace, setPrimaryWorkspace, updateWorkspace } from "../services/workspace-admin"; import { setProviderModel } from "../services/provider-default-model"; +import { completeSetup, saveOnboardingDraft } from "../services/onboarding-progress"; import { providerDisconnectImpact } from "../services/provider-disconnect-impact"; export interface IpcDeps { @@ -141,6 +142,15 @@ export function registerIpcHandlers(deps: IpcDeps): void { handle("createOrganization", (input) => mutate(() => organization.createOrganization(input))); handle("setActiveOrganization", (input) => mutate(() => organization.setActiveOrganization(input.orgId))); handle("setUiPreferences", (input) => mutate(() => organization.updateUiPreferences(input))); + handle("saveOnboardingDraft", (input) => { + try { + saveOnboardingDraft(deps.connection.db, input.draft); + return ok(null); + } catch (error) { + return fail("onboarding_draft_failed", error instanceof Error ? error.message : String(error)); + } + }); + handle("completeSetup", (input) => mutate(() => completeSetup(deps.connection.db, input.orgId))); handle("runningSessionCount", () => ok((deps.connection.db.prepare("select count(*) as n from task_runs where state = 'running'").get() as { n: number }).n)); // Chromium's locale is fixed at launch; the reply goes out before the process exits. handle("relaunchApp", () => { diff --git a/src/main/database/migrations/105-onboarding-progress.ts b/src/main/database/migrations/105-onboarding-progress.ts new file mode 100644 index 00000000..9e79dd4b --- /dev/null +++ b/src/main/database/migrations/105-onboarding-progress.ts @@ -0,0 +1,9 @@ +// Onboarding survives a restart: the unfinished wizard (never its key) and whether a created company finished setup; existing companies are done. +export const onboardingProgressSchema = ` + alter table app_state add column onboarding_draft_json text; + alter table organizations add column setup_completed_at text; + update organizations set setup_completed_at = created_at; + + insert into schema_migrations(version, name, applied_at) + values (105, 'onboarding_progress', datetime('now')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 1ca9db47..f714621e 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -103,6 +103,7 @@ import { taskBlockedSchema } from "./migrations/101-task-blocked"; import { goalDueDateSchema } from "./migrations/102-goal-due-date"; import { workVerdictsSchema } from "./migrations/103-work-verdicts"; import { workLanguageSchema } from "./migrations/104-work-language"; +import { onboardingProgressSchema } from "./migrations/105-onboarding-progress"; interface Migration { version: number; @@ -215,6 +216,7 @@ const MIGRATIONS: Migration[] = [ { version: 102, name: "goal_due_date", sql: goalDueDateSchema }, { version: 103, name: "work_verdicts", sql: workVerdictsSchema }, { version: 104, name: "work_language", sql: workLanguageSchema }, + { version: 105, name: "onboarding_progress", sql: onboardingProgressSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/src/main/services/onboarding-progress.ts b/src/main/services/onboarding-progress.ts new file mode 100644 index 00000000..01a7bf39 --- /dev/null +++ b/src/main/services/onboarding-progress.ts @@ -0,0 +1,22 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { OnboardingDraft } from "../../shared/commands"; + +/** Keeps the unfinished wizard so a restart resumes it; null discards it. The draft type has no key field, so no key is stored. */ +export function saveOnboardingDraft(db: DatabaseSync, draft: OnboardingDraft | null): void { + db.prepare(` + insert into app_state(id, onboarding_draft_json, updated_at) values (1, ?, ?) + on conflict(id) do update set onboarding_draft_json = excluded.onboarding_draft_json, updated_at = excluded.updated_at + `).run(draft ? JSON.stringify(draft) : null, new Date().toISOString()); +} + +export function loadOnboardingDraft(db: DatabaseSync): OnboardingDraft | null { + const row = db.prepare("select onboarding_draft_json from app_state where id = 1").get() as { onboarding_draft_json: string | null } | undefined; + return row?.onboarding_draft_json ? JSON.parse(row.onboarding_draft_json) as OnboardingDraft : null; +} + +/** Marks the company's setup (the steps after it was created) finished; until then the wizard reopens on it. */ +export function completeSetup(db: DatabaseSync, orgId: string): void { + const result = db.prepare("update organizations set setup_completed_at = coalesce(setup_completed_at, ?) where id = ?").run(new Date().toISOString(), orgId); + if (result.changes === 0) throw new Error("Organization was not found."); + console.info(`[onboarding] ${orgId} finished setup`); +} diff --git a/src/main/services/onboarding-service.ts b/src/main/services/onboarding-service.ts index 0cb9248c..e86bb986 100644 --- a/src/main/services/onboarding-service.ts +++ b/src/main/services/onboarding-service.ts @@ -1,3 +1,4 @@ +import { saveOnboardingDraft } from "./onboarding-progress"; import { COMPANY_PRESETS } from "../../shared/company-presets"; import type { DatabaseSync } from "node:sqlite"; import type { @@ -32,6 +33,8 @@ export class OnboardingService { bootstrap(input: BootstrapOnboardingInput): BootstrapOnboardingSummary { const provider = this.resolveProvider(input.providerKey); + // The company now exists, so the saved wizard is spent; this commits with it. + saveOnboardingDraft(this.deps.db, null); const orgId = this.organization.createOrganization({ organizationName: input.companyName, description: input.companyDescription, diff --git a/src/main/services/snapshot-service.ts b/src/main/services/snapshot-service.ts index f00ba14d..a322d170 100644 --- a/src/main/services/snapshot-service.ts +++ b/src/main/services/snapshot-service.ts @@ -1,3 +1,4 @@ +import { loadOnboardingDraft } from "./onboarding-progress"; import type { DatabaseSync, StatementSync } from "node:sqlite"; import type { AppSnapshot } from "../../shared/domain"; import { PROVIDER_CATALOG } from "../../shared/provider-catalog"; @@ -86,6 +87,7 @@ export class SnapshotService { return { activeOrgId, uiPreferences: this.resolveUiPreferences(), + onboardingDraft: loadOnboardingDraft(this.db), autonomousPaused: this.resolveAutonomousPaused(), integrationKeys: new IntegrationKeyStore(this.db).statuses(), organizations, diff --git a/src/preload/index.ts b/src/preload/index.ts index c707f8a3..3afa7697 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,6 +9,8 @@ function invoke(channel: string, payload?: unknown): Promise { const api: DesktopApi = { loadSnapshot: () => invoke(IPC_CHANNELS.loadSnapshot), relaunchApp: () => invoke(IPC_CHANNELS.relaunchApp), + saveOnboardingDraft: (input) => invoke(IPC_CHANNELS.saveOnboardingDraft, input), + completeSetup: (input) => invoke(IPC_CHANNELS.completeSetup, input), runningSessionCount: () => invoke(IPC_CHANNELS.runningSessionCount), bootstrapOnboarding: (input) => invoke(IPC_CHANNELS.bootstrapOnboarding, input), createOrganization: (input) => invoke(IPC_CHANNELS.createOrganization, input), diff --git a/src/renderer/src/app/App.tsx b/src/renderer/src/app/App.tsx index c08dff49..a2808267 100644 --- a/src/renderer/src/app/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -46,10 +46,6 @@ export function App() { return directory; }, [snapshot?.agents, snapshot?.employees, snapshot?.activeOrgId]); const [creatingOrg, setCreatingOrg] = useState(false); - // Keeps the wizard mounted through its post-bootstrap connectors step: the - // organization exists from Launch onward, so the org-count check alone - // would flip to the shell mid-onboarding. - const [finishingSetup, setFinishingSetup] = useState(false); const theme = snapshot?.uiPreferences.theme ?? "system"; useEffect(() => { @@ -67,17 +63,16 @@ export function App() { if (!snapshot) { return
; } - if (snapshot.organizations.length === 0 || creatingOrg || finishingSetup) { + // A company exists from Launch on, but its setup is unfinished until the connectors step is done; that is stored, so a restart resumes it. + const unfinished = snapshot.organizations.find((organization) => organization.setupCompletedAt === null) ?? null; + if (snapshot.organizations.length === 0 || creatingOrg || unfinished) { return ( setCreatingOrg(false) : null} - onBootstrapped={() => setFinishingSetup(true)} - onCompleted={() => { - setCreatingOrg(false); - setFinishingSetup(false); - }} + onCancel={creatingOrg && !unfinished ? () => setCreatingOrg(false) : null} + resumeOrgId={unfinished?.id ?? null} + onCompleted={() => setCreatingOrg(false)} /> ); } diff --git a/src/renderer/src/app/Onboarding.tsx b/src/renderer/src/app/Onboarding.tsx index f3cfb173..048fae7a 100644 --- a/src/renderer/src/app/Onboarding.tsx +++ b/src/renderer/src/app/Onboarding.tsx @@ -1,5 +1,7 @@ import { AutonomySummary } from "./AutonomySummary"; -import { useMemo, useState, type FormEvent } from "react"; +import { useEffect, useMemo, useState, type FormEvent } from "react"; +import { RestartForLanguage } from "../pages/settings/LanguageField"; +import type { OnboardingDraft } from "../../../shared/commands"; import { PROVIDER_AUTH_TYPE, type AppSnapshot, @@ -12,21 +14,12 @@ import { ProviderStep } from "./OnboardingProviderStep"; import { ConnectorsStep } from "./OnboardingConnectorsStep"; import { t } from "../i18n/i18n"; -type Step = "welcome" | "provider" | "company" | "mode" | "mission" | "connectors"; +type Step = OnboardingDraft["step"] | "connectors"; -export interface Draft { - companyName: string; - companyDescription: string; - employeeName: string; - workspacePath: string; - executionMode: "ai_ceo" | "ai_cofounder"; - providerKey: string; - providerSecret: string; - providerBaseUrl: string; - providerModel: string; - goalTitle: string; - goalDescription: string; -} +/** The wizard's fields; only the key is never saved between launches. */ +export type Draft = Omit & { providerSecret: string }; + +const SAVE_DELAY_MS = 400; const STEPS: Array<{ id: Step; label: string }> = [ { id: "welcome", label: t("Start") }, @@ -41,13 +34,14 @@ export function Onboarding({ snapshot, actions, onCancel, - onBootstrapped, + resumeOrgId, onCompleted, }: { snapshot: AppSnapshot; actions: SnapshotActions; onCancel: (() => void) | null; - onBootstrapped: () => void; + /** A created company whose setup was not finished: the wizard reopens on its connectors step. */ + resumeOrgId: string | null; onCompleted: () => void; }) { const availableProviders = useMemo( @@ -57,23 +51,51 @@ export function Onboarding({ const firstProvider = availableProviders[0]; if (!firstProvider) throw new Error("Provider catalog must include at least one non-cloud provider."); - const [step, setStep] = useState("welcome"); - const [draft, setDraft] = useState({ - companyName: "", - companyDescription: "", - employeeName: "", - workspacePath: "", - executionMode: "ai_ceo", - providerKey: firstProvider.key, - providerSecret: "", - providerBaseUrl: "", - providerModel: "", - goalTitle: "", - goalDescription: "", + const saved = resumeOrgId ? null : snapshot.onboardingDraft; + const [step, setStep] = useState(resumeOrgId ? "connectors" : saved?.step ?? "welcome"); + const [draft, setDraft] = useState(() => { + if (!saved) { + return { + companyName: "", companyDescription: "", employeeName: "", workspacePath: "", executionMode: "ai_ceo", providerKey: firstProvider.key, + providerSecret: "", providerBaseUrl: "", providerModel: "", goalTitle: "", goalDescription: "", + }; + } + const fields: Partial = { ...saved }; + delete fields.step; + return { ...(fields as Omit), providerSecret: "" }; }); + const [restored] = useState(saved !== null); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); - const [bootstrappedOrgId, setBootstrappedOrgId] = useState(null); + const [bootstrappedOrgId, setBootstrappedOrgId] = useState(resumeOrgId); + + // Saves the wizard (without the key) as it is filled in, so a restart resumes it; stops once the company exists. + useEffect(() => { + if (step === "connectors" || submitting || bootstrappedOrgId) return; + const fields: Partial = { ...draft }; + delete fields.providerSecret; + const timer = setTimeout(() => { + void window.agentCompany.saveOnboardingDraft({ draft: { ...(fields as Omit), step } }).then((result) => { + if (!result.ok) setError(t("Your progress could not be saved: {error}", { error: result.error.message })); + }); + }, SAVE_DELAY_MS); + return () => clearTimeout(timer); + }, [draft, step, submitting, bootstrappedOrgId]); + + // The snapshot is reloaded too, so reopening the wizard does not bring back the cancelled draft. + const cancel = onCancel && (() => { + void window.agentCompany.saveOnboardingDraft({ draft: null }).then(async (result) => { + if (!result.ok) return setError(result.error.message); + await actions.reload(); + onCancel(); + }).catch((reason: unknown) => setError(reason instanceof Error ? reason.message : String(reason))); + }); + + const finishSetup = (orgId: string) => { + void actions.run(() => window.agentCompany.completeSetup({ orgId })) + .then((result) => { if (result) onCompleted(); }) + .catch((reason: unknown) => setError(reason instanceof Error ? reason.message : String(reason))); + }; const selectedProvider = requireProvider(availableProviders, draft.providerKey); const isMac = window.agentCompany.platform === "darwin"; @@ -125,7 +147,6 @@ export function Onboarding({ setError(result.error.message); return; } - onBootstrapped(); setBootstrappedOrgId(result.data.orgId); await actions.reload(); setStep("connectors"); @@ -148,8 +169,8 @@ export function Onboarding({
A
AgentCompany
- {onCancel && step !== "connectors" && ( - )} @@ -179,6 +200,11 @@ export function Onboarding({
+ {restored && step !== "connectors" && ( +

+ {t("Your progress was restored. Keys are never saved before launch, so enter yours again.")} +

+ )} {step === "welcome" && ( finishSetup(bootstrappedOrgId)} /> )}
@@ -256,6 +282,7 @@ function WelcomeStep({ options={[{ id: "en", label: "English" }, { id: "zh", label: "中文" }]} />
+

{t("AgentCompany sets up your operating loop in six steps. Bring an API key, pick a runtime for the founding CEO, and we'll wire up the first goal, project, and task.")}

diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 80c82971..0eb0f03c 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -694,6 +694,8 @@ export const zh: Record = { "Resets {date} (UTC)": "{date} 重置(UTC)", "Unsaved": "未保存", "Working language": "工作语言", + "Your progress could not be saved: {error}": "无法保存你的进度:{error}", + "Your progress was restored. Keys are never saved before launch, so enter yours again.": "已恢复你的进度。启动前不会保存密钥,请重新输入。", "Runs on its own: approves its risky work and large hires, and writes deliverables without review.": "自主运行:自行批准高风险工作和大额招聘,交付物不经审核直接写入。", "Checks with you: risky work and large hires wait for you, and deliverables wait for your review.": "与你确认:高风险工作和大额招聘需要你批准,交付物需经你审核。", "What this mode allows": "此模式允许的范围", diff --git a/src/renderer/src/pages/settings/LanguageField.tsx b/src/renderer/src/pages/settings/LanguageField.tsx index d5eaeebd..de997e33 100644 --- a/src/renderer/src/pages/settings/LanguageField.tsx +++ b/src/renderer/src/pages/settings/LanguageField.tsx @@ -9,23 +9,6 @@ const NAMES: Record = { en: "English", zh: "中文" }; /** The display language is fixed per launch, so a choice waits for a restart the user starts after seeing what it interrupts. */ export function LanguageField({ uiPreferences, actions }: { uiPreferences: UiPreferences; actions: SnapshotActions }) { - const [confirming, setConfirming] = useState(false); - const [running, setRunning] = useState(null); - const [error, setError] = useState(null); - const confirm = () => { - setRunning(null); - setError(null); - setConfirming(true); - void window.agentCompany.runningSessionCount().then((result) => { - if (result.ok) setRunning(result.data); - else setError(result.error.message); - }); - }; - const pending = uiPreferences.language !== window.agentCompany.language; - const restart = () => { - setError(null); - void window.agentCompany.relaunchApp().then((result) => { if (!result.ok) setError(result.error.message); }); - }; return ( {t("Changes only what you see; agents keep the company's working language.")} - {pending && ( - - {t("{language} applies after a restart.", { language: NAMES[uiPreferences.language] })} - - - )} + + + ); +} + +/** Shown while the saved language differs from this launch's: a restart, confirmed after naming what it interrupts. */ +export function RestartForLanguage({ language }: { language: UiLanguage }) { + const [confirming, setConfirming] = useState(false); + const [running, setRunning] = useState(null); + const [error, setError] = useState(null); + if (language === window.agentCompany.language) return null; + const confirm = () => { + setRunning(null); + setError(null); + setConfirming(true); + void window.agentCompany.runningSessionCount().then((result) => { + if (result.ok) setRunning(result.data); + else setError(result.error.message); + }); + }; + const restart = () => { + setError(null); + void window.agentCompany.relaunchApp().then((result) => { if (!result.ok) setError(result.error.message); }); + }; + return ( + <> + + {t("{language} applies after a restart.", { language: NAMES[language] })} + + setConfirming(false)} onSubmit={(event) => { event.preventDefault(); restart(); }}>
  • {t("Text you typed but have not sent or saved is lost.")}
  • @@ -53,6 +60,6 @@ export function LanguageField({ uiPreferences, actions }: { uiPreferences: UiPre
{error &&
{error}
}
- + ); } diff --git a/src/shared/commands/onboarding.ts b/src/shared/commands/onboarding.ts index 31647971..3a98f179 100644 --- a/src/shared/commands/onboarding.ts +++ b/src/shared/commands/onboarding.ts @@ -15,6 +15,21 @@ export interface BootstrapOnboardingInput { providerModel: string; } +/** The unfinished wizard as saved between launches; it has no key field, so a key is never stored before launch. */ +export interface OnboardingDraft { + step: "welcome" | "provider" | "company" | "mode" | "mission"; + companyName: string; + companyDescription: string; + employeeName: string; + workspacePath: string; + executionMode: "ai_ceo" | "ai_cofounder"; + providerKey: string; + providerBaseUrl: string; + providerModel: string; + goalTitle: string; + goalDescription: string; +} + export interface BootstrapOnboardingSummary { orgId: string; employeeId: string; diff --git a/src/shared/desktop-api.ts b/src/shared/desktop-api.ts index 19c4238a..26c0f9ee 100644 --- a/src/shared/desktop-api.ts +++ b/src/shared/desktop-api.ts @@ -74,6 +74,7 @@ import type { SaveProviderCredentialInput, SetProviderModelInput, ProviderEndpoint, + OnboardingDraft, ProviderDisconnectImpact, ConnectComposioToolkitInput, ConnectComposioToolkitResult, @@ -122,6 +123,8 @@ export interface DesktopApi { loadSnapshot(): Promise>; /** Restarts the app, which a display-language change needs; the user asks for it after seeing what it interrupts. */ relaunchApp(): Promise>; + saveOnboardingDraft(input: { draft: OnboardingDraft | null }): Promise>; + completeSetup(input: { orgId: string }): Promise>; /** Every agent session running now, in every company: what a restart interrupts. */ runningSessionCount(): Promise>; bootstrapOnboarding(input: BootstrapOnboardingInput): Promise>; diff --git a/src/shared/domain.ts b/src/shared/domain.ts index 97a38598..c6a87d7c 100644 --- a/src/shared/domain.ts +++ b/src/shared/domain.ts @@ -50,6 +50,8 @@ export interface OrganizationRecord { auditModelId: string | null; /** The language its agents work in unless a task sets its own. */ workLanguage: WorkLanguage; + /** When the steps after creation (connectors) were finished; null reopens the setup wizard. */ + setupCompletedAt: string | null; createdAt: string; } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index e434758b..eb4fbe05 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -1,6 +1,8 @@ export const IPC_CHANNELS = { loadSnapshot: "agentcompany:load-snapshot", relaunchApp: "agentcompany:relaunch-app", + saveOnboardingDraft: "agentcompany:save-onboarding-draft", + completeSetup: "agentcompany:complete-setup", runningSessionCount: "agentcompany:running-session-count", bootstrapOnboarding: "agentcompany:bootstrap-onboarding", setActiveOrganization: "agentcompany:set-active-organization", diff --git a/src/shared/snapshot-domain.ts b/src/shared/snapshot-domain.ts index bc8aecc7..af4eaf8c 100644 --- a/src/shared/snapshot-domain.ts +++ b/src/shared/snapshot-domain.ts @@ -1,3 +1,4 @@ +import type { OnboardingDraft } from "./commands"; import type { UsageStatsRecord } from "./usage-stats"; import type { IntegrationKeyStatus } from "./commands/integration-keys"; import type { BoardInterventionRecord as BoardInterventionRecordModel } from "./board-domain"; @@ -70,6 +71,8 @@ export interface UiPreferences { export interface AppSnapshot { activeOrgId: string | null; uiPreferences: UiPreferences; + /** The setup wizard left unfinished, resumed on the next launch. */ + onboardingDraft: OnboardingDraft | null; /** True while the operator has paused the autonomous run engine. */ autonomousPaused: boolean; integrationKeys: IntegrationKeyStatus[]; diff --git a/tests/e2e/onboarding-resume.spec.ts b/tests/e2e/onboarding-resume.spec.ts new file mode 100644 index 00000000..0b4724d1 --- /dev/null +++ b/tests/e2e/onboarding-resume.spec.ts @@ -0,0 +1,97 @@ +import { _electron as electron, expect, test } from "@playwright/test"; +import { mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { randomUUID } from "node:crypto"; +import { seedBuiltinSkillsFixture } from "./builtin-skills-fixture"; + +test("onboarding resumes where it was left after the app is closed", async () => { + const profileDir = join(tmpdir(), `agentcompany-new-e2e-resume-${randomUUID()}`); + mkdirSync(profileDir, { recursive: true }); + const builtinDir = seedBuiltinSkillsFixture(join(tmpdir(), `agentcompany-new-e2e-resume-builtin-${randomUUID()}`)); + const launch = () => electron.launch({ + args: [resolve("out/main/index.js")], + env: { ...process.env, AGENTCOMPANY_NEW_PROFILE_DIR: profileDir, AGENTCOMPANY_NEW_AUTORUN: "0", AGENTCOMPANY_NEW_BUILTIN_SKILLS_DIR: builtinDir }, + }); + + // Fill the first steps, then close the app mid-wizard. + let app = await launch(); + let page = await app.firstWindow(); + await page.getByRole("button", { name: "Get started" }).click(); + await page.getByLabel("API key").fill("e2e-secret"); + await page.getByLabel("Model").fill("gpt-5.4"); + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByLabel("Company name").fill("Resume Co"); + await page.getByLabel("Your name").fill("Dana Lee"); + await page.getByLabel("Description").fill("Survives a restart."); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.getByRole("heading", { name: "Mode" })).toBeVisible(); + await expect.poll(() => page.evaluate(async () => { + const api = (window as unknown as { agentCompany: { loadSnapshot: () => Promise<{ ok: true; data: { onboardingDraft: { step: string; companyName: string } | null } }> } }).agentCompany; + const draft = (await api.loadSnapshot()).data.onboardingDraft; + return draft && `${draft.step}:${draft.companyName}`; + })).toBe("mode:Resume Co"); + await app.close(); + + // The wizard comes back on the same step with what was typed; the key is not kept. + app = await launch(); + page = await app.firstWindow(); + await expect(page.getByRole("heading", { name: "Mode" })).toBeVisible(); + await expect(page.getByRole("status").filter({ hasText: "Your progress was restored." })).toBeVisible(); + await page.getByRole("button", { name: "Back" }).click(); + await expect(page.getByLabel("Company name")).toHaveValue("Resume Co"); + await page.getByRole("button", { name: "Back" }).click(); + await expect(page.getByLabel("API key")).toHaveValue(""); + await page.getByLabel("API key").fill("e2e-secret"); + await page.getByLabel("Model").fill("gpt-5.4"); + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByLabel("Goal").fill("Ship it."); + await page.getByLabel("Acceptance criteria").fill("It ships."); + await page.getByRole("button", { name: "Launch" }).click(); + await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible(); + await app.close(); + + // A company whose setup was not finished reopens on the connectors step, then enters the app. + app = await launch(); + page = await app.firstWindow(); + await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible(); + await page.getByRole("button", { name: "Finish" }).click(); + await expect(page.getByRole("button", { name: "Settings" })).toBeVisible(); + + // A cancelled second company leaves nothing behind for the next attempt. + const savedModel = () => page.evaluate(async () => { + const api = (window as unknown as { agentCompany: { loadSnapshot: () => Promise<{ ok: true; data: { onboardingDraft: { providerModel: string } | null } }> } }).agentCompany; + return (await api.loadSnapshot()).data.onboardingDraft?.providerModel ?? null; + }); + await page.getByRole("button", { name: "Resume Co — switch organization" }).click(); + await page.getByRole("menuitem", { name: "New organization" }).click(); + await page.getByRole("button", { name: "Get started" }).click(); + await page.getByLabel("API key").fill("e2e-secret"); + await page.getByLabel("Model").fill("cancelled-model"); + await expect.poll(savedModel).toBe("cancelled-model"); + // Saving a preference refreshes the in-memory snapshot, which now holds the draft too. + await page.getByRole("button", { name: "Back", exact: true }).click(); + await page.getByRole("button", { name: "中文" }).click(); + await page.getByRole("button", { name: "English" }).click(); + // Past the 800 ms change-event reload window, so only Cancel itself can refresh the snapshot. + await page.waitForTimeout(1_200); + await page.getByRole("button", { name: "Cancel" }).click(); + await expect(page.getByRole("button", { name: "Settings" })).toBeVisible(); + expect(await savedModel()).toBeNull(); + await page.getByRole("button", { name: "Resume Co — switch organization" }).click(); + await page.getByRole("menuitem", { name: "New organization" }).click(); + await expect(page.getByRole("status").filter({ hasText: "Your progress was restored." })).toHaveCount(0); + await page.getByRole("button", { name: "Get started" }).click(); + await page.getByLabel("API key").fill("e2e-secret"); + await expect(page.getByLabel("Model")).not.toHaveValue("cancelled-model"); + await page.getByRole("button", { name: "Cancel" }).click(); + await app.close(); + + app = await launch(); + page = await app.firstWindow(); + await expect(page.getByRole("button", { name: "Settings" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Connectors" })).toHaveCount(0); + await app.close(); +}); diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index 549352ae..07eaf6e2 100644 --- a/tests/unit/domain-services.test.ts +++ b/tests/unit/domain-services.test.ts @@ -35,7 +35,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104]); + 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, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105]); connection.close(); }); diff --git a/tests/unit/onboarding-progress.test.ts b/tests/unit/onboarding-progress.test.ts new file mode 100644 index 00000000..56887d51 --- /dev/null +++ b/tests/unit/onboarding-progress.test.ts @@ -0,0 +1,59 @@ +import { randomUUID } from "node:crypto"; +import { DatabaseSync } from "node:sqlite"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OnboardingService } from "../../src/main/services/onboarding-service"; +import { ProviderService } from "../../src/main/services/provider-service"; +import { providerAdapters } from "../../src/main/services/provider-adapters"; +import { completeSetup, loadOnboardingDraft, saveOnboardingDraft } from "../../src/main/services/onboarding-progress"; +import { onboardingProgressSchema } from "../../src/main/database/migrations/105-onboarding-progress"; +import type { OnboardingDraft } from "../../src/shared/commands"; + +const vault = { encryptSecret: (secret: string) => Buffer.from(secret, "utf8"), decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8") }; + +const draft: OnboardingDraft = { + step: "mode", companyName: "Resume Co", companyDescription: "d", employeeName: "Dana", workspacePath: "", executionMode: "ai_ceo", + providerKey: "openai", providerBaseUrl: "", providerModel: "gpt-5.4", goalTitle: "", goalDescription: "", +}; + +describe("onboarding progress", () => { + test("the wizard is kept until launch, and a launched company stays unfinished until its setup is done", () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-onboarding-progress-${randomUUID()}.db`)); + const db = connection.db; + expect(loadOnboardingDraft(db)).toBeNull(); + saveOnboardingDraft(db, draft); + expect(loadOnboardingDraft(db)).toEqual(draft); + + const providers = new ProviderService(db, providerAdapters, vault); + const onboarding = new OnboardingService({ db, saveProviderCredential: (input) => providers.saveCredential(input) }); + let orgId = ""; + connection.transaction(() => { + orgId = onboarding.bootstrap({ + companyName: "Resume Co", companyDescription: "d", employeeName: "Dana", workspacePath: "", executionMode: "ai_ceo", autoApproveHires: true, + reviewDeliverables: false, goalTitle: "Ship.", goalDescription: "d", providerKey: "openai", providerSecret: "sk-test", providerModel: "gpt-5.4", + }).orgId; + }); + expect(loadOnboardingDraft(db)).toBeNull(); + expect(db.prepare("select setup_completed_at from organizations where id = ?").get(orgId)).toEqual({ setup_completed_at: null }); + completeSetup(db, orgId); + const done = (db.prepare("select setup_completed_at from organizations where id = ?").get(orgId) as { setup_completed_at: string }).setup_completed_at; + expect(done).not.toBeNull(); + completeSetup(db, orgId); + expect(db.prepare("select setup_completed_at from organizations where id = ?").get(orgId)).toEqual({ setup_completed_at: done }); + expect(() => completeSetup(db, "org_missing")).toThrow("Organization was not found."); + }); + + test("companies that existed before count as set up", () => { + const db = new DatabaseSync(":memory:"); + db.exec(` + create table schema_migrations(version integer, name text, applied_at text); + create table app_state(id integer primary key, updated_at text); + create table organizations(id text primary key, created_at text); + insert into organizations(id, created_at) values ('org_a', '2026-01-01T00:00:00.000Z'); + `); + db.exec(onboardingProgressSchema); + expect(db.prepare("select setup_completed_at from organizations").get()).toEqual({ setup_completed_at: "2026-01-01T00:00:00.000Z" }); + }); +});