diff --git a/src/main/index.ts b/src/main/index.ts index ece4cd9b..ba3e603f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6,6 +6,7 @@ import { DatabaseConnection } from "./database/connection"; import { createMainWindow } from "./app/create-window"; import { registerIpcHandlers } from "./app/ipc-handlers"; import { builtinSkillsSourceOverride, importStagedSkills, stageBuiltinSkillsLibrary } from "./services/builtin-skills"; +import { ensureCeoDirectSpace } from "./services/ceo-direct-space"; import { SkillService } from "./services/skill-service"; import { startProviderBackgroundWorker } from "./app/provider-background-worker"; import { recoverInterruptedWork } from "./app/startup-recovery"; @@ -45,6 +46,7 @@ if (!app.requestSingleInstanceLock()) { builtinSkillsLibrary, }); syncAllOrganizationSkills(connection, join(profileDir, "artifacts"), builtinSkillsSource, builtinSkillsLibrary); + ensureAllCeoConversations(connection); const stopProviderBackgroundWorker = startProviderBackgroundWorker({ db: connection.db, mainWindow: win, @@ -109,3 +111,19 @@ function syncAllOrganizationSkills(connection: DatabaseConnection, artifactRoot: console.error(`[skills] built-in skill sync failed: ${error instanceof Error ? error.message : String(error)}`); } } + +/** + * Repairs the founder⇄CEO conversation invariant for every organization at + * startup — orgs created before the pinned CEO chat existed get theirs here. + * A broken org is loud in the log but must not stop the app. + */ +function ensureAllCeoConversations(connection: DatabaseConnection): void { + const orgs = connection.db.prepare("select id from organizations").all() as Array<{ id: string }>; + for (const org of orgs) { + try { + connection.transaction(() => ensureCeoDirectSpace(connection.db, org.id)); + } catch (error) { + console.error(`[chat] ensuring the CEO conversation for org ${org.id} failed: ${error instanceof Error ? error.message : String(error)}`); + } + } +} diff --git a/src/main/services/ceo-direct-space.ts b/src/main/services/ceo-direct-space.ts new file mode 100644 index 00000000..46136730 --- /dev/null +++ b/src/main/services/ceo-direct-space.ts @@ -0,0 +1,29 @@ +import type { DatabaseSync } from "node:sqlite"; +import { ChatService } from "./chat-service"; + +/** + * The founder's direct line to the CEO: every organization keeps one direct + * conversation between its primary employee (the human operator) and its CEO + * (the c_suite agent — the same predicate the grant system enforces). The + * Messages page pins this conversation at the top, so it exists as a + * structural invariant instead of appearing lazily on first use. + * + * Returns the space id, or null while the org has no employee or no active + * CEO yet (mid-bootstrap) — the next ensure pass picks it up. + */ +export function ensureCeoDirectSpace(db: DatabaseSync, orgId: string): string | null { + const employee = db.prepare("select id from employees where org_id = ? order by created_at asc limit 1") + .get(orgId) as { id: string } | undefined; + const ceo = db.prepare(` + select id from agents + where org_id = ? and role_level = 'c_suite' and lifecycle = 'active' + order by created_at asc + limit 1 + `).get(orgId) as { id: string } | undefined; + if (!employee || !ceo) return null; + return new ChatService(db).ensureDirectSpace( + orgId, + { type: "employee", id: employee.id }, + { type: "agent", id: ceo.id }, + ); +} diff --git a/src/main/services/onboarding-service.ts b/src/main/services/onboarding-service.ts index 19151153..d8d11236 100644 --- a/src/main/services/onboarding-service.ts +++ b/src/main/services/onboarding-service.ts @@ -8,6 +8,7 @@ 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 { ensureCeoDirectSpace } from "./ceo-direct-space"; import { goalKickoffDescription, goalKickoffTitle } from "./goal-kickoff"; import { OrganizationService } from "./organization-service"; import { ChatService } from "./chat-service"; @@ -70,6 +71,11 @@ export class OnboardingService { const goalId = this.createGoal(orgId, ceoAgentId, input, now); const projectId = this.createProject(orgId, goalId, ceoAgentId, input, now); this.createProjectChatSpace(orgId, projectName(input)); + // The founder⇄CEO conversation is a bootstrap deliverable, not an option: + // the Messages page pins it, so its absence here is a hard failure. + if (!ensureCeoDirectSpace(this.deps.db, orgId)) { + throw new Error("The founder⇄CEO conversation was not created during bootstrap."); + } if (workspaceId) this.bindWorkspaceToProject(workspaceId, projectId, now); const taskId = this.organization.createTask({ orgId, diff --git a/tests/unit/ceo-direct-space.test.ts b/tests/unit/ceo-direct-space.test.ts new file mode 100644 index 00000000..bd545d21 --- /dev/null +++ b/tests/unit/ceo-direct-space.test.ts @@ -0,0 +1,119 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { ChatService } from "../../src/main/services/chat-service"; +import { ensureCeoDirectSpace } from "../../src/main/services/ceo-direct-space"; + +interface Harness { + connection: DatabaseConnection; + orgId: string; + employeeId: string; + providerId: string; +} + +function harness(options: { enableProvider?: boolean } = {}): Harness { + const connection = new DatabaseConnection(join(tmpdir(), `ac-ceo-dm-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + let orgId = ""; + let providerId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "OrbitOps", employeeName: "Yang", mission: "m", goal: "g" }); + providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id; + if (options.enableProvider) { + connection.db.prepare("update providers set enabled = 1 where id = ? and org_id = ?").run(providerId, orgId); + } + }); + const employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id; + return { connection, orgId, employeeId, providerId }; +} + +function createCeo(h: Harness, name = "CEO"): string { + const organization = new OrganizationService(h.connection.db); + let id = ""; + h.connection.transaction(() => { + id = organization.createAgent({ + orgId: h.orgId, name, role: "CEO", department: "Executive", + modelProviderId: h.providerId, modelId: "gpt-5.4", budgetCents: 25000, + managerAgentId: null, roleLevel: "c_suite", + }); + }); + return id; +} + +function spaceMembers(h: Harness, spaceId: string): Array<{ principal_type: string; principal_id: string }> { + return h.connection.db.prepare("select principal_type, principal_id from chat_memberships where space_id = ? order by principal_type") + .all(spaceId) as Array<{ principal_type: string; principal_id: string }>; +} + +describe("ensureCeoDirectSpace", () => { + test("creates the founder⇄CEO direct conversation with exactly those two members", () => { + const h = harness(); + const ceoId = createCeo(h); + + const spaceId = ensureCeoDirectSpace(h.connection.db, h.orgId); + expect(spaceId).toBeTruthy(); + + const space = h.connection.db.prepare("select space_type, visibility from chat_spaces where id = ? and org_id = ?") + .get(spaceId, h.orgId) as { space_type: string; visibility: string }; + expect(space.space_type).toBe("direct"); + expect(space.visibility).toBe("restricted"); + + const members = spaceMembers(h, spaceId as string); + expect(members).toEqual([ + { principal_type: "agent", principal_id: ceoId }, + { principal_type: "employee", principal_id: h.employeeId }, + ]); + }); + + test("is idempotent: repeated ensures return the same conversation", () => { + const h = harness(); + createCeo(h); + + const first = ensureCeoDirectSpace(h.connection.db, h.orgId); + const second = ensureCeoDirectSpace(h.connection.db, h.orgId); + expect(second).toBe(first); + + const directSpaces = h.connection.db.prepare("select id from chat_spaces where org_id = ? and space_type = 'direct'") + .all(h.orgId) as Array<{ id: string }>; + expect(directSpaces).toHaveLength(1); + }); + + test("returns null and creates nothing while the org has no CEO", () => { + const h = harness(); + expect(ensureCeoDirectSpace(h.connection.db, h.orgId)).toBeNull(); + const directSpaces = h.connection.db.prepare("select id from chat_spaces where org_id = ? and space_type = 'direct'") + .all(h.orgId) as Array<{ id: string }>; + expect(directSpaces).toHaveLength(0); + }); + + test("ignores a retired CEO", () => { + const h = harness(); + const ceoId = createCeo(h); + h.connection.db.prepare("update agents set lifecycle = 'retired' where id = ?").run(ceoId); + expect(ensureCeoDirectSpace(h.connection.db, h.orgId)).toBeNull(); + }); + + test("the founder's message in the ensured conversation wakes the CEO without an @mention", () => { + const h = harness({ enableProvider: true }); + const ceoId = createCeo(h); + const spaceId = ensureCeoDirectSpace(h.connection.db, h.orgId) as string; + + new ChatService(h.connection.db).sendMessage({ + orgId: h.orgId, + spaceId, + threadId: null, + senderType: "employee", + senderId: h.employeeId, + bodyMd: "How is the launch tracking against the goal?", + attachmentFileIds: [], + }); + + const runs = h.connection.db.prepare( + "select count(*) as count from task_runs where org_id = ? and agent_id = ? and trigger = 'chat_mention'", + ).get(h.orgId, ceoId) as { count: number }; + expect(runs.count).toBe(1); + }); +});