Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 62 additions & 16 deletions apps/web/app/api/v1/tasks/[id]/cancel/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,20 +47,61 @@ export async function POST(
"Task does not have an active agent run.",
);
}
const gateway = await fetch(
`${process.env.AGENT_GATEWAY_URL ?? "http://agent-gateway:3002"}/v1/runs/${encodeURIComponent(task.agentRunId)}/cancel`,
{
headers: agentGatewayHeaders(subject.organisationId),
method: "POST",
signal: AbortSignal.timeout(5_000),
},
);
const result = (await gateway.json()) as {
status?: string;
error?: string;
};
if (!gateway.ok)
throw new Error(result.error ?? "Agent cancellation failed");
// A run whose lease and deadline have both passed cannot still be
// executing, so the gateway's opinion is not required to release it.
// Without this a wedged run — worker died, lease expired, gateway lost
// the record — leaves the task permanently undeletable and undispatchable.
const [run] = await database()
.select({
leaseExpiresAt: schema.agentRuns.leaseExpiresAt,
deadlineAt: schema.agentRuns.deadlineAt,
})
.from(schema.agentRuns)
.where(
and(
eq(schema.agentRuns.id, task.agentRunId),
eq(schema.agentRuns.organisationId, subject.organisationId),
),
)
.limit(1);
const now = Date.now();
const stale =
!run ||
((run.leaseExpiresAt === null || run.leaseExpiresAt.getTime() < now) &&
(run.deadlineAt === null || run.deadlineAt.getTime() < now));

let result: { status?: string; error?: string } = {};
let gatewayConfirmed = false;
let gatewayError: string | null = null;
try {
const gateway = await fetch(
`${process.env.AGENT_GATEWAY_URL ?? "http://agent-gateway:3002"}/v1/runs/${encodeURIComponent(task.agentRunId)}/cancel`,
{
headers: agentGatewayHeaders(subject.organisationId),
method: "POST",
signal: AbortSignal.timeout(5_000),
},
);
result = (await gateway.json().catch(() => ({}))) as typeof result;
gatewayConfirmed = gateway.ok;
if (!gateway.ok)
gatewayError = result.error ?? `Agent gateway returned ${gateway.status}`;
} catch (cause) {
gatewayError =
cause instanceof Error ? cause.message : "Agent gateway is unreachable";
}

// Only force the release when the run provably cannot still be alive.
// Otherwise refuse, so Muster never reports a run cancelled while the
// gateway is still executing it.
if (!gatewayConfirmed && !stale) {
throw new ApiProblem(
502,
"Cancellation not confirmed",
`The agent gateway did not confirm cancellation and this run may still be executing. ${gatewayError ?? ""}`.trim(),
);
}

await settleAgentRun(
{
organisationId: subject.organisationId,
Expand All @@ -71,10 +112,15 @@ export async function POST(
task.agentRunId,
{
status: "cancelled",
error: "Cancelled by operator",
error: gatewayConfirmed
? "Cancelled by operator"
: `Force-released by operator; agent gateway did not confirm (${gatewayError ?? "no response"}). Lease and deadline had already passed.`,
},
);
return Response.json({ data: result, traceId }, { status: 202 });
return Response.json(
{ data: { ...result, gatewayConfirmed, forced: !gatewayConfirmed }, traceId },
{ status: 202 },
);
} catch (error) {
return problemResponse(error, traceId);
}
Expand Down
21 changes: 19 additions & 2 deletions apps/web/app/api/v1/tasks/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { requireCapability } from "@muster/authz";
import { TaskPrioritySchema, TaskStatusSchema } from "@muster/contracts";
import { z } from "zod";
import { apiSubject, problemResponse, requestTraceId } from "@/lib/api-context";
import { updateTask, type TaskChanges } from "@/lib/task-domain";
import { archiveTask, updateTask, type TaskChanges } from "@/lib/task-domain";

const UpdateTaskSchema = z
.object({
Expand All @@ -16,6 +16,8 @@ const UpdateTaskSchema = z
relatedCaseId: z.string().trim().max(160).nullable().optional(),
approvalRequired: z.boolean().optional(),
dueAt: z.iso.datetime({ offset: true }).nullable().optional(),
/** Soft delete. Rows stay for audit correspondence. */
archived: z.boolean().optional(),
})
.refine((value) => Object.keys(value).length > 0, "No task changes supplied");

Expand All @@ -31,7 +33,22 @@ export async function PATCH(
const input = UpdateTaskSchema.parse(await request.json());
if (input.assignedActorId !== undefined)
requireCapability(subject, "tasks.assign");
const { dueAt, ...unfilteredChanges } = input;
const { dueAt, archived, ...unfilteredChanges } = input;
if (archived !== undefined) {
const result = await archiveTask(
{
organisationId: subject.organisationId,
actorId: subject.actorId,
traceId,
},
id,
archived,
);
// Archive is its own operation; it does not combine with field edits.
if (Object.keys(unfilteredChanges).length === 0 && dueAt === undefined) {
return Response.json({ data: result, traceId });
}
}
const changes = Object.fromEntries(
Object.entries(unfilteredChanges).filter(
([, value]) => value !== undefined,
Expand Down
42 changes: 42 additions & 0 deletions apps/web/features/operations/operations-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,45 @@ describe("Stuck and failed agent work", () => {
);
});
});

describe("Removing work from the board", () => {
it("archives rather than deletes, so audit correspondence survives", async () => {
const view = await source("./operations-view.tsx");
expect(view).toContain("useArchiveTask");
expect(view).toContain("Archive");
expect(view).toContain("audit trail");
});

it("refuses to archive a task with a live run", async () => {
const view = await source("./operations-view.tsx");
expect(view).toContain("Cancel the active run before archiving");
const domain = await readFile(
new URL("../../lib/task-domain.ts", import.meta.url),
"utf8",
);
expect(domain).toContain("Cancel the active agent run before archiving");
});
});

describe("Force-releasing a wedged run", () => {
it("only forces when the run provably cannot still be executing", async () => {
const route = await readFile(
new URL("../../app/api/v1/tasks/[id]/cancel/route.ts", import.meta.url),
"utf8",
);
// Both clocks must have passed; a live run must never be reported cancelled.
expect(route).toContain("leaseExpiresAt");
expect(route).toContain("deadlineAt");
expect(route).toContain("if (!gatewayConfirmed && !stale)");
expect(route).toContain("may still be executing");
});

it("records that the gateway did not confirm", async () => {
const route = await readFile(
new URL("../../app/api/v1/tasks/[id]/cancel/route.ts", import.meta.url),
"utf8",
);
expect(route).toContain("Force-released by operator");
expect(route).toContain("gatewayConfirmed");
});
});
32 changes: 32 additions & 0 deletions apps/web/features/operations/operations-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
type ComposerSeed,
} from "@/features/operations/task-composer";
import {
useArchiveTask,
useCancelTaskRun,
useDelegateTask,
useTasks,
Expand Down Expand Up @@ -494,6 +495,7 @@ export function OperationsView() {
function DetailDrawer({ item }: { item: BoardItem | null }) {
const delegateTask = useDelegateTask();
const cancelRun = useCancelTaskRun();
const archiveTask = useArchiveTask();
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);

Expand Down Expand Up @@ -536,6 +538,22 @@ function DetailDrawer({ item }: { item: BoardItem | null }) {
}
}

async function archive() {
if (!item) return;
setError(null);
setNotice(null);
try {
await archiveTask.mutateAsync({ id: item.id, archived: true });
setNotice("Archived. The row is kept so its audit trail stays intact.");
} catch (caught) {
setError(
caught instanceof Error
? caught.message
: "Could not archive the task.",
);
}
}

return (
<aside className="rounded-md border border-border bg-card p-4">
<h2 className="text-sm font-semibold">{item.title}</h2>
Expand Down Expand Up @@ -565,6 +583,20 @@ function DetailDrawer({ item }: { item: BoardItem | null }) {
{cancelRun.isPending ? "Cancelling…" : "Cancel run"}
</Button>
) : null}
<Button
type="button"
size="sm"
variant="ghost"
disabled={hasActiveRun(item) || archiveTask.isPending}
title={
hasActiveRun(item)
? "Cancel the active run before archiving"
: "Remove from the board; the row and its audit trail are kept"
}
onClick={() => void archive()}
>
{archiveTask.isPending ? "Archiving…" : "Archive"}
</Button>
<Button
type="button"
size="sm"
Expand Down
23 changes: 23 additions & 0 deletions apps/web/lib/queries/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,29 @@ export function useCreateTask() {
});
}

/**
* Soft-delete a work item. The row stays for audit correspondence; every
* list already filters on archivedAt.
*/
export function useArchiveTask() {
const client = useQueryClient();
return useMutation({
mutationFn: async (input: { id: string; archived: boolean }) => {
const res = await apiPatch<{ id: string; archived: boolean }>(
`/api/v1/tasks/${input.id}`,
{ archived: input.archived },
);
return res.data;
},
onSuccess: async () => {
await Promise.all([
client.invalidateQueries({ queryKey: queryKeys.tasks }),
client.invalidateQueries({ queryKey: queryKeys.commandSummary }),
]);
},
});
}

/**
* Cancel a task's in-flight agent run. Without this a run wedged at
* queued/running (gateway crash, expired lease) blocks re-dispatch forever
Expand Down
62 changes: 62 additions & 0 deletions apps/web/lib/task-domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,68 @@ export async function updateTask(
});
}

/**
* Soft-delete a work item. Rows stay for audit correspondence — the board and
* every list already filter on archivedAt — so this is reversible and never
* orphans an audit event that references the task.
*/
export async function archiveTask(
context: TaskMutationContext,
taskId: string,
archived: boolean,
) {
return database().transaction(async (tx) => {
const [existing] = await tx
.select({
id: schema.tasks.id,
agentRunStatus: schema.tasks.agentRunStatus,
archivedAt: schema.tasks.archivedAt,
})
.from(schema.tasks)
.where(
and(
eq(schema.tasks.id, taskId),
eq(schema.tasks.organisationId, context.organisationId),
),
)
.limit(1);
if (!existing) throw new ApiProblem(404, "Not found", "Task not found.");
// Archiving a task with a live run would hide work that is still burning
// budget and may still write evidence. Cancel it first.
if (
archived &&
(existing.agentRunStatus === "queued" ||
existing.agentRunStatus === "running")
) {
throw new ApiProblem(
409,
"Run in progress",
"Cancel the active agent run before archiving this task.",
);
}
if (Boolean(existing.archivedAt) === archived) {
return { id: taskId, archived, duplicate: true };
}
await tx
.update(schema.tasks)
.set({ archivedAt: archived ? new Date() : null, updatedAt: new Date() })
.where(
and(
eq(schema.tasks.id, taskId),
eq(schema.tasks.organisationId, context.organisationId),
),
);
await recordMutation(
tx,
context,
taskId,
archived ? "task.archived" : "task.restored",
{ agentRunStatus: existing.agentRunStatus },
);
return { id: taskId, archived, duplicate: false };
});
}

export type AcceptedAgentRun = {
runId: string;
status: string;
Expand Down
Loading