Q151: deleting a task first shows what goes with it, then waits for the result
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
809c67a54a
commit
18f1e1fcc3
13 files changed
+171
-5
No files matched your search
@@ -46,6 +46,7 @@ export const organizationContracts = {
|
||||
}) satisfies z.ZodType<C.UpdateTaskInput>,
|
||||
deleteTask: z.strictObject({ orgId: id, taskId: id }) satisfies z.ZodType<C.DeleteTaskInput>,
|
||||
setTaskBlocked: z.strictObject({ orgId: id, taskId: id, reason: z.string().nullable() }) satisfies z.ZodType<C.SetTaskBlockedInput>,
|
||||
taskDeleteImpact: z.strictObject({ orgId: id, taskId: id }),
|
||||
updateTaskStatus: z.strictObject({ orgId: id, taskId: id, status: z.enum(TASK_STATUS) }) satisfies z.ZodType<C.UpdateTaskStatusInput>,
|
||||
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 }) satisfies z.ZodType<C.CreateGoalInput>,
|
||||
|
||||
@@ -165,6 +165,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
handle("createTask", (input) => mutate(() => organization.createTask(input)));
|
||||
handle("updateTask", (input) => mutate(() => organization.updateTask(input)));
|
||||
handle("setTaskBlocked", (input) => mutate(() => organization.setTaskBlocked(input)));
|
||||
handle("taskDeleteImpact", (input) => ok(organization.taskDeleteImpact(input.orgId, input.taskId)));
|
||||
handle("deleteTask", (input) => mutate(() => organization.deleteTask(input)));
|
||||
handle("updateTaskStatus", (input) => mutate(() => organization.updateTaskStatus(input.orgId, input.taskId, input.status)));
|
||||
handle("runTask", async (input) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TaskDeleteImpact } from "../../shared/domain";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { APPROVAL_STATE, RISK_LEVEL, TASK_STATUS, WRITING_MODE, type RiskLevel } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
@@ -472,6 +473,10 @@ export class OrganizationService {
|
||||
this.tasks.update(input);
|
||||
}
|
||||
|
||||
taskDeleteImpact(orgId: string, taskId: string): TaskDeleteImpact {
|
||||
return this.tasks.deleteImpact(orgId, taskId);
|
||||
}
|
||||
|
||||
setTaskBlocked(input: SetTaskBlockedInput): void {
|
||||
this.tasks.setBlocked(input);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TaskDeleteImpact } from "../../shared/domain";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { RISK_LEVEL, TASK_STATUS, type TaskPriority } from "../../shared/domain";
|
||||
import type { CreateTaskInput, DeleteTaskInput, SetTaskBlockedInput, UpdateTaskInput } from "../../shared/commands";
|
||||
@@ -70,6 +71,20 @@ export class TaskMutations {
|
||||
});
|
||||
}
|
||||
|
||||
/** What deleting the task would remove, keep and close, counted from the database. */
|
||||
deleteImpact(orgId: string, taskId: string): TaskDeleteImpact {
|
||||
this.requireTask(orgId, taskId);
|
||||
const count = (sql: string) => (this.db.prepare(sql).get(taskId, orgId) as { n: number }).n;
|
||||
return {
|
||||
runs: count("select count(*) as n from task_runs where task_id = ? and org_id = ?"),
|
||||
running: count("select count(*) as n from task_runs where task_id = ? and org_id = ? and state = 'running'"),
|
||||
subtasks: count("select count(*) as n from tasks where parent_task_id = ? and org_id = ?"),
|
||||
documents: count("select count(*) as n from documents where task_id = ? and org_id = ?"),
|
||||
openReviews: count("select count(*) as n from tasks where review_of_task_id = ? and org_id = ? and status <> 'done'"),
|
||||
pendingApprovals: count("select count(*) as n from approvals where source_entity_type = 'task' and source_entity_id = ? and org_id = ? and state = 'pending'"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Blocks the task with a reason (no session starts, queued ones are superseded) or unblocks it with null. */
|
||||
setBlocked(input: SetTaskBlockedInput): void {
|
||||
const reason = input.reason === null ? null : input.reason.trim();
|
||||
|
||||
@@ -91,6 +91,7 @@ const api: DesktopApi = {
|
||||
chatThreadHistory: (input) => invoke(IPC_CHANNELS.chatThreadHistory, input),
|
||||
chatSpaceHistory: (input) => invoke(IPC_CHANNELS.chatSpaceHistory, input),
|
||||
setTaskBlocked: (input) => invoke(IPC_CHANNELS.setTaskBlocked, input),
|
||||
taskDeleteImpact: (input) => invoke(IPC_CHANNELS.taskDeleteImpact, input),
|
||||
revokeConnector: (input) => invoke(IPC_CHANNELS.revokeConnector, input),
|
||||
refreshComposioAccounts: (input) => invoke(IPC_CHANNELS.refreshComposioAccounts, input),
|
||||
search: (input) => invoke(IPC_CHANNELS.search, input),
|
||||
|
||||
@@ -39,6 +39,15 @@ export const zh: Record<string, string> = {
|
||||
"Send back": "退回",
|
||||
"Reopen": "重新打开",
|
||||
"Back to backlog": "退回待办",
|
||||
"Delete this task?": "删除这个任务?",
|
||||
"Delete task": "删除任务",
|
||||
"Nothing else is affected.": "不影响其他内容。",
|
||||
"{n} sessions and their action records are deleted.": "{n} 次会话及其操作记录将被删除。",
|
||||
"{n} subtasks stay and move to the top level.": "{n} 个子任务保留,并移到顶层。",
|
||||
"{n} documents stay, no longer linked to this task.": "{n} 份文档保留,不再关联此任务。",
|
||||
"{n} open reviews of it are closed.": "{n} 个进行中的审查将被关闭。",
|
||||
"{n} pending approvals for it stay pending.": "{n} 个待处理审批仍保持待处理。",
|
||||
"A session is running; stop it or wait for it before deleting.": "有会话正在运行;请先停止或等待其结束再删除。",
|
||||
"needs you": "待你处理",
|
||||
"waiting": "等待中",
|
||||
"Resumes on its own at {time}.": "将于 {time} 自动恢复。",
|
||||
|
||||
@@ -161,6 +161,7 @@ export function WorkPage({ snapshot, actions, navigate, focus }: PageProps) {
|
||||
<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
|
||||
key={detailTask?.id ?? "none"}
|
||||
task={detailTask}
|
||||
agents={agents}
|
||||
projects={projects}
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
TaskRunRecord,
|
||||
WorkspaceRecord,
|
||||
} from "../../../../shared/domain";
|
||||
import { TASK_STATUS, type TaskStatus, type WritingMode } from "../../../../shared/domain";
|
||||
import { TASK_STATUS, type TaskDeleteImpact, type TaskStatus, type WritingMode } from "../../../../shared/domain";
|
||||
import { auditEventLabel, formatRelative } from "../types";
|
||||
import { WRITING_OPTIONS, descendantIds, nextStatus, prettyRunState, prettyStatus, providerFor, runBlocker, runsForTask, runTone } from "./task-helpers";
|
||||
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||
@@ -73,6 +73,8 @@ export function TaskDetailDialog({
|
||||
}) {
|
||||
const [tab, setTab] = useState<Tab>("overview");
|
||||
const [blocking, setBlocking] = useState(false);
|
||||
// The impact is kept with the task it was counted for, and the delete removes exactly that task.
|
||||
const [deleteImpact, setDeleteImpact] = useState<{ taskId: string; impact: TaskDeleteImpact } | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const act = (operation: () => Promise<void>, after?: () => void) => {
|
||||
setActionError(null);
|
||||
@@ -115,10 +117,17 @@ export function TaskDetailDialog({
|
||||
})).then((r) => { if (r) setEditing(false); }, (error: unknown) => setEditError(error instanceof Error ? error.message : String(error)));
|
||||
};
|
||||
|
||||
const deleteTask = () => {
|
||||
void actions.run(() => window.agentCompany.deleteTask({ orgId, taskId: task.id }));
|
||||
onClose();
|
||||
const askDelete = () => {
|
||||
const taskId = task.id;
|
||||
setActionError(null);
|
||||
window.agentCompany.taskDeleteImpact({ orgId, taskId }).then((result) => {
|
||||
if (!result.ok) throw new Error(result.error.message);
|
||||
setDeleteImpact({ taskId, impact: result.data });
|
||||
}).catch((error: unknown) => setActionError(error instanceof Error ? error.message : String(error)));
|
||||
};
|
||||
const confirmDelete = (taskId: string) => act(async () => {
|
||||
await actions.run(() => window.agentCompany.deleteTask({ orgId, taskId }));
|
||||
}, onClose);
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
|
||||
@@ -175,9 +184,12 @@ export function TaskDetailDialog({
|
||||
<Button tone="ghost" size="small" onClick={() => setBlocking(false)}>{t("Cancel")}</Button>
|
||||
</form>
|
||||
)}
|
||||
{deleteImpact && deleteImpact.taskId === task.id && (
|
||||
<DeleteConfirm impact={deleteImpact.impact} onConfirm={() => confirmDelete(deleteImpact.taskId)} onCancel={() => setDeleteImpact(null)} />
|
||||
)}
|
||||
{actionError && <div className="form-error" role="alert" style={{ marginTop: 8 }}>{actionError}</div>}
|
||||
<div className="modal-actions">
|
||||
<Button tone="ghost" onClick={deleteTask}><Icon name="x" size={16} /> {t("Delete")}</Button>
|
||||
<Button tone="ghost" onClick={askDelete} disabled={deleteImpact !== null}><Icon name="x" size={16} /> {t("Delete")}</Button>
|
||||
{task.blockedReason
|
||||
? <Button onClick={() => act(() => onBlock(task, null))}>{t("Unblock")}</Button>
|
||||
: task.status !== TASK_STATUS.DONE && !blocking && <Button onClick={() => setBlocking(true)}>{t("Block")}</Button>}
|
||||
@@ -382,3 +394,26 @@ function MetaLabel({ children }: { children: string }) {
|
||||
function LinkButton({ label, onClick, children }: { label: string; onClick: () => void; children: string }) {
|
||||
return <button type="button" className="link-button" aria-label={label} onClick={onClick} style={{ textAlign: "left" }}>{children}</button>;
|
||||
}
|
||||
|
||||
/** What a delete will do, said before it happens; a running session stops it. */
|
||||
function DeleteConfirm({ impact, onConfirm, onCancel }: { impact: TaskDeleteImpact; onConfirm: () => void; onCancel: () => void }) {
|
||||
const lines = [
|
||||
impact.runs > 0 && t("{n} sessions and their action records are deleted.", { n: impact.runs }),
|
||||
impact.subtasks > 0 && t("{n} subtasks stay and move to the top level.", { n: impact.subtasks }),
|
||||
impact.documents > 0 && t("{n} documents stay, no longer linked to this task.", { n: impact.documents }),
|
||||
impact.openReviews > 0 && t("{n} open reviews of it are closed.", { n: impact.openReviews }),
|
||||
impact.pendingApprovals > 0 && t("{n} pending approvals for it stay pending.", { n: impact.pendingApprovals }),
|
||||
].filter((line): line is string => typeof line === "string");
|
||||
return (
|
||||
<div role="alertdialog" aria-label={t("Delete this task?")} style={{ marginTop: 12, padding: 12, border: "1px solid var(--red)", borderRadius: 10 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 6 }}>{t("Delete this task?")}</div>
|
||||
{lines.length === 0 && <div style={{ fontSize: 13, color: "var(--text-2)" }}>{t("Nothing else is affected.")}</div>}
|
||||
{lines.map((line) => <div key={line} style={{ fontSize: 13, color: "var(--text-2)" }}>{line}</div>)}
|
||||
{impact.running > 0 && <div className="form-error" role="alert">{t("A session is running; stop it or wait for it before deleting.")}</div>}
|
||||
<div className="row" style={{ gap: 8, marginTop: 10 }}>
|
||||
<Button tone="primary" size="small" onClick={onConfirm} disabled={impact.running > 0}>{t("Delete task")}</Button>
|
||||
<Button tone="ghost" size="small" onClick={onCancel}>{t("Cancel")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TaskDeleteImpact } from "./domain";
|
||||
import type { SpaceHistoryPage, ThreadHistory } from "./chat-domain";
|
||||
import type { RunRequestRecord } from "./provider-runtime-domain";
|
||||
import type { ApprovalDrift } from "./approval-pin";
|
||||
@@ -202,6 +203,8 @@ export interface DesktopApi {
|
||||
/** The page of a conversation before a loaded message, for history older than the snapshot holds. */
|
||||
/** Blocks a task with a reason, or unblocks it with null. */
|
||||
setTaskBlocked(input: SetTaskBlockedInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
/** What deleting a task would remove, keep and close. */
|
||||
taskDeleteImpact(input: { orgId: string; taskId: string }): Promise<DesktopResult<TaskDeleteImpact>>;
|
||||
chatSpaceHistory(input: { orgId: string; spaceId: string; beforeMessageId: string | null } & ({ limit: number } | { fromMessageId: string })): Promise<DesktopResult<SpaceHistoryPage>>;
|
||||
revokeConnector(input: RevokeConnectorInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
search(input: SearchInput): Promise<DesktopResult<SearchResultRecord[]>>;
|
||||
|
||||
@@ -89,6 +89,22 @@ export type TaskHold =
|
||||
| { reason: string; waitingOn: "provider"; resumesAt: string }
|
||||
| { reason: string; waitingOn: "operator"; resumesAt: string | null };
|
||||
|
||||
/** What deleting a task removes, keeps and closes, and whether a running session stops it. */
|
||||
export interface TaskDeleteImpact {
|
||||
/** Sessions removed with the task, with their action journals. */
|
||||
runs: number;
|
||||
/** Sessions still running; the delete is refused while any is. */
|
||||
running: number;
|
||||
/** Subtasks kept, moved to the top level. */
|
||||
subtasks: number;
|
||||
/** Documents kept, no longer linked to the task. */
|
||||
documents: number;
|
||||
/** Open reviews of the task, closed by the delete. */
|
||||
openReviews: number;
|
||||
/** Approvals requested for the task that stay pending. */
|
||||
pendingApprovals: number;
|
||||
}
|
||||
|
||||
export interface TaskRecord {
|
||||
id: string;
|
||||
orgId: string;
|
||||
|
||||
@@ -84,6 +84,7 @@ export const IPC_CHANNELS = {
|
||||
chatThreadHistory: "agentcompany:chat-thread-history",
|
||||
chatSpaceHistory: "agentcompany:chat-space-history",
|
||||
setTaskBlocked: "agentcompany:set-task-blocked",
|
||||
taskDeleteImpact: "agentcompany:task-delete-impact",
|
||||
revokeConnector: "agentcompany:revoke-connector",
|
||||
search: "agentcompany:search",
|
||||
rendererEvent: "agentcompany:renderer-event",
|
||||
|
||||
@@ -251,6 +251,47 @@ test("the pinned CEO conversation is the Messages front door", async () => {
|
||||
await page.locator(".convo-item").first().click();
|
||||
await page.getByRole("button", { name: /^Members \(/ }).click();
|
||||
await expect(page.getByRole("menu")).toContainText("Replies to every message");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// Deleting a task first says what it takes with it, then waits for the result.
|
||||
await page.evaluate(async () => {
|
||||
const api = (window as unknown as { agentCompany: {
|
||||
loadSnapshot: () => Promise<{ ok: true; data: { activeOrgId: string; tasks: Array<{ id: string; title: string }> } }>;
|
||||
createTask: (input: unknown) => Promise<{ ok: boolean }>;
|
||||
updateTask: (input: unknown) => Promise<{ ok: boolean }>;
|
||||
} }).agentCompany;
|
||||
const before = (await api.loadSnapshot()).data;
|
||||
const parent = before.tasks.find((task) => task.title === "Operationalize goal: Ship the FlowPilot MVP.")!;
|
||||
await api.createTask({
|
||||
orgId: before.activeOrgId, title: "Child step", description: "d", status: "backlog", assigneeAgentId: null, risk: "low",
|
||||
projectId: null, goalId: null, workspaceId: null, priority: "normal", requiresFilesystem: false,
|
||||
});
|
||||
const child = (await api.loadSnapshot()).data.tasks.find((task) => task.title === "Child step")!;
|
||||
await api.updateTask({
|
||||
orgId: before.activeOrgId, taskId: child.id, title: "Child step", description: "d", assigneeAgentId: null, risk: "low", priority: "normal",
|
||||
parentTaskId: parent.id, projectId: null, goalId: null, workspaceId: null, requiresFilesystem: false, writingMode: "conversational",
|
||||
});
|
||||
});
|
||||
await page.getByRole("button", { name: "Work" }).click();
|
||||
await page.getByRole("button", { name: /Operationalize goal: Ship the FlowPilot MVP\./ }).first().click();
|
||||
const parentDialog = page.getByRole("dialog", { name: "Operationalize goal: Ship the FlowPilot MVP." });
|
||||
await parentDialog.getByRole("button", { name: "Delete", exact: true }).click();
|
||||
await expect(parentDialog.getByRole("alertdialog", { name: "Delete this task?" })).toContainText("1 subtasks stay and move to the top level.");
|
||||
// Switching to another task drops a pending confirmation, so it can never delete the wrong task.
|
||||
await parentDialog.getByRole("button", { name: "Subtasks" }).click();
|
||||
await parentDialog.getByRole("button", { name: "Open task Child step" }).click();
|
||||
const childDialog = page.getByRole("dialog", { name: "Child step" });
|
||||
await expect(childDialog).toBeVisible();
|
||||
await expect(childDialog.getByRole("alertdialog")).toHaveCount(0);
|
||||
await childDialog.getByRole("button", { name: "Close" }).click();
|
||||
await page.getByRole("button", { name: /Operationalize goal: Ship the FlowPilot MVP\./ }).first().click();
|
||||
const doomed = page.getByRole("dialog", { name: "Operationalize goal: Ship the FlowPilot MVP." });
|
||||
await doomed.getByRole("button", { name: "Delete", exact: true }).click();
|
||||
const confirm = doomed.getByRole("alertdialog", { name: "Delete this task?" });
|
||||
await expect(confirm).toContainText("sessions and their action records are deleted.");
|
||||
await confirm.getByRole("button", { name: "Delete task" }).click();
|
||||
await expect(doomed).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: /Operationalize goal: Ship the FlowPilot MVP\./ })).toHaveCount(0);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
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 { DocumentService } from "../../src/main/services/document-service";
|
||||
import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS } from "../../src/shared/domain";
|
||||
|
||||
describe("deleting a task", () => {
|
||||
test("says beforehand what goes with it, what stays, and what stops it", () => {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-delete-impact-${randomUUID()}.db`));
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const ids: Record<string, string> = {};
|
||||
const task = (title: string) => organization.createTask({
|
||||
orgId: ids.org, title, description: "d", status: TASK_STATUS.BACKLOG, assigneeAgentId: null, risk: RISK_LEVEL.LOW,
|
||||
projectId: null, goalId: null, workspaceId: null, priority: TASK_PRIORITY.NORMAL, requiresFilesystem: false,
|
||||
});
|
||||
connection.transaction(() => {
|
||||
ids.org = organization.createOrganization({ organizationName: "Acme", employeeName: "Yang", mission: "m", goal: "g" });
|
||||
ids.agent = organization.createAgent({ orgId: ids.org, name: "Ada", role: "Engineer", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: null });
|
||||
ids.task = task("Parent");
|
||||
ids.sub = task("Child");
|
||||
connection.db.prepare("update tasks set parent_task_id = ? where id = ?").run(ids.task, ids.sub);
|
||||
new DocumentService(connection.db).create({ orgId: ids.org, title: "Spec", docType: "spec", content: "c", authorAgentId: null, reviewerAgentId: null, projectId: null, goalId: null, taskId: ids.task });
|
||||
for (const state of ["completed", "failed"]) {
|
||||
connection.db.prepare("insert into task_runs(id, org_id, task_id, agent_id, trigger, state, created_at) values (?, ?, ?, ?, 'manual', ?, ?)")
|
||||
.run(`run_${state}`, ids.org, ids.task, ids.agent, state, new Date().toISOString());
|
||||
}
|
||||
});
|
||||
expect(organization.taskDeleteImpact(ids.org, ids.task)).toEqual({ runs: 2, running: 0, subtasks: 1, documents: 1, openReviews: 0, pendingApprovals: 0 });
|
||||
connection.db.prepare("update task_runs set state = 'running' where id = 'run_failed'").run();
|
||||
expect(organization.taskDeleteImpact(ids.org, ids.task).running).toBe(1);
|
||||
expect(() => organization.taskDeleteImpact(ids.org, "tsk_missing")).toThrow("Task was not found.");
|
||||
connection.close();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user