diff --git a/src/main/app/ipc-contracts/organization.ts b/src/main/app/ipc-contracts/organization.ts index 3b0108a0..89b6dd46 100644 --- a/src/main/app/ipc-contracts/organization.ts +++ b/src/main/app/ipc-contracts/organization.ts @@ -56,6 +56,10 @@ export const organizationContracts = { runTask: z.strictObject({ orgId: id, taskId: id }) satisfies z.ZodType, createGoal: z.strictObject({ orgId: id, title: z.string(), description: z.string(), ownerAgentId: nullableId, dueAt: z.string().nullable().optional() }) satisfies z.ZodType, setGoalDueDate: z.strictObject({ orgId: id, goalId: id, dueAt: z.string().nullable() }) satisfies z.ZodType, + updateOrganization: z.strictObject({ + orgId: id, name: z.string(), description: z.string(), mission: z.string(), goal: z.string(), + executionMode: z.enum(["ai_ceo", "ai_cofounder"]), autoApproveHires: z.boolean(), reviewDeliverables: z.boolean(), + }) satisfies z.ZodType, resumeGoal: z.strictObject({ orgId: id, goalId: id }) satisfies z.ZodType, createProject: z.strictObject({ orgId: id, goalId: nullableId, name: z.string(), description: z.string(), leadAgentId: nullableId, diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index 4651c4a2..f7c86e37 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -184,6 +184,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { }); handle("createGoal", (input) => mutate(() => organization.createGoal(input))); handle("setGoalDueDate", (input) => mutate(() => organization.setGoalDueDate(input))); + handle("updateOrganization", (input) => mutate(() => organization.updateOrganization(input))); handle("createProject", (input) => mutate(() => organization.createProject(input))); handle("createWorkspace", (input) => mutate(() => organization.createWorkspace(input))); handle("createDocument", (input) => mutate(() => documents.create(input))); diff --git a/src/main/services/app-state.ts b/src/main/services/app-state.ts new file mode 100644 index 00000000..b25d76e4 --- /dev/null +++ b/src/main/services/app-state.ts @@ -0,0 +1,23 @@ +import type { DatabaseSync } from "node:sqlite"; + +/** The global handbrake: while paused, no run is queued or claimed (one in flight finishes); it survives restarts. */ +export function setAutonomousPaused(db: DatabaseSync, paused: boolean): void { + db.prepare(` + insert into app_state(id, active_org_id, autorun_paused, updated_at) + values (1, null, ?, ?) + on conflict(id) do update set autorun_paused = excluded.autorun_paused, updated_at = excluded.updated_at + `).run(paused ? 1 : 0, new Date().toISOString()); +} + +/** The app-wide theme and language, kept for every company. */ +export function updateUiPreferences(db: DatabaseSync, input: { theme?: "system" | "light" | "dark"; language?: "en" | "zh" }): void { + const current = db.prepare("select theme, language from app_state where id = 1") + .get() as { theme: string; language: string } | undefined; + const theme = input.theme ?? current?.theme ?? "system"; + const language = input.language ?? current?.language ?? "en"; + db.prepare(` + insert into app_state(id, active_org_id, theme, language, updated_at) + values (1, null, ?, ?, ?) + on conflict(id) do update set theme = excluded.theme, language = excluded.language, updated_at = excluded.updated_at + `).run(theme, language, new Date().toISOString()); +} diff --git a/src/main/services/goal-due-date.ts b/src/main/services/goal-due-date.ts new file mode 100644 index 00000000..8a417182 --- /dev/null +++ b/src/main/services/goal-due-date.ts @@ -0,0 +1,21 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { SetGoalDueDateInput } from "../../shared/commands"; +import { AuditService } from "./audit-service"; + +/** Sets or clears when a goal is planned to be reached. */ +export function setGoalDueDate(db: DatabaseSync, input: SetGoalDueDateInput): void { + const result = db.prepare("update goals set due_at = ?, updated_at = ? where id = ? and org_id = ?") + .run(validDueAt(input.dueAt), new Date().toISOString(), input.goalId, input.orgId); + if (result.changes === 0) throw new Error("Goal was not found."); + new AuditService(db).record({ orgId: input.orgId, entityType: "goal", entityId: input.goalId, actorType: "employee", actorId: null, action: "due_date_set", payload: { dueAt: input.dueAt } }); +} + +/** A target date is an exact ISO UTC time on a real calendar day; anything else is refused rather than stored shifted. */ +export function validDueAt(dueAt: string | null): string | null { + if (dueAt === null) return null; + const exact = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/.test(dueAt); + const time = new Date(dueAt); + const normalized = dueAt.length === 20 ? dueAt.replace("Z", ".000Z") : dueAt; + if (!exact || Number.isNaN(time.getTime()) || time.toISOString() !== normalized) throw new Error(`"${dueAt}" is not a date.`); + return normalized; +} diff --git a/src/main/services/organization-profile.ts b/src/main/services/organization-profile.ts new file mode 100644 index 00000000..4eab1f0e --- /dev/null +++ b/src/main/services/organization-profile.ts @@ -0,0 +1,28 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { UpdateOrganizationInput } from "../../shared/commands"; +import { AuditService } from "./audit-service"; + +/** Changes the profile and how the company runs; requests already waiting keep their state, new work follows the new settings. */ +export function updateOrganizationProfile(db: DatabaseSync, input: UpdateOrganizationInput): void { + const name = input.name.trim(); + if (!name) throw new Error("The company needs a name."); + const before = db.prepare(` + select name, description, mission, goal, execution_mode, auto_approve_hires, review_deliverables from organizations where id = ? + `).get(input.orgId) as { name: string; description: string; mission: string; goal: string; execution_mode: string; auto_approve_hires: number; review_deliverables: number } | undefined; + if (!before) throw new Error("Organization was not found."); + const next = { + name, description: input.description.trim(), mission: input.mission.trim(), goal: input.goal.trim(), + executionMode: input.executionMode, autoApproveHires: input.autoApproveHires, reviewDeliverables: input.reviewDeliverables, + }; + const previous = { + name: before.name, description: before.description, mission: before.mission, goal: before.goal, + executionMode: before.execution_mode, autoApproveHires: before.auto_approve_hires === 1, reviewDeliverables: before.review_deliverables === 1, + }; + const changed = (Object.keys(next) as Array).filter((key) => next[key] !== previous[key]); + db.prepare(` + update organizations set name = ?, description = ?, mission = ?, goal = ?, execution_mode = ?, auto_approve_hires = ?, review_deliverables = ? + where id = ? + `).run(next.name, next.description, next.mission, next.goal, next.executionMode, next.autoApproveHires ? 1 : 0, next.reviewDeliverables ? 1 : 0, input.orgId); + console.info(`[organization] ${input.orgId} updated: ${changed.join(", ") || "nothing changed"}`); + new AuditService(db).record({ orgId: input.orgId, entityType: "organization", entityId: input.orgId, actorType: "employee", actorId: null, action: "updated", payload: { changed, ...Object.fromEntries(changed.map((key) => [key, next[key]])) } }); +} diff --git a/src/main/services/organization-service.ts b/src/main/services/organization-service.ts index 98ab1d9a..08ac6215 100644 --- a/src/main/services/organization-service.ts +++ b/src/main/services/organization-service.ts @@ -1,9 +1,12 @@ +import { setAutonomousPaused, updateUiPreferences } from "./app-state"; +import { updateOrganizationProfile } from "./organization-profile"; +import { setGoalDueDate, validDueAt } from "./goal-due-date"; import type { StatusCause } from "./task-mutations"; import type { TaskDeleteImpact } from "../../shared/domain"; import type { DatabaseSync } from "node:sqlite"; import { APPROVAL_STATE, RISK_LEVEL, TASK_STATUS, WRITING_MODE, type RiskLevel } from "../../shared/domain"; import { createId } from "../../shared/ids"; -import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, DeleteTaskInput, SetGoalDueDateInput, SetTaskBlockedInput, UpdateAgentInput, UpdateEmployeeProfileInput, UpdateTaskInput } from "../../shared/commands"; +import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, DeleteTaskInput, SetGoalDueDateInput, SetTaskBlockedInput, UpdateAgentInput, UpdateOrganizationInput, UpdateEmployeeProfileInput, UpdateTaskInput } from "../../shared/commands"; import { AuditService } from "./audit-service"; import { ensureAgentHeartbeat, resolveGoalKickoff, type GoalKickoffPlan } from "./goal-kickoff"; import { PolicyService } from "./policy-service"; @@ -33,6 +36,12 @@ export class OrganizationService { return seedOrganization(this.db, input); } + updateOrganization(input: UpdateOrganizationInput): void { + updateOrganizationProfile(this.db, input); + } + + + setActiveOrganization(orgId: string): void { const row = this.db.prepare("select id from organizations where id = ?").get(orgId) as { id: string } | undefined; if (!row) throw new Error("Organization was not found."); @@ -53,30 +62,12 @@ export class OrganizationService { }); } - /** - * The global handbrake: while paused, the autonomous queue stops enqueuing - * and claiming runs (the run already in flight finishes). Survives - * restarts, so a runaway conversation stays stopped until the operator - * resumes it. - */ setAutonomousPaused(paused: boolean): void { - this.db.prepare(` - insert into app_state(id, active_org_id, autorun_paused, updated_at) - values (1, null, ?, ?) - on conflict(id) do update set autorun_paused = excluded.autorun_paused, updated_at = excluded.updated_at - `).run(paused ? 1 : 0, new Date().toISOString()); + setAutonomousPaused(this.db, paused); } updateUiPreferences(input: { theme?: "system" | "light" | "dark"; language?: "en" | "zh" }): void { - const current = this.db.prepare("select theme, language from app_state where id = 1") - .get() as { theme: string; language: string } | undefined; - const theme = input.theme ?? current?.theme ?? "system"; - const language = input.language ?? current?.language ?? "en"; - this.db.prepare(` - insert into app_state(id, active_org_id, theme, language, updated_at) - values (1, null, ?, ?, ?) - on conflict(id) do update set theme = excluded.theme, language = excluded.language, updated_at = excluded.updated_at - `).run(theme, language, new Date().toISOString()); + updateUiPreferences(this.db, input); } deleteOrganization(orgId: string): void { @@ -478,12 +469,10 @@ export class OrganizationService { return this.tasks.deleteImpact(orgId, taskId); } - /** Sets or clears when a goal is planned to be reached. */ + + setGoalDueDate(input: SetGoalDueDateInput): void { - const result = this.db.prepare("update goals set due_at = ?, updated_at = ? where id = ? and org_id = ?") - .run(validDueAt(input.dueAt), new Date().toISOString(), input.goalId, input.orgId); - if (result.changes === 0) throw new Error("Goal was not found."); - this.audit.record({ orgId: input.orgId, entityType: "goal", entityId: input.goalId, actorType: "employee", actorId: null, action: "due_date_set", payload: { dueAt: input.dueAt } }); + setGoalDueDate(this.db, input); } setTaskBlocked(input: SetTaskBlockedInput): void { @@ -502,13 +491,3 @@ export class OrganizationService { return this.tasks.agentSetStatus(input); } } - -/** A target date is an exact ISO UTC time on a real calendar day; anything else is refused rather than stored shifted. */ -function validDueAt(dueAt: string | null): string | null { - if (dueAt === null) return null; - const exact = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/.test(dueAt); - const time = new Date(dueAt); - const normalized = dueAt.length === 20 ? dueAt.replace("Z", ".000Z") : dueAt; - if (!exact || Number.isNaN(time.getTime()) || time.toISOString() !== normalized) throw new Error(`"${dueAt}" is not a date.`); - return normalized; -} diff --git a/src/preload/index.ts b/src/preload/index.ts index 1f6f2163..12ad2b26 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -95,6 +95,7 @@ const api: DesktopApi = { cancelQueuedRun: (input) => invoke(IPC_CHANNELS.cancelQueuedRun, input), runHistory: (input) => invoke(IPC_CHANNELS.runHistory, input), setGoalDueDate: (input) => invoke(IPC_CHANNELS.setGoalDueDate, input), + updateOrganization: (input) => invoke(IPC_CHANNELS.updateOrganization, input), revokeConnector: (input) => invoke(IPC_CHANNELS.revokeConnector, input), refreshComposioAccounts: (input) => invoke(IPC_CHANNELS.refreshComposioAccounts, input), search: (input) => invoke(IPC_CHANNELS.search, input), diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index c544941e..ff2b8134 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -54,6 +54,12 @@ export const zh: Record = { "Cancel run": "取消运行", "Show older runs": "显示更早的运行", "Target date": "目标日期", + "Edit company": "编辑公司", + "Who runs the work": "由谁推进工作", + "AI CEO: runs tasks without asking": "AI CEO:无需询问直接执行任务", + "AI Co-Founder: asks before risky tasks": "AI 联合创始人:高风险任务先征求同意", + "Review deliverables before they are written": "交付物写入前先审查", + "Changes apply to work from now on; requests already waiting keep their state.": "更改从现在起对新工作生效;已在等待的申请保持原状态。", "Applies to every company in this app.": "适用于本应用中的所有公司。", "Applies to {company} only; the folders are on this computer.": "仅适用于 {company};文件夹位于这台电脑上。", "Applies to {company} only.": "仅适用于 {company}。", diff --git a/src/renderer/src/pages/settings/CompanyDialog.tsx b/src/renderer/src/pages/settings/CompanyDialog.tsx new file mode 100644 index 00000000..4e80019f --- /dev/null +++ b/src/renderer/src/pages/settings/CompanyDialog.tsx @@ -0,0 +1,45 @@ +import { useState, type FormEvent } from "react"; +import type { OrganizationRecord } from "../../../../shared/domain"; +import { Field } from "../../shared/ui"; +import { Dialog } from "../../shared/Dialog"; +import type { SnapshotActions } from "../../app/useSnapshot"; +import { t } from "../../i18n/i18n"; + +/** Changes the company's profile and how it runs after onboarding; requests already waiting keep their state. */ +export function CompanyDialog({ organization, open, actions, onClose }: { organization: OrganizationRecord; open: boolean; actions: SnapshotActions; onClose: () => void }) { + const [error, setError] = useState(null); + const submit = (event: FormEvent) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const value = (key: string) => String(form.get(key) ?? ""); + setError(null); + actions.run(() => window.agentCompany.updateOrganization({ + orgId: organization.id, + name: value("name"), + description: value("description"), + mission: value("mission"), + goal: value("goal"), + executionMode: value("executionMode") === "ai_cofounder" ? "ai_cofounder" : "ai_ceo", + autoApproveHires: form.get("autoApproveHires") === "on", + reviewDeliverables: form.get("reviewDeliverables") === "on", + })).then(onClose, (reason: unknown) => setError(reason instanceof Error ? reason.message : String(reason))); + }; + return ( + + +