From e8722cd815fbe7e4d253a00495aa5c4bdc99e246 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 03:32:03 -0400 Subject: [PATCH] Q166: workspaces can be edited, made primary and removed Co-Authored-By: Claude Opus 5.5 --- src/main/app/ipc-contracts/organization.ts | 3 + src/main/app/ipc-handlers.ts | 4 ++ src/main/services/workspace-admin.ts | 65 +++++++++++++++++ src/preload/index.ts | 3 + src/renderer/src/i18n/zh.ts | 4 ++ .../src/pages/settings/WorkspacePanel.tsx | 11 +-- .../src/pages/settings/WorkspaceRow.tsx | 71 +++++++++++++++++++ src/shared/commands/organization.ts | 8 +++ src/shared/desktop-api.ts | 4 ++ src/shared/ipc.ts | 3 + tests/e2e/ceo-chat.spec.ts | 19 +++++ tests/unit/workspace-admin.test.ts | 61 ++++++++++++++++ 12 files changed, 247 insertions(+), 9 deletions(-) create mode 100644 src/main/services/workspace-admin.ts create mode 100644 src/renderer/src/pages/settings/WorkspaceRow.tsx create mode 100644 tests/unit/workspace-admin.test.ts diff --git a/src/main/app/ipc-contracts/organization.ts b/src/main/app/ipc-contracts/organization.ts index 89b6dd46..5fa0b312 100644 --- a/src/main/app/ipc-contracts/organization.ts +++ b/src/main/app/ipc-contracts/organization.ts @@ -56,6 +56,9 @@ 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, + updateWorkspace: z.strictObject({ orgId: id, workspaceId: id, name: z.string(), localPath: z.string(), projectId: nullableId }) satisfies z.ZodType, + 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(), diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index f7c86e37..40961d96 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -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))); diff --git a/src/main/services/workspace-admin.ts b/src/main/services/workspace-admin.ts new file mode 100644 index 00000000..eee81582 --- /dev/null +++ b/src/main/services/workspace-admin.ts @@ -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`); + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 12ad2b26..05081d85 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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), diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index ff2b8134..c5cd82b7 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -55,6 +55,10 @@ export const zh: Record = { "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 联合创始人:高风险任务先征求同意", diff --git a/src/renderer/src/pages/settings/WorkspacePanel.tsx b/src/renderer/src/pages/settings/WorkspacePanel.tsx index 11ce99a6..8e2288bf 100644 --- a/src/renderer/src/pages/settings/WorkspacePanel.tsx +++ b/src/renderer/src/pages/settings/WorkspacePanel.tsx @@ -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({ {workspaces.map((workspace) => ( -
-
-
{workspace.name}
-
{workspace.localPath}
-
-
- {workspace.isPrimary && {t("Primary")}} -
-
+ ))} setCreateOpen(false)} onSubmit={submit}> diff --git a/src/renderer/src/pages/settings/WorkspaceRow.tsx b/src/renderer/src/pages/settings/WorkspaceRow.tsx new file mode 100644 index 00000000..8dcd805b --- /dev/null +++ b/src/renderer/src/pages/settings/WorkspaceRow.tsx @@ -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(null); + const act = (operation: () => Promise, 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) => { + 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(`input[data-workspace-path="${workspace.id}"]`); + if (result.ok && result.data && input) input.value = result.data; + }); + }; + return ( +
+
+
+
{workspace.name}
+
{workspace.localPath}
+
+ {workspace.isPrimary + ? {t("Primary")} + : } + setEditing(true)} /> + +
+ {confirmRemove && ( +
+ {t("Remove this workspace? Its folder stays on disk.")} + + +
+ )} + {error &&
{error}
} + setEditing(false)} onSubmit={submit}> + + +
+ + +
+
+ + + + {error &&
{error}
} +
+
+ ); +} diff --git a/src/shared/commands/organization.ts b/src/shared/commands/organization.ts index ab6921dd..98a0b41a 100644 --- a/src/shared/commands/organization.ts +++ b/src/shared/commands/organization.ts @@ -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; diff --git a/src/shared/desktop-api.ts b/src/shared/desktop-api.ts index d0a2f17b..e051da48 100644 --- a/src/shared/desktop-api.ts +++ b/src/shared/desktop-api.ts @@ -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>; updateOrganization(input: UpdateOrganizationInput): Promise>; + updateWorkspace(input: UpdateWorkspaceInput): Promise>; + setPrimaryWorkspace(input: { orgId: string; workspaceId: string }): Promise>; + deleteWorkspace(input: { orgId: string; workspaceId: string }): Promise>; cancelQueuedRun(input: { orgId: string; runId: string }): Promise>; /** 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>; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index b0067016..cba36648 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -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", diff --git a/tests/e2e/ceo-chat.spec.ts b/tests/e2e/ceo-chat.spec.ts index fcf6b174..05eff119 100644 --- a/tests/e2e/ceo-chat.spec.ts +++ b/tests/e2e/ceo-chat.spec.ts @@ -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(); diff --git a/tests/unit/workspace-admin.test.ts b/tests/unit/workspace-admin.test.ts new file mode 100644 index 00000000..03abd5b5 --- /dev/null +++ b/tests/unit/workspace-admin.test.ts @@ -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(); + }); +});