Q166: workspaces can be edited, made primary and removed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 03:32:03 -04:00
1 parent 20c37362f3
commit e8722cd815
12 files changed
+247 -9

No files matched your search

@@ -56,6 +56,9 @@ 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>,
updateWorkspace: z.strictObject({ orgId: id, workspaceId: id, name: z.string(), localPath: z.string(), projectId: nullableId }) satisfies z.ZodType<C.UpdateWorkspaceInput>,
setPrimaryWorkspace: z.strictObject({ orgId: id, workspaceId: id }),
deleteWorkspace: z.strictObject({ orgId: id, workspaceId: id }),
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(),
+4
View File
@@ -39,6 +39,7 @@ import { resumeGoal } from "../services/goal-review-service";
import { stopAllRuns, stopRun } from "../services/run-control";
import { cancelQueuedRun } from "../services/run-cancel";
import { runHistory } from "../services/run-history";
import { deleteWorkspace, setPrimaryWorkspace, updateWorkspace } from "../services/workspace-admin";
export interface IpcDeps {
connection: DatabaseConnection;
@@ -185,6 +186,9 @@ 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("updateWorkspace", (input) => mutate(() => updateWorkspace(deps.connection.db, input)));
handle("setPrimaryWorkspace", (input) => mutate(() => setPrimaryWorkspace(deps.connection.db, input.orgId, input.workspaceId)));
handle("deleteWorkspace", (input) => mutate(() => deleteWorkspace(deps.connection.db, input.orgId, input.workspaceId)));
handle("createProject", (input) => mutate(() => organization.createProject(input)));
handle("createWorkspace", (input) => mutate(() => organization.createWorkspace(input)));
handle("createDocument", (input) => mutate(() => documents.create(input)));
+65
View File
@@ -0,0 +1,65 @@
import { existsSync, statSync } from "node:fs";
import type { DatabaseSync } from "node:sqlite";
import type { UpdateWorkspaceInput } from "../../shared/commands";
import { AuditService } from "./audit-service";
import { closeChatWakeTask } from "./chat-wake-close";
function requireWorkspace(db: DatabaseSync, orgId: string, workspaceId: string): { name: string; local_path: string } {
const row = db.prepare("select name, local_path from workspaces where id = ? and org_id = ?").get(workspaceId, orgId) as { name: string; local_path: string } | undefined;
if (!row) throw new Error("Workspace was not found.");
return row;
}
/** Renames a workspace, points it at its folder (which must exist), or relinks its project; runs queued for the old folder are superseded. */
export function updateWorkspace(db: DatabaseSync, input: UpdateWorkspaceInput): void {
const before = requireWorkspace(db, input.orgId, input.workspaceId);
const name = input.name.trim();
if (!name) throw new Error("The workspace needs a name.");
const localPath = input.localPath.trim();
if (!existsSync(localPath) || !statSync(localPath).isDirectory()) throw new Error(`"${localPath}" is not a folder on this computer.`);
if (input.projectId && !db.prepare("select 1 from projects where id = ? and org_id = ?").get(input.projectId, input.orgId)) throw new Error("Project was not found.");
db.prepare("update workspaces set name = ?, local_path = ?, project_id = ?, updated_at = ? where id = ? and org_id = ?")
.run(name, localPath, input.projectId, new Date().toISOString(), input.workspaceId, input.orgId);
if (localPath !== before.local_path) supersedeQueuedRuns(db, input.orgId, input.workspaceId);
new AuditService(db).record({ orgId: input.orgId, entityType: "workspace", entityId: input.workspaceId, actorType: "employee", actorId: null, action: "updated", payload: { name, localPath, projectId: input.projectId } });
}
/** Makes one workspace the company's primary one, where new goals' work starts; the others stop being primary. */
export function setPrimaryWorkspace(db: DatabaseSync, orgId: string, workspaceId: string): void {
requireWorkspace(db, orgId, workspaceId);
db.prepare("update workspaces set is_primary = case when id = ? then 1 else 0 end, updated_at = ? where org_id = ?").run(workspaceId, new Date().toISOString(), orgId);
new AuditService(db).record({ orgId, entityType: "workspace", entityId: workspaceId, actorType: "employee", actorId: null, action: "made_primary", payload: {} });
}
/** Removes a workspace nothing open uses; finished tasks keep their history without the link. */
export function deleteWorkspace(db: DatabaseSync, orgId: string, workspaceId: string): void {
const workspace = requireWorkspace(db, orgId, workspaceId);
const count = (sql: string) => (db.prepare(sql).get(workspaceId, orgId) as { n: number }).n;
// New goals start in the primary workspace, so it cannot leave while another could take its place.
const primary = count("select count(*) as n from workspaces where id = ? and org_id = ? and is_primary = 1") > 0;
const others = count("select count(*) as n from workspaces where id <> ? and org_id = ?");
if (primary && others > 0) throw new Error("This is the primary workspace; make another one primary first.");
const openTasks = count("select count(*) as n from tasks where workspace_id = ? and org_id = ? and status <> 'done'");
if (openTasks > 0) throw new Error(`${openTasks} open task${openTasks === 1 ? " uses" : "s use"} this workspace; move ${openTasks === 1 ? "it" : "them"} to another workspace first.`);
const agents = count("select count(*) as n from agents where workspace_id = ? and org_id = ?");
if (agents > 0) throw new Error(`${agents} agent${agents === 1 ? " works" : "s work"} in this workspace; give ${agents === 1 ? "it" : "them"} another workspace first.`);
db.prepare("update tasks set workspace_id = null where workspace_id = ? and org_id = ?").run(workspaceId, orgId);
db.prepare("delete from workspaces where id = ? and org_id = ?").run(workspaceId, orgId);
console.info(`[workspaces] removed ${workspace.name} (${workspaceId})`);
new AuditService(db).record({ orgId, entityType: "workspace", entityId: workspaceId, actorType: "employee", actorId: null, action: "deleted", payload: { name: workspace.name } });
}
/** A queued run recorded the old folder as its mount; it would run against the new one, so it is superseded instead (no retry budget spent). */
function supersedeQueuedRuns(db: DatabaseSync, orgId: string, workspaceId: string): void {
const queued = db.prepare(`
select r.id from task_runs r
where r.org_id = ? and r.state = 'queued'
and exists (select 1 from json_each(r.workspace_mount_json) m where json_extract(m.value, '$.workspaceId') = ?)
`).all(orgId, workspaceId) as Array<{ id: string }>;
for (const run of queued) {
db.prepare("update task_runs set state = 'superseded', completed_at = ?, error_message = ? where id = ?")
.run(new Date().toISOString(), "Superseded: the workspace folder changed after this run was queued.", run.id);
closeChatWakeTask(db, orgId, run.id);
console.info(`[workspaces] run ${run.id} superseded: its workspace folder changed`);
}
}
+3
View File
@@ -96,6 +96,9 @@ const api: DesktopApi = {
runHistory: (input) => invoke(IPC_CHANNELS.runHistory, input),
setGoalDueDate: (input) => invoke(IPC_CHANNELS.setGoalDueDate, input),
updateOrganization: (input) => invoke(IPC_CHANNELS.updateOrganization, input),
updateWorkspace: (input) => invoke(IPC_CHANNELS.updateWorkspace, input),
setPrimaryWorkspace: (input) => invoke(IPC_CHANNELS.setPrimaryWorkspace, input),
deleteWorkspace: (input) => invoke(IPC_CHANNELS.deleteWorkspace, input),
revokeConnector: (input) => invoke(IPC_CHANNELS.revokeConnector, input),
refreshComposioAccounts: (input) => invoke(IPC_CHANNELS.refreshComposioAccounts, input),
search: (input) => invoke(IPC_CHANNELS.search, input),
+4
View File
@@ -55,6 +55,10 @@ export const zh: Record<string, string> = {
"Show older runs": "显示更早的运行",
"Target date": "目标日期",
"Edit company": "编辑公司",
"Make primary": "设为主工作区",
"Remove this workspace?": "移除这个工作区?",
"Remove this workspace? Its folder stays on disk.": "移除这个工作区?文件夹仍保留在磁盘上。",
"Edit workspace": "编辑工作区",
"Who runs the work": "由谁推进工作",
"AI CEO: runs tasks without asking": "AI CEO:无需询问直接执行任务",
"AI Co-Founder: asks before risky tasks": "AI 联合创始人:高风险任务先征求同意",
@@ -1,3 +1,4 @@
import { WorkspaceRow } from "./WorkspaceRow";
import { CompanyDialog } from "./CompanyDialog";
import { ScopeNote } from "./ScopeNote";
import { useState, type FormEvent } from "react";
@@ -124,15 +125,7 @@ export function WorkspacePanel({
</div>
<ScopeNote scope="company_folders" company={organization.name} />
{workspaces.map((workspace) => (
<div key={workspace.id} className="row" aria-current={workspace.id === focusedWorkspaceId ? "true" : undefined} style={{ padding: "8px 0", borderBottom: "1px solid var(--border-soft)", background: workspace.id === focusedWorkspaceId ? "var(--accent-soft)" : undefined }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 540 }}>{workspace.name}</div>
<div style={{ fontSize: 12, color: "var(--text-3)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{workspace.localPath}</div>
</div>
<div style={{ marginLeft: "auto" }}>
{workspace.isPrimary && <Pill tone="violet">{t("Primary")}</Pill>}
</div>
</div>
<WorkspaceRow key={workspace.id} workspace={workspace} projects={projects} focused={workspace.id === focusedWorkspaceId} actions={actions} />
))}
<Dialog title={t("Add workspace")} open={createOpen} submitLabel={t("Create")} onClose={() => setCreateOpen(false)} onSubmit={submit}>
@@ -0,0 +1,71 @@
import { useState, type FormEvent } from "react";
import type { ProjectRecord, WorkspaceRecord } from "../../../../shared/domain";
import { Button, Field, Icon, IconButton, Pill } from "../../shared/ui";
import { Dialog } from "../../shared/Dialog";
import type { SnapshotActions } from "../../app/useSnapshot";
import { t } from "../../i18n/i18n";
/** One workspace with its upkeep: edit its name, folder and project, make it primary, or remove it once nothing open uses it. */
export function WorkspaceRow({ workspace, projects, focused, actions }: { workspace: WorkspaceRecord; projects: ProjectRecord[]; focused: boolean; actions: SnapshotActions }) {
const [editing, setEditing] = useState(false);
const [confirmRemove, setConfirmRemove] = useState(false);
const [error, setError] = useState<string | null>(null);
const act = (operation: () => Promise<unknown>, after?: () => void) => {
setError(null);
operation().then(after, (reason: unknown) => setError(reason instanceof Error ? reason.message : String(reason)));
};
const orgId = workspace.orgId;
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const value = (key: string) => String(form.get(key) ?? "");
act(() => actions.run(() => window.agentCompany.updateWorkspace({
orgId, workspaceId: workspace.id, name: value("name"), localPath: value("localPath"), projectId: value("projectId") || null,
})), () => setEditing(false));
};
const browse = () => {
void window.agentCompany.pickDirectory().then((result) => {
const input = document.querySelector<HTMLInputElement>(`input[data-workspace-path="${workspace.id}"]`);
if (result.ok && result.data && input) input.value = result.data;
});
};
return (
<div role="group" aria-label={workspace.name} aria-current={focused ? "true" : undefined} style={{ padding: "8px 0", borderBottom: "1px solid var(--border-soft)", background: focused ? "var(--accent-soft)" : undefined }}>
<div className="row" style={{ gap: 8 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontWeight: 540 }}>{workspace.name}</div>
<div style={{ fontSize: 12, color: "var(--text-3)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{workspace.localPath}</div>
</div>
{workspace.isPrimary
? <Pill tone="violet">{t("Primary")}</Pill>
: <Button size="small" tone="ghost" onClick={() => act(() => actions.run(() => window.agentCompany.setPrimaryWorkspace({ orgId, workspaceId: workspace.id })))}>{t("Make primary")}</Button>}
<IconButton icon="edit" label={t("Edit {name}", { name: workspace.name })} onClick={() => setEditing(true)} />
<Button size="small" tone="ghost" onClick={() => setConfirmRemove(true)}>{t("Remove")}</Button>
</div>
{confirmRemove && (
<div className="row" role="alertdialog" aria-label={t("Remove this workspace?")} style={{ gap: 8, marginTop: 6 }}>
<span style={{ fontSize: 13 }}>{t("Remove this workspace? Its folder stays on disk.")}</span>
<Button size="small" tone="primary" onClick={() => act(() => actions.run(() => window.agentCompany.deleteWorkspace({ orgId, workspaceId: workspace.id })))}>{t("Remove")}</Button>
<Button size="small" tone="ghost" onClick={() => { setConfirmRemove(false); setError(null); }}>{t("Cancel")}</Button>
</div>
)}
{error && <div className="form-error" role="alert">{error}</div>}
<Dialog title={t("Edit workspace")} open={editing} submitLabel={t("Save")} onClose={() => setEditing(false)} onSubmit={submit}>
<Field label={t("Name")}><input className="input" name="name" defaultValue={workspace.name} required /></Field>
<Field label={t("Local path")}>
<div className="row" style={{ gap: 6 }}>
<input className="input" name="localPath" data-workspace-path={workspace.id} defaultValue={workspace.localPath} required style={{ flex: 1 }} />
<Button tone="ghost" onClick={browse}><Icon name="folder" size={16} /> {t("Browse")}</Button>
</div>
</Field>
<Field label={t("Project")}>
<select className="input" name="projectId" defaultValue={workspace.projectId ?? ""}>
<option value="">{t("None")}</option>
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
</select>
</Field>
{error && <div className="form-error" role="alert">{error}</div>}
</Dialog>
</div>
);
}
+8
View File
@@ -171,6 +171,14 @@ export interface CreateProjectInput {
leadAgentId: string | null;
}
export interface UpdateWorkspaceInput {
orgId: string;
workspaceId: string;
name: string;
localPath: string;
projectId: string | null;
}
export interface CreateWorkspaceInput {
orgId: string;
name: string;
+4
View File
@@ -38,6 +38,7 @@ import type {
SetGoalDueDateInput,
SetTaskBlockedInput,
UpdateOrganizationInput,
UpdateWorkspaceInput,
DeleteKnowledgeEntryInput,
ResumeGoalInput,
UpdateAutomationRuleInput,
@@ -210,6 +211,9 @@ export interface DesktopApi {
/** Cancels a run that has not started yet. */
setGoalDueDate(input: SetGoalDueDateInput): Promise<DesktopResult<AppSnapshot>>;
updateOrganization(input: UpdateOrganizationInput): Promise<DesktopResult<AppSnapshot>>;
updateWorkspace(input: UpdateWorkspaceInput): Promise<DesktopResult<AppSnapshot>>;
setPrimaryWorkspace(input: { orgId: string; workspaceId: string }): Promise<DesktopResult<AppSnapshot>>;
deleteWorkspace(input: { orgId: string; workspaceId: string }): 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>>;
+3
View File
@@ -89,6 +89,9 @@ export const IPC_CHANNELS = {
runHistory: "agentcompany:run-history",
setGoalDueDate: "agentcompany:set-goal-due-date",
updateOrganization: "agentcompany:update-organization",
updateWorkspace: "agentcompany:update-workspace",
setPrimaryWorkspace: "agentcompany:set-primary-workspace",
deleteWorkspace: "agentcompany:delete-workspace",
revokeConnector: "agentcompany:revoke-connector",
search: "agentcompany:search",
rendererEvent: "agentcompany:renderer-event",
+19
View File
@@ -88,6 +88,25 @@ test("the pinned CEO conversation is the Messages front door", async () => {
await expect(page.getByText("Hires require approval")).toBeVisible();
await editCompany("ai_ceo", true);
await expect(page.getByText("AI CEO", { exact: true })).toBeVisible();
// A workspace can be kept up after it is added: renamed, made primary, removed.
const folder = join(tmpdir(), `agentcompany-new-e2e-ws-${randomUUID()}`);
mkdirSync(folder, { recursive: true });
await page.evaluate(async (localPath) => {
const api = (window as unknown as { agentCompany: {
loadSnapshot: () => Promise<{ ok: true; data: { activeOrgId: string } }>;
createWorkspace: (input: unknown) => Promise<{ ok: boolean }>;
} }).agentCompany;
const orgId = (await api.loadSnapshot()).data.activeOrgId;
await api.createWorkspace({ orgId, name: "Scratch", localPath, projectId: null });
}, folder);
await page.getByRole("button", { name: "Edit Scratch" }).click();
await page.getByLabel("Name").fill("Scratch repo");
await page.getByRole("button", { name: "Save" }).click();
await expect(page.getByText("Scratch repo", { exact: true })).toBeVisible();
const scratch = page.getByRole("group", { name: "Scratch repo" });
await scratch.getByRole("button", { name: "Remove" }).click();
await page.getByRole("alertdialog", { name: "Remove this workspace?" }).getByRole("button", { name: "Remove" }).click();
await expect(page.getByText("Scratch repo", { exact: true })).toHaveCount(0);
// A goal's pace is measured against its target date; without one, no pace is claimed.
await page.getByRole("button", { name: "Home" }).click();
+61
View File
@@ -0,0 +1,61 @@
import { randomUUID } from "node:crypto";
import { mkdirSync } from "node:fs";
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 { deleteWorkspace, setPrimaryWorkspace, updateWorkspace } from "../../src/main/services/workspace-admin";
import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS } from "../../src/shared/domain";
describe("managing a workspace after adding it", () => {
test("can be renamed, pointed at a moved folder, relinked, made primary, and removed once nothing open uses it", () => {
const connection = new DatabaseConnection(join(tmpdir(), `ac-workspace-admin-${randomUUID()}.db`));
const db = connection.db;
const organization = new OrganizationService(db);
const folder = (name: string) => {
const path = join(tmpdir(), `ac-ws-${name}-${randomUUID()}`);
mkdirSync(path, { recursive: true });
return path;
};
let orgId = "";
let wsA = "";
let wsB = "";
let projectId = "";
connection.transaction(() => {
orgId = organization.createOrganization({ organizationName: "Acme", employeeName: "Yang", mission: "m", goal: "g" });
projectId = organization.createProject({ orgId, goalId: null, name: "App", description: "d", leadAgentId: null });
wsA = organization.createWorkspace({ orgId, name: "A", localPath: folder("a"), projectId: null });
wsB = organization.createWorkspace({ orgId, name: "B", localPath: folder("b"), projectId: null });
});
// A run queued for the old folder must not run against the new one.
db.prepare("insert into task_runs(id, org_id, task_id, trigger, state, workspace_mount_json, created_at) values ('run_q', ?, ?, 'manual', 'queued', ?, ?)")
.run(orgId, organization.createTask({
orgId, title: "Queued", description: "d", status: TASK_STATUS.BACKLOG, assigneeAgentId: null, risk: RISK_LEVEL.LOW,
projectId: null, goalId: null, workspaceId: wsA, priority: TASK_PRIORITY.NORMAL, requiresFilesystem: false,
}), JSON.stringify([{ workspaceId: wsA, mode: "read_only" }]), new Date().toISOString());
const moved = folder("moved");
updateWorkspace(db, { orgId, workspaceId: wsA, name: "Main repo", localPath: moved, projectId });
expect(db.prepare("select name, local_path, project_id from workspaces where id = ?").get(wsA)).toEqual({ name: "Main repo", local_path: moved, project_id: projectId });
expect(db.prepare("select state from task_runs where id = 'run_q'").get()).toEqual({ state: "superseded" });
expect(() => updateWorkspace(db, { orgId, workspaceId: wsA, name: "Main repo", localPath: join(tmpdir(), "ac-missing-folder"), projectId: null }))
.toThrow("is not a folder on this computer.");
expect(() => updateWorkspace(db, { orgId, workspaceId: wsA, name: " ", localPath: moved, projectId: null })).toThrow("The workspace needs a name.");
setPrimaryWorkspace(db, orgId, wsB);
expect(db.prepare("select id from workspaces where org_id = ? and is_primary = 1").all(orgId)).toEqual([{ id: wsB }]);
expect(() => deleteWorkspace(db, orgId, wsB)).toThrow("This is the primary workspace; make another one primary first.");
const taskId = organization.createTask({
orgId, title: "Build", description: "d", status: TASK_STATUS.IN_PROGRESS, assigneeAgentId: null, risk: RISK_LEVEL.LOW,
projectId: null, goalId: null, workspaceId: wsA, priority: TASK_PRIORITY.NORMAL, requiresFilesystem: false,
});
expect(() => deleteWorkspace(db, orgId, wsA)).toThrow("2 open tasks use this workspace; move them to another workspace first.");
db.prepare("update tasks set status = 'done' where workspace_id = ?").run(wsA);
db.prepare("update tasks set status = 'done' where id = ?").run(taskId);
deleteWorkspace(db, orgId, wsA);
expect(db.prepare("select count(*) as n from workspaces where id = ?").get(wsA)).toEqual({ n: 0 });
expect(db.prepare("select workspace_id from tasks where id = ?").get(taskId)).toEqual({ workspace_id: null });
connection.close();
});
});