Q149: editing a task can change everything creating it can
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
766d4ab8cd
commit
7ab46bb2de
14 files changed
+150
-16
No files matched your search
@@ -41,7 +41,8 @@ export const organizationContracts = {
|
||||
}) satisfies z.ZodType<C.CreateTaskInput>,
|
||||
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<C.UpdateTaskInput>,
|
||||
deleteTask: z.strictObject({ orgId: id, taskId: id }) satisfies z.ZodType<C.DeleteTaskInput>,
|
||||
updateTaskStatus: z.strictObject({ orgId: id, taskId: id, status: z.enum(TASK_STATUS) }) satisfies z.ZodType<C.UpdateTaskStatusInput>,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 && <Button tone="primary" onClick={() => onAdvance(task)}><Icon name="check" size={16} /> {prettyStatus(advanceTo)}</Button>}
|
||||
</div>
|
||||
|
||||
{editing && <EditOverlay task={task} agents={agents} allTasks={allTasks} error={editError} onSubmit={submitEdit} onCancel={() => { setEditing(false); setEditError(null); }} />}
|
||||
{editing && (
|
||||
<EditOverlay
|
||||
task={task}
|
||||
agents={agents}
|
||||
allTasks={allTasks}
|
||||
projects={projects}
|
||||
goals={goals}
|
||||
workspaces={workspaces}
|
||||
error={editError}
|
||||
onSubmit={submitEdit}
|
||||
onCancel={() => { setEditing(false); setEditError(null); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -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<HTMLFormElement>) => void; onCancel: () => void;
|
||||
}) {
|
||||
const beneath = descendantIds(task.id, allTasks);
|
||||
@@ -287,6 +305,32 @@ function EditOverlay({ task, agents, allTasks, error, onSubmit, onCancel }: {
|
||||
{otherTasks.map((t) => <option key={t.id} value={t.id}>{t.title}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("Project")}>
|
||||
<select className="input" name="projectId" defaultValue={task.projectId ?? ""}>
|
||||
<option value="">{t("None")}</option>
|
||||
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("Goal")}>
|
||||
<select className="input" name="goalId" defaultValue={task.goalId ?? ""}>
|
||||
<option value="">{t("None")}</option>
|
||||
{goals.map((goal) => <option key={goal.id} value={goal.id}>{goal.title}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("Workspace")}>
|
||||
<select className="input" name="workspaceId" defaultValue={task.workspaceId ?? ""}>
|
||||
<option value="">{t("None")}</option>
|
||||
{workspaces.map((workspace) => <option key={workspace.id} value={workspace.id}>{workspace.name}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("Writing")}>
|
||||
<select className="input" name="writingMode" defaultValue={task.writingMode}>
|
||||
{WRITING_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" name="requiresFilesystem" defaultChecked={task.requiresFilesystem} /> {t("Requires workspace access")}
|
||||
</label>
|
||||
{error && <div className="form-error" role="alert">{error}</div>}
|
||||
<div className="modal-actions">
|
||||
<Button onClick={onCancel}>{t("Cancel")}</Button>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.`);
|
||||
|
||||
Reference in new issue
Block a user