Skip to content
Draft
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
14 changes: 10 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,10 +318,16 @@ above — to every place the product tells the user something failed or was refu
temporarily limiting our requests" / "This goal was already delivered"), *why the user's
work is safe* ("nothing changed" / "the card is just out of date"), and *what to do*. The
server carries a machine-readable `code` + structured `detail` so the UI renders guidance
without string-matching the human message — see the goal-action pattern:
`src/lib/engine/refusal-codes.ts` (the code contract), `engineErrorToHttp`
(`src/lib/engine/http-errors.ts`, surfaces `code`/`detail`), and the client renderer
`goal-action-refusal.tsx` (`describeGoalRefusal` / `GoalRefusalPanel` / `goalRefusalToast`).
without string-matching the human message. The **goal-action refusal surface is the
reference implementation of both rules**: `src/lib/engine/refusal-codes.ts` (the code
contract), the server carrier `src/lib/engine/goal-action-refusal.ts` (attaches
`code` + structured `detail` at every goal-action throw site), `engineErrorToHttp`
(`src/lib/engine/http-errors.ts`, surfaces them over HTTP), the client renderer
`src/components/engine/goal-action-refusal.tsx` (`describeGoalRefusal` /
`GoalRefusalPanel` / `goalRefusalToast`), and the goal cards that render it (the goals
kanban + detail views under `src/app/(app)/o/[orgSlug]/p/[projectSlug]/goals/`). It sits
alongside the fleet's `machine-blocking-reason.tsx` — the same code-keyed, plain-language,
remediation-carrying treatment for a different surface.

5. **Every alert / refusal must carry a concrete next-action recommendation — never a bare
alert.** A message that only states the problem is incomplete; it must offer at least one
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,47 @@ describe("EngineGoalDetailClient — accept", () => {
expect(actionCalls()[1][0]).toBe(`/api/v2/projects/${PROJECT_ID}/goals/${GOAL_ID}`);
});

it("ci_failed: a refused Fix CI surfaces a structured, plain-language recovery toast (never the raw message) and refetches", async () => {
// The /fix-ci route can refuse (e.g. GitHub rate-limits the check poll). That refusal
// must render as plain-language guidance with a concrete next step — not the bare
// `body.message` technical string it used to show.
queueAction({
ok: false,
status: 502,
json: async () => ({
error: "github_unverifiable",
message: "GitHub GET checks pull #5 failed: 403",
code: "github_rate_limited",
detail: { resetAt: new Date(Date.now() + 3 * 60_000).toISOString() },
}),
});
queueAction({ ok: true, json: async () => ({ data: data() }) });
renderClient(
data({
goal: {
...data().goal,
prBoundary: true,
prState: "ready",
deployGateState: "failed",
prUrl: "https://github.com/o/r/pull/5",
},
completion: { doneTasks: 3, totalTasks: 3, complete: true },
deployGate: { mergeable: false, unresolvedMustFix: 0, ciState: "failed", lastReviewedSha: "abc123", prHeadSha: "abc123" },
}),
);
fireEvent.click(screen.getByRole("button", { name: /^fix ci$/i }));
await waitFor(() => expect(toast.error).toHaveBeenCalled());
const [title, opts] = (toast.error as unknown as ReturnType<typeof vi.fn>).mock.calls[0] as [
string,
{ description?: string; action?: { label?: string } }?,
];
// Plain-language headline; the raw "failed: 403" string never reaches the user.
expect(title).not.toMatch(/failed:\s*\d{3}/);
expect(title.toLowerCase()).toContain("github");
// Carries a concrete next step (a Retry action for the rate-limit case).
expect(opts?.action?.label).toBeTruthy();
});

it("shows the merge_conflict blocker with a Resolve merge conflict CTA + View PR link, never the dead-end Accept", async () => {
queueAction({ ok: true, json: async () => ({ data: { spawned: true, task: { id: "t1" } } }) });
queueAction({ ok: true, json: async () => ({ data: data() }) });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ import {
isStaleStateRefusal,
GoalRefusalPanel,
type GoalRefusal,
} from "../goal-action-refusal";
} from "@/components/engine/goal-action-refusal";
import { isGoalReadyToDeliver, deriveGoalResolveAction } from "@/lib/engine/goal-ready";
import { deriveAutopilotProgress } from "@/lib/engine/goal-autopilot";
import { isGoalParked } from "@/lib/engine/goal-parked";
Expand Down Expand Up @@ -873,8 +873,12 @@ export function EngineGoalDetailClient({
headers: { "Content-Type": "application/json" },
});
if (!res.ok) {
const j = (await res.json().catch(() => null)) as { message?: string } | null;
toast.error(j?.message ?? "Couldn't fix CI. Please try again.");
// Structured recovery toast: refresh converges a stale card, and a rate-limit /
// unverifiable refusal offers a Retry that re-invokes this same action.
goalRefusalToast(await parseGoalRefusal(res), {
onRefresh: () => void refetch(),
onRetry: () => void handleFixCi(),
});
} else {
const json = (await res.json().catch(() => null)) as { data?: { spawned?: boolean } } | null;
toast.success(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { toast } from "sonner";
import { parseGoalRefusal, goalRefusalToast } from "./goal-action-refusal";
import { parseGoalRefusal, goalRefusalToast } from "@/components/engine/goal-action-refusal";
import {
Plus,
Target,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, within, act } from "@testing-library/react";
import "@testing-library/jest-dom";
import React from "react";
import { toast } from "sonner";
import { GoalsKanbanView } from "./goals-kanban-view";
import type { EngineGoalRow } from "./goals-view";
import type { GoalRunningMachine } from "@/lib/engine/goal-machine-activity";
Expand Down Expand Up @@ -959,6 +960,84 @@ describe("GoalsKanbanView — next-action button", () => {
});
});

// #1456 (G#371, deliverable 2 + 3): a stale Accept on a goal the server has ALREADY moved to
// a terminal state must SELF-HEAL the card — a surgical row refetch that converges the card to
// the true state and re-derives the Accept to its non-clickable terminal form — instead of
// stranding the user on a bare error. The refusal renderer (goal-action-refusal.tsx) drives
// this off the machine-readable `code`, never a string-match on the message.
describe("GoalsKanbanView — stale-card self-heal on refused Accept (#1456)", () => {
// A goal whose CLIENT row still reads as ready-to-deliver (all gates passing, non-terminal),
// so its Queue card shows a live Accept — the exact stale-card precondition.
const staleReadyGoal = () =>
goal({
id: "gstale",
title: "Stale Ready",
status: "in_progress",
prBoundary: true,
prState: "ready",
deployGateState: "passed",
repos: [],
completion: { doneTasks: 3, totalTasks: 3, complete: true, backlogTasks: 0, runningTasks: 0, queuedTasks: 0 },
unresolvedMustFix: 0,
});

beforeEach(() => {
vi.mocked(toast.error).mockClear();
vi.mocked(toast.success).mockClear();
});

it("a 409 goal_already_terminal on Accept refetches the row (self-heal) and shows NO bare error toast", async () => {
const applyEngineChange = vi.fn();
const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
code: "goal_already_terminal",
detail: { status: "accepted" },
// The raw server message the user must NEVER see — self-heal is silent.
message: "cannot accept a accepted goal",
}),
{ status: 409 },
),
);
renderBoard([staleReadyGoal()], { applyEngineChange });

fireEvent.click(within(column("Queue")).getByRole("button", { name: "Accept" }));

// The refused stale action triggers the surgical row refetch (goalRefusalToast →
// onRefresh → applyEngineChange), NOT a red error toast — goal_already_terminal self-heals.
await vi.waitFor(() => expect(applyEngineChange).toHaveBeenCalledWith({ goalId: "gstale" }));
expect(toast.error).not.toHaveBeenCalled();
// And it certainly must not falsely report success on a refusal.
expect(toast.success).not.toHaveBeenCalled();
fetchSpy.mockRestore();
});

it("after the self-heal refetch converges the row to accepted, the Accept re-derives to non-clickable (no lingering error, no live Accept)", async () => {
const applyEngineChange = vi.fn();
const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue(
new Response(
JSON.stringify({ code: "goal_already_terminal", detail: { status: "accepted" }, message: "cannot accept a accepted goal" }),
{ status: 409 },
),
);
const { rerenderGoals } = renderBoard([staleReadyGoal()], { applyEngineChange });

fireEvent.click(within(column("Queue")).getByRole("button", { name: "Accept" }));
await vi.waitFor(() => expect(applyEngineChange).toHaveBeenCalledWith({ goalId: "gstale" }));

// Simulate the refetch landing the TRUE server state (the parent's applyEngineChange
// surgically refetches the row and re-renders the board with it).
rerenderGoals([{ ...staleReadyGoal(), status: "accepted" }]);

// The card is now terminal → in Complete, with NO clickable Accept anywhere on the board,
// and no bare error toast was ever shown.
expect(within(column("Complete")).queryByRole("button", { name: "Accept" })).not.toBeInTheDocument();
expect(within(column("Queue")).queryByRole("button", { name: "Accept" })).not.toBeInTheDocument();
expect(toast.error).not.toHaveBeenCalled();
fetchSpy.mockRestore();
});
});

describe("GoalsKanbanView — stuck indicator", () => {
it("shows amber banner 'Machine offline' for hasBlockedMachine with machine_offline reason, in the Blocked column", () => {
renderBoard([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { toast } from "sonner";
import { parseGoalRefusal, goalRefusalToast } from "./goal-action-refusal";
import { parseGoalRefusal, goalRefusalToast } from "@/components/engine/goal-action-refusal";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import {
DndContext,
Expand Down Expand Up @@ -1056,7 +1056,16 @@ function KanbanCard({
const openDetail = () => router.push(goalHref(goal.id, goal.displayNumber));
switch (resolveAction.kind) {
case "ready":
nextAction = { label: "Accept", onClick: () => onAccept(goal) };
// Drive the Accept's clickability off the single-source-of-truth `actionable` flag,
// not the `kind` label alone (#1456, deliverable 2). `deriveGoalResolveAction` always
// marks a terminal/underivable goal `actionable:false`, so this can never render a
// live Accept for one — and if the readiness derivation ever grows a non-actionable
// `ready` sub-state, the button correctly hides instead of dead-ending on a re-failing
// click. A card whose SERVER state has already moved to accepted (stale card) still
// derives `ready` from its stale row, so the Accept shows once; the refused click then
// self-heals (acceptGoal → goalRefusalToast → applyEngineChange refetch) and the row
// re-derives to the non-clickable terminal state.
if (resolveAction.actionable) nextAction = { label: "Accept", onClick: () => onAccept(goal) };
break;
case "review_outdated":
nextAction = { label: "Request re-review", onClick: () => onRequestReReview(goal) };
Expand Down
100 changes: 100 additions & 0 deletions src/components/engine/goal-cancel-dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// @vitest-environment jsdom
// Copyright (c) 2026 Interactor, Inc.
// SPDX-License-Identifier: AGPL-3.0-or-later
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";

import { GoalCancelDialog } from "./goal-cancel-dialog";

const toastError = vi.fn();
const toastSuccess = vi.fn();
vi.mock("sonner", () => ({
toast: {
error: (...a: unknown[]) => toastError(...a),
success: (...a: unknown[]) => toastSuccess(...a),
},
}));

const base = {
projectId: "p1",
goalId: "g1",
goalTitle: "Ship the thing",
open: true,
};

beforeEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});

function clickCancel() {
fireEvent.click(screen.getByRole("button", { name: /^Cancel goal$/i }));
}

describe("GoalCancelDialog — structured refusal on failure", () => {
it("renders a plain-language, recovery-carrying refusal (never the raw message) on a mapped 409", async () => {
// A raw technical string in `message` must NOT reach the user — the code drives the copy.
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
code: "deploy_gate_failed",
detail: { ciState: "failing" },
message: "GitHub GET checks pull #17 failed: 403",
}),
{ status: 409, headers: { "Content-Type": "application/json" } },
),
);
const onCancelled = vi.fn();
const onClose = vi.fn();
render(<GoalCancelDialog {...base} onClose={onClose} onCancelled={onCancelled} />);

clickCancel();

await waitFor(() => expect(toastError).toHaveBeenCalled());
// Success toast never fired; no bare technical string surfaced.
expect(toastSuccess).not.toHaveBeenCalled();
const [title, opts] = toastError.mock.calls[0] as [
string,
{ description?: string; action?: { label?: string } }?,
];
expect(title).not.toMatch(/failed:\s*\d{3}/);
expect(title.toLowerCase()).toContain("checks");
// Carries a concrete recovery action (Refresh status, wired to onCancelled), not a bare alert.
expect(opts?.action?.label).toBeTruthy();
});

it("self-heals a stale (already-terminal) cancel: refreshes the parent, no error toast", async () => {
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(
JSON.stringify({ code: "goal_already_terminal", detail: { status: "accepted" } }),
{ status: 409, headers: { "Content-Type": "application/json" } },
),
);
const onCancelled = vi.fn();
const onClose = vi.fn();
render(<GoalCancelDialog {...base} onClose={onClose} onCancelled={onCancelled} />);

clickCancel();

// goal_already_terminal self-heals: onCancelled (the parent refetch) fires, no red toast.
await waitFor(() => expect(onCancelled).toHaveBeenCalled());
expect(toastError).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});

it("falls back to a safe actionable message on a legacy (code-less) refusal", async () => {
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(JSON.stringify({ message: "Some older server error." }), {
status: 422,
headers: { "Content-Type": "application/json" },
}),
);
render(<GoalCancelDialog {...base} onClose={vi.fn()} onCancelled={vi.fn()} />);

clickCancel();

await waitFor(() => expect(toastError).toHaveBeenCalled());
expect(toastSuccess).not.toHaveBeenCalled();
});
});
8 changes: 6 additions & 2 deletions src/components/engine/goal-cancel-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useState } from "react";
import * as AlertDialog from "@radix-ui/react-alert-dialog";
import { Loader2, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import { parseGoalRefusal, goalRefusalToast } from "@/components/engine/goal-action-refusal";

interface Props {
projectId: string;
Expand Down Expand Up @@ -41,8 +42,11 @@ export function GoalCancelDialog({
body: JSON.stringify({ action: "close", ...(trimmed ? { reason: trimmed } : {}) }),
});
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { message?: string } | null;
toast.error(body?.message ?? "Couldn't cancel the goal. Please try again.");
// Structured, recovery-carrying refusal (never a bare technical string). A
// stale-state refusal (e.g. the goal is already terminal) self-heals the card via
// onCancelled(), which the parent wires to a refetch/router.refresh().
goalRefusalToast(await parseGoalRefusal(res), { onRefresh: onCancelled });
onClose();
return;
}
toast.success("Goal cancelled.");
Expand Down
Loading