Q165: the company's profile and how it runs can be changed after onboarding
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
93c5eb2d71
commit
20c37362f3
15 files changed
+208
-37
No files matched your search
@@ -56,6 +56,10 @@ export const organizationContracts = {
|
||||
runTask: z.strictObject({ orgId: id, taskId: id }) satisfies z.ZodType<C.RunTaskInput>,
|
||||
createGoal: z.strictObject({ orgId: id, title: z.string(), description: z.string(), ownerAgentId: nullableId, dueAt: z.string().nullable().optional() }) satisfies z.ZodType<C.CreateGoalInput>,
|
||||
setGoalDueDate: z.strictObject({ orgId: id, goalId: id, dueAt: z.string().nullable() }) satisfies z.ZodType<C.SetGoalDueDateInput>,
|
||||
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<C.UpdateOrganizationInput>,
|
||||
resumeGoal: z.strictObject({ orgId: id, goalId: id }) satisfies z.ZodType<C.ResumeGoalInput>,
|
||||
createProject: z.strictObject({
|
||||
orgId: id, goalId: nullableId, name: z.string(), description: z.string(), leadAgentId: nullableId,
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<keyof typeof next>).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]])) } });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -54,6 +54,12 @@ export const zh: Record<string, string> = {
|
||||
"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}。",
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
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 (
|
||||
<Dialog title={t("Edit company")} open={open} submitLabel={t("Save")} onClose={onClose} onSubmit={submit}>
|
||||
<Field label={t("Name")}><input className="input" name="name" defaultValue={organization.name} required /></Field>
|
||||
<Field label={t("Description")}><textarea className="input" name="description" defaultValue={organization.description} /></Field>
|
||||
<Field label={t("Mission")}><input className="input" name="mission" defaultValue={organization.mission} /></Field>
|
||||
<Field label={t("Goal")}><input className="input" name="goal" defaultValue={organization.goal} /></Field>
|
||||
<Field label={t("Who runs the work")}>
|
||||
<select className="input" name="executionMode" defaultValue={organization.executionMode}>
|
||||
<option value="ai_ceo">{t("AI CEO: runs tasks without asking")}</option>
|
||||
<option value="ai_cofounder">{t("AI Co-Founder: asks before risky tasks")}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<label className="checkbox-row"><input type="checkbox" name="autoApproveHires" defaultChecked={organization.autoApproveHires} /> {t("Auto-approve hires")}</label>
|
||||
<label className="checkbox-row"><input type="checkbox" name="reviewDeliverables" defaultChecked={organization.reviewDeliverables} /> {t("Review deliverables before they are written")}</label>
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)" }}>{t("Changes apply to work from now on; requests already waiting keep their state.")}</div>
|
||||
{error && <div className="form-error" role="alert">{error}</div>}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CompanyDialog } from "./CompanyDialog";
|
||||
import { ScopeNote } from "./ScopeNote";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import type {
|
||||
@@ -40,6 +41,7 @@ export function WorkspacePanel({
|
||||
actions: SnapshotActions;
|
||||
}) {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
||||
const chooseDirectory = async (form: HTMLFormElement) => {
|
||||
const result = await window.agentCompany.pickDirectory();
|
||||
@@ -62,8 +64,12 @@ export function WorkspacePanel({
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1 className="page-h1">{t("General")}</h1>
|
||||
<div className="row" style={{ alignItems: "baseline" }}>
|
||||
<h1 className="page-h1">{t("General")}</h1>
|
||||
<span style={{ marginLeft: "auto" }}><Button onClick={() => setEditOpen(true)}><Icon name="edit" size={14} /> {t("Edit company")}</Button></span>
|
||||
</div>
|
||||
<ScopeNote scope="company" company={organization.name} />
|
||||
<CompanyDialog key={editOpen ? "open" : "closed"} organization={organization} open={editOpen} actions={actions} onClose={() => setEditOpen(false)} />
|
||||
|
||||
<div className="row" style={{ gap: 8, margin: "16px 0 24px" }}>
|
||||
<Pill tone="violet" dot>{organization.executionMode === "ai_ceo" ? t("AI CEO") : t("AI Co-Founder")}</Pill>
|
||||
|
||||
@@ -144,6 +144,18 @@ export interface CreateGoalInput {
|
||||
dueAt?: string | null;
|
||||
}
|
||||
|
||||
/** The company's profile and how it runs, as set after onboarding; changes apply to work from now on. */
|
||||
export interface UpdateOrganizationInput {
|
||||
orgId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
mission: string;
|
||||
goal: string;
|
||||
executionMode: "ai_ceo" | "ai_cofounder";
|
||||
autoApproveHires: boolean;
|
||||
reviewDeliverables: boolean;
|
||||
}
|
||||
|
||||
export interface SetGoalDueDateInput {
|
||||
orgId: string;
|
||||
goalId: string;
|
||||
|
||||
@@ -37,6 +37,7 @@ import type {
|
||||
DeleteTaskInput,
|
||||
SetGoalDueDateInput,
|
||||
SetTaskBlockedInput,
|
||||
UpdateOrganizationInput,
|
||||
DeleteKnowledgeEntryInput,
|
||||
ResumeGoalInput,
|
||||
UpdateAutomationRuleInput,
|
||||
@@ -208,6 +209,7 @@ export interface DesktopApi {
|
||||
taskDeleteImpact(input: { orgId: string; taskId: string }): Promise<DesktopResult<TaskDeleteImpact>>;
|
||||
/** Cancels a run that has not started yet. */
|
||||
setGoalDueDate(input: SetGoalDueDateInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
updateOrganization(input: UpdateOrganizationInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
cancelQueuedRun(input: { orgId: string; runId: string }): Promise<DesktopResult<AppSnapshot>>;
|
||||
/** Runs older than a loaded one, for history beyond the snapshot's recent runs. */
|
||||
runHistory(input: { orgId: string; beforeRunId: string | null } & ({ limit: number } | { from: RunCursor })): Promise<DesktopResult<RunHistoryPage>>;
|
||||
|
||||
@@ -88,6 +88,7 @@ export const IPC_CHANNELS = {
|
||||
cancelQueuedRun: "agentcompany:cancel-queued-run",
|
||||
runHistory: "agentcompany:run-history",
|
||||
setGoalDueDate: "agentcompany:set-goal-due-date",
|
||||
updateOrganization: "agentcompany:update-organization",
|
||||
revokeConnector: "agentcompany:revoke-connector",
|
||||
search: "agentcompany:search",
|
||||
rendererEvent: "agentcompany:renderer-event",
|
||||
|
||||
Reference in new issue
Block a user