Add goal, project, and workspace CRUD
Full-stack: IPC channels, command types, OrganizationService methods, IPC handlers, preload bridge, desktop-api types, and UI dialogs on the Work page. Users can now create goals and projects directly instead of relying on the onboarding bootstrap.
This commit is contained in:
1 parent
2ef721774f
commit
5f92cd0d75
9 files changed
+218
-1
No files matched your search
@@ -101,6 +101,9 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
return fail("task_run_failed", error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.createGoal, (_event, input) => mutate(() => organization.createGoal(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.createProject, (_event, input) => mutate(() => organization.createProject(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.createWorkspace, (_event, input) => mutate(() => organization.createWorkspace(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.createApproval, (_event, input) => mutate(() => approvals.create(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.decideApproval, (_event, input) => mutate(() => approvals.decide(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.syncEmployeeIdentity, (_event, input) => mutate(() => identityAccess.syncEmployeeIdentity(input)));
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { DatabaseSync } from "node:sqlite";
|
||||
import { APPROVAL_STATE, RISK_LEVEL, TASK_STATUS, type ApprovalState } from "../../shared/domain";
|
||||
import { PROVIDER_CATALOG } from "../../shared/provider-catalog";
|
||||
import { createId } from "../../shared/ids";
|
||||
import type { CreateAgentInput, CreateOrganizationInput, CreateTaskInput, UpdateAgentInput } from "../../shared/commands";
|
||||
import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, UpdateAgentInput } from "../../shared/commands";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { PolicyService } from "./policy-service";
|
||||
import { IdentityAccessService } from "./identity-access-service";
|
||||
@@ -382,6 +382,67 @@ export class OrganizationService {
|
||||
}
|
||||
}
|
||||
|
||||
createGoal(input: CreateGoalInput): string {
|
||||
const id = createId("gol");
|
||||
const now = new Date().toISOString();
|
||||
if (input.ownerAgentId) this.verifyOptionalOrgEntity("agents", input.ownerAgentId, input.orgId, "Owner agent");
|
||||
this.db.prepare(`
|
||||
insert into goals(id, org_id, title, description, status, owner_agent_id, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, input.orgId, input.title.trim(), input.description.trim(), "active", input.ownerAgentId, now, now);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "goal",
|
||||
entityId: id,
|
||||
actorType: "employee",
|
||||
actorId: null,
|
||||
action: "created",
|
||||
payload: { title: input.title.trim() },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
createProject(input: CreateProjectInput): string {
|
||||
const id = createId("prj");
|
||||
const now = new Date().toISOString();
|
||||
if (input.goalId) this.verifyOptionalOrgEntity("goals", input.goalId, input.orgId, "Goal");
|
||||
if (input.leadAgentId) this.verifyOptionalOrgEntity("agents", input.leadAgentId, input.orgId, "Lead agent");
|
||||
this.db.prepare(`
|
||||
insert into projects(id, org_id, goal_id, name, description, lead_agent_id, status, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, input.orgId, input.goalId, input.name.trim(), input.description.trim(), input.leadAgentId, "in_progress", now, now);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "project",
|
||||
entityId: id,
|
||||
actorType: "employee",
|
||||
actorId: null,
|
||||
action: "created",
|
||||
payload: { name: input.name.trim() },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
createWorkspace(input: CreateWorkspaceInput): string {
|
||||
const id = createId("wsp");
|
||||
const now = new Date().toISOString();
|
||||
if (input.projectId) this.verifyProject(input.projectId, null, input.orgId);
|
||||
this.db.prepare(`
|
||||
insert into workspaces(id, org_id, name, local_path, repo_url, repo_ref, project_id, is_primary, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, input.orgId, input.name.trim(), input.localPath.trim(), "", "", input.projectId, 0, now, now);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "workspace",
|
||||
entityId: id,
|
||||
actorType: "employee",
|
||||
actorId: null,
|
||||
action: "created",
|
||||
payload: { name: input.name.trim() },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
updateTaskStatus(orgId: string, taskId: string, status: string): void {
|
||||
const result = this.db.prepare("update tasks set status = ?, updated_at = ? where id = ? and org_id = ?")
|
||||
.run(status, new Date().toISOString(), taskId, orgId);
|
||||
|
||||
@@ -17,6 +17,9 @@ const api: DesktopApi = {
|
||||
createTask: (input) => invoke(IPC_CHANNELS.createTask, input),
|
||||
updateTaskStatus: (input) => invoke(IPC_CHANNELS.updateTaskStatus, input),
|
||||
runTask: (input) => invoke(IPC_CHANNELS.runTask, input),
|
||||
createGoal: (input) => invoke(IPC_CHANNELS.createGoal, input),
|
||||
createProject: (input) => invoke(IPC_CHANNELS.createProject, input),
|
||||
createWorkspace: (input) => invoke(IPC_CHANNELS.createWorkspace, input),
|
||||
createApproval: (input) => invoke(IPC_CHANNELS.createApproval, input),
|
||||
decideApproval: (input) => invoke(IPC_CHANNELS.decideApproval, input),
|
||||
syncEmployeeIdentity: (input) => invoke(IPC_CHANNELS.syncEmployeeIdentity, input),
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
type WorkspaceRecord,
|
||||
} from "../../../shared/domain";
|
||||
import { NewTaskDialog } from "./NewTaskDialog";
|
||||
import { GoalDialog } from "./work/GoalDialog";
|
||||
import { ProjectDialog } from "./work/ProjectDialog";
|
||||
import { TaskDetailDialog } from "./work/TaskDetailDialog";
|
||||
import { ProjectOverview } from "./work/ProjectOverview";
|
||||
import { canRun, latestTaskRun, nextStatus, prettyRunState, prettyStatus, providerFor, runTone } from "./work/task-helpers";
|
||||
@@ -41,6 +43,8 @@ export function WorkPage({ snapshot, actions }: PageProps) {
|
||||
const orgId = activeOrgId(snapshot);
|
||||
const [view, setView] = useState<View>("board");
|
||||
const [newTaskOpen, setNewTaskOpen] = useState(false);
|
||||
const [newGoalOpen, setNewGoalOpen] = useState(false);
|
||||
const [newProjectOpen, setNewProjectOpen] = useState(false);
|
||||
const [priorityFilter, setPriorityFilter] = useState<TaskPriority | "all">("all");
|
||||
const [assigneeFilter, setAssigneeFilter] = useState<string>("all");
|
||||
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
|
||||
@@ -88,6 +92,8 @@ export function WorkPage({ snapshot, actions }: PageProps) {
|
||||
onPriority={setPriorityFilter}
|
||||
onAssignee={setAssigneeFilter}
|
||||
/>
|
||||
<Button tone="ghost" onClick={() => setNewGoalOpen(true)}><Icon name="plus" size={13} /> Goal</Button>
|
||||
<Button tone="ghost" onClick={() => setNewProjectOpen(true)}><Icon name="plus" size={13} /> Project</Button>
|
||||
<Button tone="primary" onClick={() => setNewTaskOpen(true)}><Icon name="plus" size={13} /> New task</Button>
|
||||
</TopbarActions>
|
||||
<h1 className="page-h1">Work</h1>
|
||||
@@ -124,6 +130,8 @@ export function WorkPage({ snapshot, actions }: PageProps) {
|
||||
)}
|
||||
{view === "timeline" && <TimelineView tasks={tasks} goals={goals} />}
|
||||
|
||||
<GoalDialog open={newGoalOpen} orgId={orgId} agents={agents} actions={actions} onClose={() => setNewGoalOpen(false)} />
|
||||
<ProjectDialog open={newProjectOpen} orgId={orgId} goals={goals} agents={agents} actions={actions} onClose={() => setNewProjectOpen(false)} />
|
||||
<NewTaskDialog open={newTaskOpen} orgId={orgId} snapshot={snapshot} actions={actions} onClose={() => setNewTaskOpen(false)} />
|
||||
<TaskDetailDialog
|
||||
task={detailTask}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { type FormEvent } from "react";
|
||||
import { Field } from "../../shared/ui";
|
||||
import { Dialog } from "../../shared/Dialog";
|
||||
import type { AgentRecord, AppSnapshot } from "../../../../shared/domain";
|
||||
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||
|
||||
function text(form: FormData, key: string): string {
|
||||
return String(form.get(key) ?? "").trim();
|
||||
}
|
||||
|
||||
function optional(form: FormData, key: string): string | null {
|
||||
return text(form, key) || null;
|
||||
}
|
||||
|
||||
export function GoalDialog({
|
||||
open,
|
||||
orgId,
|
||||
agents,
|
||||
actions,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
orgId: string;
|
||||
agents: AgentRecord[];
|
||||
actions: SnapshotActions;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
void actions.run(() => window.agentCompany.createGoal({
|
||||
orgId,
|
||||
title: text(form, "title"),
|
||||
description: text(form, "description"),
|
||||
ownerAgentId: optional(form, "ownerAgentId"),
|
||||
})).then((result) => { if (result) onClose(); });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog title="New goal" open={open} submitLabel="Create" onClose={onClose} onSubmit={submit}>
|
||||
<Field label="Title"><input className="input" name="title" required /></Field>
|
||||
<Field label="Description"><textarea className="input" name="description" required /></Field>
|
||||
<Field label="Owner agent">
|
||||
<select className="input" name="ownerAgentId" defaultValue="">
|
||||
<option value="">Unassigned</option>
|
||||
{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name} · {agent.role}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { type FormEvent } from "react";
|
||||
import { Field } from "../../shared/ui";
|
||||
import { Dialog } from "../../shared/Dialog";
|
||||
import type { AgentRecord, GoalRecord } from "../../../../shared/domain";
|
||||
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||
|
||||
function text(form: FormData, key: string): string {
|
||||
return String(form.get(key) ?? "").trim();
|
||||
}
|
||||
|
||||
function optional(form: FormData, key: string): string | null {
|
||||
return text(form, key) || null;
|
||||
}
|
||||
|
||||
export function ProjectDialog({
|
||||
open,
|
||||
orgId,
|
||||
goals,
|
||||
agents,
|
||||
actions,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
orgId: string;
|
||||
goals: GoalRecord[];
|
||||
agents: AgentRecord[];
|
||||
actions: SnapshotActions;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
void actions.run(() => window.agentCompany.createProject({
|
||||
orgId,
|
||||
goalId: optional(form, "goalId"),
|
||||
name: text(form, "name"),
|
||||
description: text(form, "description"),
|
||||
leadAgentId: optional(form, "leadAgentId"),
|
||||
})).then((result) => { if (result) onClose(); });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog title="New project" open={open} submitLabel="Create" onClose={onClose} onSubmit={submit}>
|
||||
<Field label="Name"><input className="input" name="name" required /></Field>
|
||||
<Field label="Description"><textarea className="input" name="description" required /></Field>
|
||||
<Field label="Goal">
|
||||
<select className="input" name="goalId" defaultValue="">
|
||||
<option value="">None</option>
|
||||
{goals.map((goal) => <option key={goal.id} value={goal.id}>{goal.title}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Lead agent">
|
||||
<select className="input" name="leadAgentId" defaultValue="">
|
||||
<option value="">Unassigned</option>
|
||||
{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name} · {agent.role}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -88,6 +88,28 @@ export interface RunTaskInput {
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
export interface CreateGoalInput {
|
||||
orgId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
ownerAgentId: string | null;
|
||||
}
|
||||
|
||||
export interface CreateProjectInput {
|
||||
orgId: string;
|
||||
goalId: string | null;
|
||||
name: string;
|
||||
description: string;
|
||||
leadAgentId: string | null;
|
||||
}
|
||||
|
||||
export interface CreateWorkspaceInput {
|
||||
orgId: string;
|
||||
name: string;
|
||||
localPath: string;
|
||||
projectId: string | null;
|
||||
}
|
||||
|
||||
export interface CreateApprovalInput {
|
||||
orgId: string;
|
||||
kind: string;
|
||||
|
||||
@@ -9,6 +9,9 @@ import type {
|
||||
CreateAgentInput,
|
||||
UpdateAgentInput,
|
||||
CreateApprovalInput,
|
||||
CreateGoalInput,
|
||||
CreateProjectInput,
|
||||
CreateWorkspaceInput,
|
||||
CreateAuditReplayInput,
|
||||
CreateChatSpaceInput,
|
||||
CreateComplianceExportInput,
|
||||
@@ -90,6 +93,9 @@ export interface DesktopApi {
|
||||
createTask(input: CreateTaskInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
updateTaskStatus(input: UpdateTaskStatusInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
runTask(input: RunTaskInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createGoal(input: CreateGoalInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createProject(input: CreateProjectInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createWorkspace(input: CreateWorkspaceInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createApproval(input: CreateApprovalInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
decideApproval(input: DecideApprovalInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
syncEmployeeIdentity(input: SyncEmployeeIdentityInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
|
||||
@@ -57,6 +57,9 @@ export const IPC_CHANNELS = {
|
||||
setLegalHoldState: "agentcompany:set-legal-hold-state",
|
||||
createRetentionFreeze: "agentcompany:create-retention-freeze",
|
||||
setRetentionFreezeState: "agentcompany:set-retention-freeze-state",
|
||||
createGoal: "agentcompany:create-goal",
|
||||
createProject: "agentcompany:create-project",
|
||||
createWorkspace: "agentcompany:create-workspace",
|
||||
search: "agentcompany:search",
|
||||
rendererEvent: "agentcompany:renderer-event",
|
||||
windowMinimize: "agentcompany:window-minimize",
|
||||
|
||||
Reference in new issue
Block a user