Q166: workspaces can be edited, made primary and removed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
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(),
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user