diff --git a/src/main/app/ipc-contracts/organization.ts b/src/main/app/ipc-contracts/organization.ts index d3e53f43..ed1001d1 100644 --- a/src/main/app/ipc-contracts/organization.ts +++ b/src/main/app/ipc-contracts/organization.ts @@ -41,7 +41,8 @@ export const organizationContracts = { }) satisfies z.ZodType, updateTask: z.strictObject({ orgId: id, taskId: id, title: z.string(), description: z.string(), assigneeAgentId: nullableId, risk: z.enum(RISK_LEVEL), - priority: z.enum(TASK_PRIORITY), parentTaskId: nullableId, + priority: z.enum(TASK_PRIORITY), parentTaskId: nullableId, projectId: nullableId, goalId: nullableId, workspaceId: nullableId, + requiresFilesystem: z.boolean(), writingMode: z.enum(WRITING_MODE), }) satisfies z.ZodType, deleteTask: z.strictObject({ orgId: id, taskId: id }) satisfies z.ZodType, updateTaskStatus: z.strictObject({ orgId: id, taskId: id, status: z.enum(TASK_STATUS) }) satisfies z.ZodType, diff --git a/src/main/services/organization-service.ts b/src/main/services/organization-service.ts index 8bb41525..369f4d2d 100644 --- a/src/main/services/organization-service.ts +++ b/src/main/services/organization-service.ts @@ -466,6 +466,9 @@ export class OrganizationService { } updateTask(input: UpdateTaskInput): void { + this.verifyOptionalOrgEntity("goals", input.goalId, input.orgId, "Goal"); + this.verifyOptionalOrgEntity("workspaces", input.workspaceId, input.orgId, "Workspace"); + this.verifyProject(input.projectId, input.goalId, input.orgId); this.tasks.update(input); } diff --git a/src/main/services/task-mutations.ts b/src/main/services/task-mutations.ts index c0a622e1..4b28268b 100644 --- a/src/main/services/task-mutations.ts +++ b/src/main/services/task-mutations.ts @@ -49,10 +49,14 @@ export class TaskMutations { this.applyStatus(input.orgId, before, TASK_STATUS.IN_PROGRESS, { type: "system" }, null); } const result = this.db.prepare(` - update tasks set title = ?, description = ?, assignee_agent_id = ?, risk = ?, priority = ?, parent_task_id = ?, updated_at = ?, + update tasks set title = ?, description = ?, assignee_agent_id = ?, risk = ?, priority = ?, parent_task_id = ?, + project_id = ?, goal_id = ?, workspace_id = ?, requires_filesystem = ?, writing_mode = ?, updated_at = ?, stalled_reason = null, enqueue_error = null, run_budget_floor = (select coalesce(max(rowid), 0) from task_runs) where id = ? and org_id = ? - `).run(input.title.trim(), input.description.trim(), input.assigneeAgentId, input.risk, input.priority, input.parentTaskId, now, input.taskId, input.orgId); + `).run( + input.title.trim(), input.description.trim(), input.assigneeAgentId, input.risk, input.priority, input.parentTaskId, + input.projectId, input.goalId, input.workspaceId, input.requiresFilesystem ? 1 : 0, input.writingMode, now, input.taskId, input.orgId, + ); if (result.changes === 0) throw new Error("Task was not found."); this.requestApproval(input.orgId, input.taskId); this.audit.record({ diff --git a/src/renderer/src/pages/NewTaskDialog.tsx b/src/renderer/src/pages/NewTaskDialog.tsx index aaece3d5..25109521 100644 --- a/src/renderer/src/pages/NewTaskDialog.tsx +++ b/src/renderer/src/pages/NewTaskDialog.tsx @@ -5,6 +5,7 @@ import type { AppSnapshot, RiskLevel, TaskPriority, WritingMode } from "../../.. import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS, WRITING_MODE } from "../../../shared/domain"; import type { SnapshotActions } from "../app/useSnapshot"; import { t } from "../i18n/i18n"; +import { WRITING_OPTIONS } from "./work/task-helpers"; const RISK_OPTIONS: ReadonlyArray<{ value: RiskLevel; label: string }> = [ { value: RISK_LEVEL.LOW, label: t("Low") }, @@ -18,12 +19,6 @@ const PRIORITY_OPTIONS: ReadonlyArray<{ value: TaskPriority; label: string }> = { value: TASK_PRIORITY.HIGH, label: t("High") }, ]; -const WRITING_OPTIONS: ReadonlyArray<{ value: WritingMode; label: string }> = [ - { value: WRITING_MODE.CONVERSATIONAL, label: t("Conversational (house style)") }, - { value: WRITING_MODE.TECHNICAL, label: t("Technical spec") }, - { value: WRITING_MODE.STRUCTURED, label: t("Structured output") }, -]; - function value(form: FormData, key: string): string { return String(form.get(key) ?? "").trim(); } diff --git a/src/renderer/src/pages/work/TaskDetailDialog.tsx b/src/renderer/src/pages/work/TaskDetailDialog.tsx index 72891e28..8251054c 100644 --- a/src/renderer/src/pages/work/TaskDetailDialog.tsx +++ b/src/renderer/src/pages/work/TaskDetailDialog.tsx @@ -12,9 +12,9 @@ import type { TaskRunRecord, WorkspaceRecord, } from "../../../../shared/domain"; -import { TASK_STATUS } from "../../../../shared/domain"; +import { TASK_STATUS, type WritingMode } from "../../../../shared/domain"; import { auditEventLabel, formatRelative } from "../types"; -import { descendantIds, nextStatus, prettyRunState, prettyStatus, providerFor, runBlocker, runsForTask, runTone } from "./task-helpers"; +import { WRITING_OPTIONS, descendantIds, nextStatus, prettyRunState, prettyStatus, providerFor, runBlocker, runsForTask, runTone } from "./task-helpers"; import type { SnapshotActions } from "../../app/useSnapshot"; import type { Focus, PageProps } from "../types"; import { t } from "../../i18n/i18n"; @@ -95,6 +95,11 @@ export function TaskDetailDialog({ risk: (val("risk") || "low") as "low" | "medium" | "high", priority: (val("priority") || "normal") as "low" | "normal" | "high", parentTaskId: val("parentTaskId") || null, + projectId: val("projectId") || null, + goalId: val("goalId") || null, + workspaceId: val("workspaceId") || null, + requiresFilesystem: form.get("requiresFilesystem") === "on", + writingMode: val("writingMode") as WritingMode, })).then((r) => { if (r) setEditing(false); }, (error: unknown) => setEditError(error instanceof Error ? error.message : String(error))); }; @@ -149,7 +154,19 @@ export function TaskDetailDialog({ {advanceTo && } - {editing && { setEditing(false); setEditError(null); }} />} + {editing && ( + { setEditing(false); setEditError(null); }} + /> + )} ); @@ -251,8 +268,9 @@ function ActivityTab({ audit, approvals, onOpenApproval }: { audit: AuditEventRe ); } -function EditOverlay({ task, agents, allTasks, error, onSubmit, onCancel }: { - task: TaskRecord; agents: AgentRecord[]; allTasks: TaskRecord[]; error: string | null; +function EditOverlay({ task, agents, allTasks, projects, goals, workspaces, error, onSubmit, onCancel }: { + task: TaskRecord; agents: AgentRecord[]; allTasks: TaskRecord[]; + projects: ProjectRecord[]; goals: GoalRecord[]; workspaces: WorkspaceRecord[]; error: string | null; onSubmit: (e: FormEvent) => void; onCancel: () => void; }) { const beneath = descendantIds(task.id, allTasks); @@ -287,6 +305,32 @@ function EditOverlay({ task, agents, allTasks, error, onSubmit, onCancel }: { {otherTasks.map((t) => )} + + + + + + + + + + + + + {error &&
{error}
}
diff --git a/src/renderer/src/pages/work/task-helpers.ts b/src/renderer/src/pages/work/task-helpers.ts index 21c5fd1e..6c2a5ca2 100644 --- a/src/renderer/src/pages/work/task-helpers.ts +++ b/src/renderer/src/pages/work/task-helpers.ts @@ -1,13 +1,23 @@ import { TASK_STATUS, + WRITING_MODE, type AgentRecord, type ProviderRecord, type TaskRecord, type TaskRunRecord, type TaskStatus, + type WritingMode, } from "../../../../shared/domain"; import { t } from "../../i18n/i18n"; +/** The writing modes a task can ask for, as the create and edit forms offer them. */ +export const WRITING_OPTIONS: ReadonlyArray<{ value: WritingMode; label: string }> = [ + { value: WRITING_MODE.CONVERSATIONAL, label: t("Conversational (house style)") }, + { value: WRITING_MODE.TECHNICAL, label: t("Technical spec") }, + { value: WRITING_MODE.STRUCTURED, label: t("Structured output") }, +]; + + export function nextStatus(status: TaskStatus): TaskStatus | null { if (status === TASK_STATUS.BACKLOG) return TASK_STATUS.IN_PROGRESS; if (status === TASK_STATUS.IN_PROGRESS) return TASK_STATUS.REVIEW; diff --git a/src/shared/commands/organization.ts b/src/shared/commands/organization.ts index 9efbddd0..ea74129e 100644 --- a/src/shared/commands/organization.ts +++ b/src/shared/commands/organization.ts @@ -116,6 +116,11 @@ export interface UpdateTaskInput { risk: RiskLevel; priority: TaskPriority; parentTaskId: string | null; + projectId: string | null; + goalId: string | null; + workspaceId: string | null; + requiresFilesystem: boolean; + writingMode: WritingMode; } export interface DeleteTaskInput { diff --git a/tests/e2e/ceo-chat.spec.ts b/tests/e2e/ceo-chat.spec.ts index 86b11d41..cd6e440f 100644 --- a/tests/e2e/ceo-chat.spec.ts +++ b/tests/e2e/ceo-chat.spec.ts @@ -83,6 +83,18 @@ test("the pinned CEO conversation is the Messages front door", async () => { await expect(page.getByRole("button", { name: "Open task Operationalize goal: Ship the FlowPilot MVP." })).toBeVisible(); await page.getByRole("button", { name: "Open task Operationalize goal: Ship the FlowPilot MVP." }).click(); await expect(taskDialog).toBeVisible(); + // A wrong link is corrected in the same task's editor, as at creation. + await taskDialog.getByRole("button", { name: "Edit" }).click(); + await taskDialog.getByLabel("Writing").selectOption("technical"); + await taskDialog.getByLabel("Requires workspace access").check(); + await taskDialog.getByRole("button", { name: "Save" }).click(); + await expect(taskDialog.getByRole("heading", { name: "Edit task" })).toHaveCount(0); + const edited = await page.evaluate(async () => { + const api = (window as unknown as { agentCompany: { loadSnapshot: () => Promise<{ ok: true; data: { tasks: Array<{ title: string; writingMode: string; requiresFilesystem: boolean }> } }> } }).agentCompany; + const task = (await api.loadSnapshot()).data.tasks.find((item) => item.title === "Operationalize goal: Ship the FlowPilot MVP.")!; + return [task.writingMode, task.requiresFilesystem]; + }); + expect(edited).toEqual(["technical", true]); await taskDialog.getByRole("button", { name: "Close" }).click(); await page.getByRole("button", { name: "Messages" }).click(); diff --git a/tests/helpers/task-links.ts b/tests/helpers/task-links.ts new file mode 100644 index 00000000..f2b92e5a --- /dev/null +++ b/tests/helpers/task-links.ts @@ -0,0 +1,10 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { WritingMode } from "../../src/shared/domain"; + +/** A task's current links, for edits in tests that change other fields and keep these. */ +export function currentLinks(db: DatabaseSync, taskId: string) { + const row = db.prepare("select project_id, goal_id, workspace_id, requires_filesystem, writing_mode from tasks where id = ?").get(taskId) as { + project_id: string | null; goal_id: string | null; workspace_id: string | null; requires_filesystem: number; writing_mode: WritingMode; + }; + return { projectId: row.project_id, goalId: row.goal_id, workspaceId: row.workspace_id, requiresFilesystem: row.requires_filesystem === 1, writingMode: row.writing_mode }; +} diff --git a/tests/unit/execution-modes.test.ts b/tests/unit/execution-modes.test.ts index 0369d2f1..679d5dee 100644 --- a/tests/unit/execution-modes.test.ts +++ b/tests/unit/execution-modes.test.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { currentLinks } from "../helpers/task-links"; import { describe, expect, test } from "vitest"; import { DatabaseConnection } from "../../src/main/database/connection"; import { OrganizationService } from "../../src/main/services/organization-service"; @@ -302,6 +303,7 @@ describe("execution modes", () => { const taskApprovals = () => db.prepare("select id, state, details from approvals where source_entity_id = ? order by rowid").all(taskId) as Array<{ id: string; state: string; details: string }>; const edit = (description: string) => h.connection.transaction(() => h.deps.organization.updateTask({ orgId: h.orgId, taskId, title: "Wipe staging", description, assigneeAgentId: h.ceoId, risk: "high", priority: TASK_PRIORITY.NORMAL, parentTaskId: null, + ...currentLinks(db, taskId), })); const decide = (id: string) => h.connection.transaction(() => approvals.decide({ orgId: h.orgId, approvalId: id, state: "approved", decidedByEmployeeId: employeeId })); diff --git a/tests/unit/task-edit-links.test.ts b/tests/unit/task-edit-links.test.ts new file mode 100644 index 00000000..46b8da36 --- /dev/null +++ b/tests/unit/task-edit-links.test.ts @@ -0,0 +1,44 @@ +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 { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS, WRITING_MODE } from "../../src/shared/domain"; + +describe("editing a task", () => { + test("can correct its project, goal, workspace, access and writing mode like creating it can", () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-task-edit-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + const ids: Record = {}; + connection.transaction(() => { + ids.org = organization.createOrganization({ organizationName: "Acme", employeeName: "Yang", mission: "m", goal: "g" }); + ids.goalA = organization.createGoal({ orgId: ids.org, title: "Goal A", description: "d", ownerAgentId: null }); + ids.goalB = organization.createGoal({ orgId: ids.org, title: "Goal B", description: "d", ownerAgentId: null }); + ids.projectA = organization.createProject({ orgId: ids.org, goalId: ids.goalA, name: "Project A", description: "d", leadAgentId: null }); + ids.projectB = organization.createProject({ orgId: ids.org, goalId: ids.goalB, name: "Project B", description: "d", leadAgentId: null }); + for (const name of ["wsA", "wsB"]) { + const path = join(tmpdir(), `ac-task-edit-${name}-${randomUUID()}`); + mkdirSync(path, { recursive: true }); + ids[name] = organization.createWorkspace({ orgId: ids.org, name, localPath: path, projectId: null }); + } + ids.task = organization.createTask({ + orgId: ids.org, title: "Ship", description: "d", status: TASK_STATUS.BACKLOG, assigneeAgentId: null, risk: RISK_LEVEL.LOW, + projectId: ids.projectA, goalId: ids.goalA, workspaceId: ids.wsA, priority: TASK_PRIORITY.NORMAL, requiresFilesystem: false, + }); + }); + const edit = (change: { projectId: string | null; goalId: string | null; workspaceId: string | null }) => connection.transaction(() => organization.updateTask({ + orgId: ids.org, taskId: ids.task, title: "Ship", description: "d", assigneeAgentId: null, risk: RISK_LEVEL.LOW, priority: TASK_PRIORITY.NORMAL, + parentTaskId: null, requiresFilesystem: true, writingMode: WRITING_MODE.TECHNICAL, ...change, + })); + + edit({ projectId: ids.projectB, goalId: ids.goalB, workspaceId: ids.wsB }); + expect(connection.db.prepare("select project_id, goal_id, workspace_id, requires_filesystem, writing_mode from tasks where id = ?").get(ids.task)) + .toEqual({ project_id: ids.projectB, goal_id: ids.goalB, workspace_id: ids.wsB, requires_filesystem: 1, writing_mode: "technical" }); + expect(() => edit({ projectId: ids.projectA, goalId: ids.goalB, workspaceId: ids.wsB })).toThrow("Project is linked to a different goal."); + expect(() => edit({ projectId: null, goalId: null, workspaceId: "wsp_missing" })).toThrow("Workspace was not found."); + expect(() => edit({ projectId: null, goalId: "gol_missing", workspaceId: null })).toThrow("Goal was not found."); + connection.close(); + }); +}); diff --git a/tests/unit/task-execution-service.test.ts b/tests/unit/task-execution-service.test.ts index 611b218d..9c4d7d2a 100644 --- a/tests/unit/task-execution-service.test.ts +++ b/tests/unit/task-execution-service.test.ts @@ -2,6 +2,7 @@ import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { currentLinks } from "../helpers/task-links"; import { describe, expect, test } from "vitest"; import { DatabaseConnection } from "../../src/main/database/connection"; import { ApprovalService } from "../../src/main/services/approval-service"; @@ -733,7 +734,7 @@ describe("task execution service", () => { expect(connection.db.prepare("select count(*) as n from audit_events where entity_id = ? and action = 'stalled'").get(taskId)).toEqual({ n: 1 }); expect(notified).toBe(1); - organization.updateTask({ orgId, taskId, title: "Ship it", description: "Narrower scope.", assigneeAgentId: agentId, risk: RISK_LEVEL.LOW, priority: TASK_PRIORITY.NORMAL, parentTaskId: null }); + organization.updateTask({ orgId, taskId, title: "Ship it", description: "Narrower scope.", assigneeAgentId: agentId, risk: RISK_LEVEL.LOW, priority: TASK_PRIORITY.NORMAL, parentTaskId: null, ...currentLinks(connection.db, taskId) }); expect(connection.db.prepare("select stalled_reason from tasks where id = ?").get(taskId)).toEqual({ stalled_reason: null }); expect(connection.transaction(() => execution.enqueueHeartbeatRuns())).toHaveLength(1); diff --git a/tests/unit/task-hierarchy.test.ts b/tests/unit/task-hierarchy.test.ts index 639809d6..f6ed0b84 100644 --- a/tests/unit/task-hierarchy.test.ts +++ b/tests/unit/task-hierarchy.test.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { currentLinks } from "../helpers/task-links"; import { describe, expect, test } from "vitest"; import { DatabaseConnection } from "../../src/main/database/connection"; import { OrganizationService } from "../../src/main/services/organization-service"; @@ -23,6 +24,7 @@ describe("task hierarchy", () => { }); const reparent = (title: string, parent: string | null) => organization.updateTask({ orgId, taskId: ids[title], title, description: "d", assigneeAgentId: null, risk: RISK_LEVEL.LOW, priority: TASK_PRIORITY.NORMAL, parentTaskId: parent, + ...currentLinks(connection.db, ids[title]), }); reparent("B", ids.A); reparent("C", ids.B); diff --git a/tests/unit/task-status-authority.test.ts b/tests/unit/task-status-authority.test.ts index 6813d95b..1d8607df 100644 --- a/tests/unit/task-status-authority.test.ts +++ b/tests/unit/task-status-authority.test.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { currentLinks } from "../helpers/task-links"; import { describe, expect, test } from "vitest"; import { DatabaseConnection } from "../../src/main/database/connection"; import { OrganizationService } from "../../src/main/services/organization-service"; @@ -57,7 +58,7 @@ describe("task status authority", () => { expect(h.as("dev", { status: "backlog", evidence: 123 })).toBe('ERROR: "evidence" is required.'); h.as("dev", { status: "done", evidence: "file_id=fil_1" }); const reviewId = (h.connection.db.prepare("select id from tasks where review_of_task_id = ?").get(h.ids.task) as { id: string }).id; - h.organization.updateTask({ orgId: h.orgId, taskId: h.ids.task, title: "Login page", description: "Build it.", assigneeAgentId: h.ids.cto, risk: RISK_LEVEL.LOW, priority: TASK_PRIORITY.NORMAL, parentTaskId: null }); + h.organization.updateTask({ orgId: h.orgId, taskId: h.ids.task, title: "Login page", description: "Build it.", assigneeAgentId: h.ids.cto, risk: RISK_LEVEL.LOW, priority: TASK_PRIORITY.NORMAL, parentTaskId: null, ...currentLinks(h.connection.db, h.ids.task) }); expect(h.status()).toBe("in_progress"); expect(h.connection.db.prepare("select status from tasks where id = ?").get(reviewId)).toEqual({ status: "done" }); expect(h.as("cto", { status: "done", evidence: "Rebuilt the form." })).toBe(`Task ${h.ids.task} moved to review: done needs sign-off from your manager. Cleo will review it.`);