diff --git a/src/components/PipelineRun/RunNotesEditor.tsx b/src/components/PipelineRun/RunNotesEditor.tsx
index c16165c30e..6cac7e79b1 100644
--- a/src/components/PipelineRun/RunNotesEditor.tsx
+++ b/src/components/PipelineRun/RunNotesEditor.tsx
@@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
import { Textarea } from "@/components/ui/textarea";
import { Paragraph } from "@/components/ui/typography";
+import useToastNotification from "@/hooks/useToastNotification";
import { useBackend } from "@/providers/BackendProvider";
import { updateRunAnnotation } from "@/services/pipelineRunService";
import { runAnnotationsQueryOptions } from "@/services/runAnnotations";
@@ -14,10 +15,16 @@ import {
interface RunNotesEditorProps {
runId: string;
readOnly?: boolean;
+ teamId?: string;
}
-export const RunNotesEditor = ({ runId, readOnly }: RunNotesEditorProps) => {
+export const RunNotesEditor = ({
+ runId,
+ readOnly,
+ teamId,
+}: RunNotesEditorProps) => {
const { backendUrl } = useBackend();
+ const notify = useToastNotification();
const {
data: annotations,
@@ -29,13 +36,21 @@ export const RunNotesEditor = ({ runId, readOnly }: RunNotesEditorProps) => {
const { mutate: saveRunNotes, isPending } = useMutation({
mutationFn: (runId: string) =>
- updateRunAnnotation(runId, backendUrl, {
- key: PIPELINE_RUN_NOTES_ANNOTATION,
- value: value,
- }),
+ updateRunAnnotation(
+ runId,
+ backendUrl,
+ {
+ key: PIPELINE_RUN_NOTES_ANNOTATION,
+ value: value,
+ },
+ teamId,
+ ),
onSuccess: () => {
refetch();
},
+ onError: (error) => {
+ notify(error.message, "error");
+ },
});
const notes =
diff --git a/src/components/PipelineRun/RunToolbar.test.tsx b/src/components/PipelineRun/RunToolbar.test.tsx
index dbfc991bf3..bbf647b2fa 100644
--- a/src/components/PipelineRun/RunToolbar.test.tsx
+++ b/src/components/PipelineRun/RunToolbar.test.tsx
@@ -42,6 +42,10 @@ vi.mock("@/hooks/useUserDetails", () => ({
})),
}));
+vi.mock("@/services/teams/useRunTeam", () => ({
+ useRunTeam: () => ({ status: "none" }),
+}));
+
describe("", () => {
const queryClient = new QueryClient({
defaultOptions: {
diff --git a/src/components/PipelineRun/RunToolbar.tsx b/src/components/PipelineRun/RunToolbar.tsx
index b2d21154da..ef22656b07 100644
--- a/src/components/PipelineRun/RunToolbar.tsx
+++ b/src/components/PipelineRun/RunToolbar.tsx
@@ -88,11 +88,7 @@ export const RunToolbar = () => {
)}
{isComplete && (
-
+
)}
);
diff --git a/src/components/PipelineRun/components/CancelPipelineRunButton.test.tsx b/src/components/PipelineRun/components/CancelPipelineRunButton.test.tsx
index 9ae7920988..ddbb9f3c33 100644
--- a/src/components/PipelineRun/components/CancelPipelineRunButton.test.tsx
+++ b/src/components/PipelineRun/components/CancelPipelineRunButton.test.tsx
@@ -145,6 +145,7 @@ describe("", () => {
expect(cancelPipelineRun).toHaveBeenCalledWith(
"test-run-123",
"http://localhost:8000",
+ undefined,
);
expect(mockNotify).toHaveBeenCalledWith(
"Pipeline run test-run-123 cancelled",
@@ -154,6 +155,27 @@ describe("", () => {
expect(button).toHaveTextContent("Stop");
});
+ test("cancels a team's run as the team", async () => {
+ cancelPipelineRun.mockResolvedValue();
+ renderWithQueryClient(
+ ,
+ );
+
+ await act(() =>
+ fireEvent.click(screen.getByTestId("cancel-pipeline-run-button")),
+ );
+ await act(() => fireEvent.click(screen.getByText("Continue")));
+
+ expect(cancelPipelineRun).toHaveBeenCalledWith(
+ "test-run-123",
+ "http://localhost:8000",
+ "ml-team@example.com",
+ );
+ });
+
test("handles cancellation error", async () => {
// arrange
const errorMessage = "Network error";
@@ -168,7 +190,7 @@ describe("", () => {
// assert
expect(mockNotify).toHaveBeenCalledWith(
- `Error cancelling run: Error: ${errorMessage}`,
+ `Error cancelling run: ${errorMessage}`,
"error",
);
expect(button).toBeVisible();
diff --git a/src/components/PipelineRun/components/CancelPipelineRunButton.tsx b/src/components/PipelineRun/components/CancelPipelineRunButton.tsx
index f4618381b6..4011dd6e8b 100644
--- a/src/components/PipelineRun/components/CancelPipelineRunButton.tsx
+++ b/src/components/PipelineRun/components/CancelPipelineRunButton.tsx
@@ -11,6 +11,7 @@ import { cancelPipelineRun } from "@/services/pipelineRunService";
type CancelPipelineRunButtonProps = {
runId: string | null | undefined;
+ teamId?: string;
showLabel?: boolean;
displayLabel?: string;
showTooltip?: boolean;
@@ -21,6 +22,7 @@ type CancelPipelineRunButtonProps = {
export const CancelPipelineRunButton = ({
runId,
+ teamId,
showLabel,
displayLabel,
showTooltip = true,
@@ -36,12 +38,12 @@ export const CancelPipelineRunButton = ({
isPending,
isSuccess,
} = useMutation({
- mutationFn: (runId: string) => cancelPipelineRun(runId, backendUrl),
+ mutationFn: (runId: string) => cancelPipelineRun(runId, backendUrl, teamId),
onSuccess: () => {
notify(`Pipeline run ${runId} cancelled`, "success");
},
onError: (error) => {
- notify(`Error cancelling run: ${error}`, "error");
+ notify(`Error cancelling run: ${error.message}`, "error");
},
});
diff --git a/src/components/PipelineRun/components/RerunBlockedReason.tsx b/src/components/PipelineRun/components/RerunBlockedReason.tsx
new file mode 100644
index 0000000000..5ee7ae3d95
--- /dev/null
+++ b/src/components/PipelineRun/components/RerunBlockedReason.tsx
@@ -0,0 +1,15 @@
+import { TeamName } from "@/components/shared/Teams/TeamName";
+import type { RerunBlock } from "@/routes/v2/pages/RunView/hooks/useRerunPipelineRun";
+
+export function RerunBlockedReason({ block }: { block: RerunBlock }) {
+ if (block.kind === "team-unchecked") {
+ return <>{"Couldn't check this run's workspace, so it can't be rerun."}>;
+ }
+ return (
+ <>
+ {"This run went as the "}
+
+ {" workspace. Only someone who can act as the workspace can rerun it."}
+ >
+ );
+}
diff --git a/src/components/PipelineRun/components/RerunPipelineButton.test.tsx b/src/components/PipelineRun/components/RerunPipelineButton.test.tsx
index b6ff67d162..9ab5eb2700 100644
--- a/src/components/PipelineRun/components/RerunPipelineButton.test.tsx
+++ b/src/components/PipelineRun/components/RerunPipelineButton.test.tsx
@@ -7,10 +7,17 @@ import {
screen,
waitFor,
} from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import type { ReactElement } from "react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import {
+ type RunIdentity,
+ runIdentity,
+} from "@/components/shared/SecretsManagement/runIdentity";
+import { useRunIdentity } from "@/components/shared/SecretsManagement/useRunIdentity";
import { BackendProvider } from "@/providers/BackendProvider";
+import { useRunTeam } from "@/services/teams/useRunTeam";
import { RerunPipelineButton } from "./RerunPipelineButton";
@@ -74,6 +81,44 @@ vi.mock("@/providers/ExecutionDataProvider", () => ({
useExecutionDataOptional: mockUseExecutionDataOptional,
}));
+vi.mock("@/services/teams/useRunTeam", () => ({
+ useRunTeam: vi.fn(),
+}));
+
+vi.mock("@/hooks/useUserDetails", () => ({
+ useUserDetails: () => ({ data: { id: "me@example.com", permissions: [] } }),
+}));
+
+vi.mock(
+ "@/components/shared/SecretsManagement/useRunIdentity",
+ async (importOriginal) => ({
+ ...(await importOriginal()),
+ useRunIdentity: vi.fn(),
+ }),
+);
+
+class ResizeObserverMock {
+ observe = vi.fn();
+ unobserve = vi.fn();
+ disconnect = vi.fn();
+}
+vi.stubGlobal("ResizeObserver", ResizeObserverMock);
+
+const ML_TEAM = { id: "ml-team@example.com", label: "ML Research" };
+const ME = "me@example.com";
+
+const named = (...names: string[]) => names.map((name) => ({ name }));
+
+function ranBy(createdBy: string) {
+ mockUseExecutionDataOptional.mockReturnValue({
+ metadata: { id: "run-1", created_by: createdBy },
+ });
+}
+
+function withIdentity(identity: RunIdentity) {
+ vi.mocked(useRunIdentity).mockReturnValue({ ...identity, isChecking: false });
+}
+
const testOrigin = import.meta.env.VITE_BASE_URL || "http://localhost:3000";
Object.defineProperty(window, "location", {
@@ -116,6 +161,8 @@ describe("", () => {
mockGetToken.mockReturnValue("mock-token");
mockAwaitAuthorization.mockClear();
mockUseExecutionDataOptional.mockReturnValue(undefined);
+ vi.mocked(useRunTeam).mockReturnValue({ status: "none" });
+ withIdentity(runIdentity({ team: undefined, referenced: [] }));
});
afterEach(async () => {
@@ -419,4 +466,248 @@ describe("", () => {
);
});
});
+
+ describe("for a run filed under a team's project", () => {
+ const rerunNow = async () => {
+ await act(async () => {
+ renderWithProviders(
+ ,
+ );
+ });
+ await act(async () => {
+ fireEvent.click(screen.getByTestId("rerun-pipeline-button"));
+ });
+ };
+
+ beforeEach(() => {
+ mockSubmitPipelineRun.mockImplementation(async (_, __, { onSuccess }) => {
+ onSuccess({ id: 123 });
+ });
+ });
+
+ const ACTIVE = {
+ status: "active",
+ team: { ...ML_TEAM, canWrite: true },
+ } as const;
+
+ test("reruns as you when its secrets don't need the team", async () => {
+ vi.mocked(useRunTeam).mockReturnValue(ACTIVE);
+ withIdentity(
+ runIdentity({
+ team: ML_TEAM,
+ referenced: ["TEST_PERSONAL"],
+ yourSecrets: named("TEST_PERSONAL"),
+ teamSecrets: [],
+ }),
+ );
+
+ await rerunNow();
+
+ await waitFor(() => {
+ expect(mockSubmitPipelineRun).toHaveBeenCalledWith(
+ componentSpec,
+ expect.any(String),
+ expect.objectContaining({ teamId: undefined }),
+ );
+ });
+ });
+
+ test("reruns as the team when only the team has its secrets", async () => {
+ vi.mocked(useRunTeam).mockReturnValue(ACTIVE);
+ withIdentity(
+ runIdentity({
+ team: ML_TEAM,
+ referenced: ["Test"],
+ yourSecrets: [],
+ teamSecrets: named("Test"),
+ }),
+ );
+
+ await rerunNow();
+
+ await waitFor(() => {
+ expect(mockSubmitPipelineRun).toHaveBeenCalledWith(
+ componentSpec,
+ expect.any(String),
+ expect.objectContaining({ teamId: ML_TEAM.id }),
+ );
+ });
+ });
+
+ test("lets you choose who reruns it when its secrets are split", async () => {
+ vi.mocked(useRunTeam).mockReturnValue(ACTIVE);
+ withIdentity(
+ runIdentity({
+ team: ML_TEAM,
+ referenced: ["TEST_PERSONAL", "Test"],
+ yourSecrets: named("TEST_PERSONAL"),
+ teamSecrets: named("Test"),
+ }),
+ );
+
+ await rerunNow();
+
+ expect(screen.getByTestId("info-box-title")).toHaveTextContent(
+ "Secrets split between you and the workspace",
+ );
+ expect(mockSubmitPipelineRun).not.toHaveBeenCalled();
+
+ await act(async () => {
+ fireEvent.click(
+ screen.getByRole("radio", { name: "Workspace: ML Research" }),
+ );
+ });
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: "Continue" }));
+ });
+
+ await waitFor(() => {
+ expect(mockSubmitPipelineRun).toHaveBeenCalledWith(
+ componentSpec,
+ expect.any(String),
+ expect.objectContaining({ teamId: ML_TEAM.id }),
+ );
+ });
+ });
+
+ test("reruns the team's run as the team", async () => {
+ vi.mocked(useRunTeam).mockReturnValue(ACTIVE);
+ ranBy(ML_TEAM.id);
+
+ await rerunNow();
+
+ await waitFor(() => {
+ expect(mockSubmitPipelineRun).toHaveBeenCalledWith(
+ componentSpec,
+ expect.any(String),
+ expect.objectContaining({ teamId: ML_TEAM.id }),
+ );
+ });
+ });
+
+ test.each([
+ ["you're not in its workspace", { status: "not-member", team: ML_TEAM }],
+ [
+ "you can't act as its workspace",
+ { status: "unavailable", team: ML_TEAM },
+ ],
+ ] as const)("won't rerun the team's run when %s", async (_, resolution) => {
+ vi.mocked(useRunTeam).mockReturnValue(resolution);
+ ranBy(ML_TEAM.id);
+ const user = userEvent.setup();
+
+ await act(async () => {
+ renderWithProviders(
+ ,
+ );
+ });
+
+ const button = screen.getByTestId("rerun-pipeline-button");
+ expect(button).toBeDisabled();
+
+ await user.hover(button.parentElement!);
+ expect(await screen.findByRole("tooltip")).toHaveTextContent(
+ "This run went as the ML Research workspace. Only someone who can act as the workspace can rerun it.",
+ );
+ });
+
+ test("reruns your own run as you when you're not in its team", async () => {
+ vi.mocked(useRunTeam).mockReturnValue({
+ status: "not-member",
+ team: ML_TEAM,
+ });
+ ranBy(ME);
+
+ await rerunNow();
+
+ expect(screen.queryByRole("alertdialog")).toBeNull();
+ await waitFor(() => {
+ expect(mockSubmitPipelineRun).toHaveBeenCalledWith(
+ componentSpec,
+ expect.any(String),
+ expect.objectContaining({ teamId: undefined }),
+ );
+ });
+ });
+
+ test("won't rerun someone else's run when its team can't be checked", async () => {
+ vi.mocked(useRunTeam).mockReturnValue({
+ status: "error",
+ error: new Error("Lookup failed"),
+ });
+ ranBy("colleague@example.com");
+
+ await act(async () => {
+ renderWithProviders(
+ ,
+ );
+ });
+
+ expect(screen.getByTestId("rerun-pipeline-button")).toBeDisabled();
+ });
+
+ test("reruns your own run when its team can't be checked", async () => {
+ vi.mocked(useRunTeam).mockReturnValue({
+ status: "error",
+ error: new Error("Lookup failed"),
+ });
+ ranBy(ME);
+
+ await rerunNow();
+
+ await waitFor(() => {
+ expect(mockSubmitPipelineRun).toHaveBeenCalledWith(
+ componentSpec,
+ expect.any(String),
+ expect.objectContaining({ teamId: undefined }),
+ );
+ });
+ });
+
+ test("asks before rerunning without the secrets the run uses", async () => {
+ vi.mocked(useRunTeam).mockReturnValue(ACTIVE);
+ withIdentity(
+ runIdentity({
+ team: ML_TEAM,
+ referenced: ["API_KEY"],
+ yourSecrets: [],
+ teamSecrets: [],
+ }),
+ );
+
+ await rerunNow();
+
+ expect(screen.getByTestId("info-box-warning")).toHaveTextContent(
+ "API_KEY isn't in your secrets, so the run will fail when it starts.",
+ );
+ expect(mockSubmitPipelineRun).not.toHaveBeenCalled();
+ });
+
+ test("waits for the run's team before rerunning", async () => {
+ vi.mocked(useRunTeam).mockReturnValue({ status: "resolving" });
+
+ await act(async () => {
+ renderWithProviders(
+ ,
+ );
+ });
+
+ expect(screen.getByTestId("rerun-pipeline-button")).toBeDisabled();
+ });
+
+ test("won't rerun a run whose projects are on different teams", async () => {
+ vi.mocked(useRunTeam).mockReturnValue({
+ status: "conflict",
+ teams: [ML_TEAM, { id: "data@example.com", label: "Data" }],
+ });
+
+ await act(async () => {
+ renderWithProviders(
+ ,
+ );
+ });
+
+ expect(screen.getByTestId("rerun-pipeline-button")).toBeDisabled();
+ });
+ });
});
diff --git a/src/components/PipelineRun/components/RerunPipelineButton.tsx b/src/components/PipelineRun/components/RerunPipelineButton.tsx
index 0076ab15e3..f26d6b8b6a 100644
--- a/src/components/PipelineRun/components/RerunPipelineButton.tsx
+++ b/src/components/PipelineRun/components/RerunPipelineButton.tsx
@@ -1,28 +1,15 @@
-import { useMutation } from "@tanstack/react-query";
-import { useNavigate } from "@tanstack/react-router";
-import { type ComponentPropsWithoutRef, useCallback } from "react";
+import type { ComponentPropsWithoutRef } from "react";
-import { isAuthorizationRequired } from "@/components/shared/Authentication/helpers";
-import { useAuthLocalStorage } from "@/components/shared/Authentication/useAuthLocalStorage";
-import { useAwaitAuthorization } from "@/components/shared/Authentication/useAwaitAuthorization";
import TooltipButton from "@/components/shared/Buttons/TooltipButton";
-import { buildTaskSpecShape } from "@/components/shared/PipelineRunNameTemplate/types";
import { Icon } from "@/components/ui/icon";
-import { useRerunProjectIds } from "@/hooks/useRerunProjectIds";
-import useToastNotification from "@/hooks/useToastNotification";
-import { useBackend } from "@/providers/BackendProvider";
-import { useExecutionDataOptional } from "@/providers/ExecutionDataProvider";
-import { useRunSubmissionAnnotations } from "@/providers/RunSubmissionScopeProvider";
-import { getDefaultRunPath } from "@/routes/runRoutes";
-import type { PipelineRun } from "@/types/pipelineRun";
-import { extractCanonicalName } from "@/utils/canonicalPipelineName";
-import type { ArgumentType, ComponentSpec } from "@/utils/componentSpec";
-import { projectRunAnnotations } from "@/utils/projectRunAnnotation";
-import { submitPipelineRun } from "@/utils/submitPipeline";
+import { useRerunPipelineRun } from "@/routes/v2/pages/RunView/hooks/useRerunPipelineRun";
+import type { ComponentSpec } from "@/utils/componentSpec";
+
+import { RerunBlockedReason } from "./RerunBlockedReason";
+import { RerunReviewDialog } from "./RerunReviewDialog";
type RerunPipelineButtonProps = {
componentSpec: ComponentSpec;
- runId?: string | null;
showLabel?: boolean;
displayLabel?: string;
showTooltip?: boolean;
@@ -33,88 +20,34 @@ type RerunPipelineButtonProps = {
export const RerunPipelineButton = ({
componentSpec,
- runId,
showLabel,
displayLabel,
showTooltip = true,
...rest
}: RerunPipelineButtonProps) => {
- const { backendUrl } = useBackend();
- const navigate = useNavigate();
- const notify = useToastNotification();
- const executionData = useExecutionDataOptional();
- const runAnnotations = useRunSubmissionAnnotations();
- const rerunProjectIds = useRerunProjectIds();
-
- const { awaitAuthorization, isAuthorized } = useAwaitAuthorization();
- const { getToken } = useAuthLocalStorage();
+ const { rerun, isRerunning, canRerun, blocked, review } =
+ useRerunPipelineRun(componentSpec);
- const onSuccess = useCallback((response: PipelineRun) => {
- navigate({ to: getDefaultRunPath(response.id) });
- }, []);
-
- const onError = useCallback(
- (error: Error | string) => {
- const message = `Failed to submit pipeline. ${error instanceof Error ? error.message : String(error)}`;
- notify(message, "error");
- },
- [notify],
+ const tooltip = blocked ? (
+
+ ) : (
+ showTooltip && "Rerun pipeline"
);
- const getAuthToken = useCallback(async (): Promise => {
- const authorizationRequired = isAuthorizationRequired();
-
- if (authorizationRequired && !isAuthorized) {
- const token = await awaitAuthorization();
- if (token) {
- return token;
- }
- }
-
- return getToken();
- }, [awaitAuthorization, getToken, isAuthorized]);
-
- const { mutate, isPending } = useMutation({
- mutationFn: async () => {
- const authorizationToken = await getAuthToken();
- const projectIds = await rerunProjectIds(runId);
-
- return new Promise((resolve, reject) => {
- submitPipelineRun(componentSpec, backendUrl, {
- canonicalName: extractCanonicalName(
- buildTaskSpecShape(
- executionData?.rootDetails?.task_spec,
- componentSpec,
- ),
- ),
- // Generated API definitions slightly differs from the componentSpec
- taskArguments: executionData?.rootDetails?.task_spec
- .arguments as Record,
- authorizationToken,
- runAnnotations: {
- ...runAnnotations,
- ...projectRunAnnotations(projectIds),
- },
- onSuccess: resolve,
- onError: reject,
- });
- });
- },
- onSuccess,
- onError,
- });
-
return (
- mutate()}
- tooltip={showTooltip ? "Rerun pipeline" : undefined}
- disabled={isPending}
- data-testid="rerun-pipeline-button"
- {...rest}
- >
-
- {displayLabel ?? (showLabel ? "Rerun" : null)}
-
+ <>
+
+
+ {displayLabel ?? (showLabel ? "Rerun" : null)}
+
+
+ >
);
};
diff --git a/src/components/PipelineRun/components/RerunReviewDialog.tsx b/src/components/PipelineRun/components/RerunReviewDialog.tsx
new file mode 100644
index 0000000000..5e56334b2b
--- /dev/null
+++ b/src/components/PipelineRun/components/RerunReviewDialog.tsx
@@ -0,0 +1,27 @@
+import ConfirmationDialog from "@/components/shared/Dialogs/ConfirmationDialog";
+import { RunIdentitySummary } from "@/components/shared/Submitters/Tangle/components/RunIdentitySummary";
+import type { RerunReview } from "@/routes/v2/pages/RunView/hooks/useRerunPipelineRun";
+
+interface RerunReviewDialogProps {
+ review: RerunReview;
+}
+
+export function RerunReviewDialog({ review }: RerunReviewDialogProps) {
+ return (
+
+ }
+ onConfirm={review.confirm}
+ onCancel={review.dismiss}
+ />
+ );
+}
diff --git a/src/routes/v2/pages/RunView/components/RunActionsBar.tsx b/src/routes/v2/pages/RunView/components/RunActionsBar.tsx
index 93527dcef3..2654b49d52 100644
--- a/src/routes/v2/pages/RunView/components/RunActionsBar.tsx
+++ b/src/routes/v2/pages/RunView/components/RunActionsBar.tsx
@@ -1,3 +1,5 @@
+import { RerunBlockedReason } from "@/components/PipelineRun/components/RerunBlockedReason";
+import { RerunReviewDialog } from "@/components/PipelineRun/components/RerunReviewDialog";
import TooltipButton from "@/components/shared/Buttons/TooltipButton";
import ConfirmationDialog from "@/components/shared/Dialogs/ConfirmationDialog";
import { StatusBar } from "@/components/shared/Status";
@@ -35,18 +37,20 @@ export function RunActionsBar({
const actions = useRunViewActions();
const componentSpec = actions.ready ? actions.componentSpec : undefined;
const runId = actions.ready ? actions.runId : undefined;
+ const cancelAsTeamId = actions.ready ? actions.management.teamId : undefined;
const pipelineName = actions.ready ? actions.pipelineName : undefined;
const { yamlViewerOpen, openYamlViewer, closeYamlViewer } = useYamlViewer();
const { clone, isCloning } = useClonePipelineRun(componentSpec, runId);
- const { rerun, isRerunning } = useRerunPipelineRun(componentSpec);
+ const { rerun, isRerunning, canRerun, blocked, review } =
+ useRerunPipelineRun(componentSpec);
const {
cancelDialogOpen,
isCancelling,
requestCancel,
confirmCancel,
dismissCancel,
- } = useCancelPipelineRun(runId);
+ } = useCancelPipelineRun(runId, cancelAsTeamId);
const { inspect } = useInspectPipeline(pipelineName);
const { exportYaml } = useExportPipelineYaml(componentSpec, pipelineName);
@@ -72,9 +76,8 @@ export function RunActionsBar({
);
}
- const { canAccessEditorSpec, isRunCreator, isInProgress, isComplete } =
- actions;
- const showCancel = isInProgress && isRunCreator;
+ const { canAccessEditorSpec, management, isInProgress, isComplete } = actions;
+ const showCancel = isInProgress && management.canManage;
const showSeparator = showCancel || isComplete;
return (
@@ -150,11 +153,18 @@ export function RunActionsBar({
{isComplete && (
- Rerun Pipeline
+
+ Rerun Pipeline
+ {blocked && (
+
+
+
+ )}
+
)}
@@ -170,6 +180,8 @@ export function RunActionsBar({
onCancel={dismissCancel}
/>
+
+
{yamlViewerOpen && (
| undefined;
+let runCreatedBy = mockMetadata.created_by;
+
+const ML_TEAM = { id: "ml-team@example.com", label: "ML Research" };
vi.mock("@/routes/v2/shared/providers/SpecContext", () => ({
useSpec: () => mockSpec,
@@ -32,7 +43,11 @@ vi.mock("@/providers/ExecutionDataProvider", () => ({
useExecutionData: () => ({
rootDetails: { task_spec: { arguments: {} } },
rootState: { child_execution_status_stats: {} },
- metadata: { ...mockMetadata, execution_status_stats: runStats },
+ metadata: {
+ ...mockMetadata,
+ created_by: runCreatedBy,
+ execution_status_stats: runStats,
+ },
isLoading: false,
error: undefined,
}),
@@ -52,7 +67,11 @@ vi.mock("@/hooks/useUserDetails", () => ({
vi.mock("@/services/pipelineRunService", () => ({
fetchRunAnnotations: (...args: unknown[]) => fetchRunAnnotations(...args),
- updateRunAnnotation: vi.fn(),
+ updateRunAnnotation: (...args: unknown[]) => updateRunAnnotation(...args),
+}));
+
+vi.mock("@/services/teams/useRunTeam", () => ({
+ useRunTeam: vi.fn(),
}));
vi.mock("@/components/shared/Execution/PipelineIO", () => ({
@@ -99,10 +118,12 @@ const openDetailsSection = async () => {
beforeEach(() => {
queryClient.clear();
vi.clearAllMocks();
+ vi.mocked(useRunTeam).mockReturnValue({ status: "none" });
});
afterEach(() => {
cleanup();
+ runCreatedBy = mockMetadata.created_by;
});
describe("", () => {
@@ -138,6 +159,56 @@ describe("", () => {
expect(screen.queryByText("Run Annotations")).not.toBeInTheDocument();
});
+ describe("run notes", () => {
+ beforeEach(() => {
+ fetchRunAnnotations.mockResolvedValue({ notes: "First try" });
+ updateRunAnnotation.mockResolvedValue(undefined);
+ });
+
+ it("lets you edit the notes of a run you created", async () => {
+ renderPanel();
+ await openDetailsSection();
+
+ expect(await screen.findByDisplayValue("First try")).toBeInTheDocument();
+ });
+
+ it("saves a team's run notes as the team", async () => {
+ runCreatedBy = ML_TEAM.id;
+ vi.mocked(useRunTeam).mockReturnValue({
+ status: "active",
+ team: { ...ML_TEAM, canWrite: true },
+ });
+ renderPanel();
+ await openDetailsSection();
+
+ const notes = await screen.findByDisplayValue("First try");
+ fireEvent.change(notes, { target: { value: "Second try" } });
+ fireEvent.blur(notes);
+
+ await waitFor(() =>
+ expect(updateRunAnnotation).toHaveBeenCalledWith(
+ "run-1",
+ "http://backend",
+ { key: "notes", value: "Second try" },
+ ML_TEAM.id,
+ ),
+ );
+ });
+
+ it("shows a team's run notes read-only outside the team", async () => {
+ runCreatedBy = ML_TEAM.id;
+ vi.mocked(useRunTeam).mockReturnValue({
+ status: "not-member",
+ team: ML_TEAM,
+ });
+ renderPanel();
+ await openDetailsSection();
+
+ expect(await screen.findByText("First try")).toBeInTheDocument();
+ expect(screen.queryByRole("textbox")).toBeNull();
+ });
+ });
+
describe("with Tangent", () => {
beforeEach(() => {
tangentEnabled = true;
diff --git a/src/routes/v2/pages/RunView/components/RunDetailsContent.tsx b/src/routes/v2/pages/RunView/components/RunDetailsContent.tsx
index 63ecfa7acf..023c51d51a 100644
--- a/src/routes/v2/pages/RunView/components/RunDetailsContent.tsx
+++ b/src/routes/v2/pages/RunView/components/RunDetailsContent.tsx
@@ -37,6 +37,8 @@ import { useOpenInTangent } from "@/routes/v2/pages/RunView/hooks/useOpenInTange
import { PipelineDetailsCollapsibleSection } from "@/routes/v2/shared/components/PipelineDetailsCollapsibleSection";
import { useSpec } from "@/routes/v2/shared/providers/SpecContext";
import { runAnnotationsQueryOptions } from "@/services/runAnnotations";
+import { runManagement } from "@/services/teams/runManagement";
+import { useRunTeam } from "@/services/teams/useRunTeam";
import {
getAnnotationValue,
isSystemRunAnnotation,
@@ -362,8 +364,12 @@ function NotesSection({
metadata,
currentUserId,
}: NotesSectionProps) {
- const isRunCreator =
- !!currentUserId && metadata?.created_by === currentUserId;
+ const runTeam = useRunTeam(metadata?.id);
+ const management = runManagement(
+ metadata?.created_by,
+ currentUserId,
+ runTeam,
+ );
return (
@@ -376,7 +382,11 @@ function NotesSection({
{!!metadata?.id && (
Run Notes
-
+
)}
diff --git a/src/routes/v2/pages/RunView/components/RunToolsContent.tsx b/src/routes/v2/pages/RunView/components/RunToolsContent.tsx
index 9536098a20..69bbff2786 100644
--- a/src/routes/v2/pages/RunView/components/RunToolsContent.tsx
+++ b/src/routes/v2/pages/RunView/components/RunToolsContent.tsx
@@ -27,7 +27,7 @@ export const RunToolsContent = observer(function RunToolsContent({
componentSpec,
runId,
canAccessEditorSpec,
- isRunCreator,
+ management,
isInProgress,
isComplete,
pipelineName,
@@ -84,9 +84,10 @@ export const RunToolsContent = observer(function RunToolsContent({
{...tracking("v2.run_view.tools.clone_pipeline")}
/>
- {isInProgress && isRunCreator && (
+ {isInProgress && management.canManage && (
- Rerun Pipeline
+
+ Rerun Pipeline
+ {blocked && (
+
+
+
+ )}
+
)}
@@ -158,6 +170,8 @@ export function RunMenu() {
onCancel={dismissCancel}
/>
+
+
{yamlViewerOpen && (
cancelPipelineRun(runId, backendUrl),
+ mutationFn: (runId: string) => cancelPipelineRun(runId, backendUrl, teamId),
onSuccess: () => {
notify("Pipeline run cancelled", "success");
},
onError: (error) => {
- notify(`Error cancelling run: ${error}`, "error");
+ notify(`Error cancelling run: ${error.message}`, "error");
},
});
diff --git a/src/routes/v2/pages/RunView/hooks/useRerunPipelineRun.ts b/src/routes/v2/pages/RunView/hooks/useRerunPipelineRun.ts
index 0cf8cd1303..212aacea92 100644
--- a/src/routes/v2/pages/RunView/hooks/useRerunPipelineRun.ts
+++ b/src/routes/v2/pages/RunView/hooks/useRerunPipelineRun.ts
@@ -1,16 +1,31 @@
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
+import { useState } from "react";
import { isAuthorizationRequired } from "@/components/shared/Authentication/helpers";
import { useAuthLocalStorage } from "@/components/shared/Authentication/useAuthLocalStorage";
import { useAwaitAuthorization } from "@/components/shared/Authentication/useAwaitAuthorization";
import { buildTaskSpecShape } from "@/components/shared/PipelineRunNameTemplate/types";
+import {
+ needsReview,
+ type RunAs,
+ runAsFor,
+ type RunIdentity,
+ teamIdFor,
+} from "@/components/shared/SecretsManagement/runIdentity";
+import {
+ useRunAsChoice,
+ useRunIdentity,
+} from "@/components/shared/SecretsManagement/useRunIdentity";
import { useRerunProjectIds } from "@/hooks/useRerunProjectIds";
import useToastNotification from "@/hooks/useToastNotification";
+import { useUserDetails } from "@/hooks/useUserDetails";
import { useBackend } from "@/providers/BackendProvider";
-import { useExecutionData } from "@/providers/ExecutionDataProvider";
+import { useExecutionDataOptional } from "@/providers/ExecutionDataProvider";
import { useRunSubmissionAnnotations } from "@/providers/RunSubmissionScopeProvider";
-import { APP_ROUTES } from "@/routes/router";
+import { getDefaultRunPath } from "@/routes/runRoutes";
+import type { TeamResolution, TeamSummary } from "@/services/teams/types";
+import { useRunTeam } from "@/services/teams/useRunTeam";
import type { PipelineRun } from "@/types/pipelineRun";
import { extractCanonicalName } from "@/utils/canonicalPipelineName";
import type { ArgumentType, ComponentSpec } from "@/utils/componentSpec";
@@ -21,6 +36,48 @@ interface RerunVariables {
componentSpec: ComponentSpec;
canonicalName?: string;
taskArguments?: Record;
+ teamId?: string;
+}
+
+export interface RerunReview {
+ isOpen: boolean;
+ team: TeamResolution;
+ identity: RunIdentity;
+ runAs: RunAs;
+ chooseRunAs: (runAs: RunAs) => void;
+ confirm: () => void;
+ dismiss: () => void;
+}
+
+export type RerunBlock =
+ { kind: "team-only"; team: TeamSummary } | { kind: "team-unchecked" };
+
+function teamThatRan(
+ createdBy: string | null | undefined,
+ runTeam: TeamResolution,
+): TeamSummary | undefined {
+ if (
+ runTeam.status !== "active" &&
+ runTeam.status !== "not-member" &&
+ runTeam.status !== "unavailable"
+ ) {
+ return undefined;
+ }
+ return createdBy === runTeam.team.id ? runTeam.team : undefined;
+}
+
+function rerunBlock(
+ ranAs: TeamSummary | undefined,
+ runTeam: TeamResolution,
+ isYours: boolean,
+): RerunBlock | undefined {
+ if (ranAs && runTeam.status !== "active") {
+ return { kind: "team-only", team: ranAs };
+ }
+ if (runTeam.status === "error" && !isYours) {
+ return { kind: "team-unchecked" };
+ }
+ return undefined;
}
export function useRerunPipelineRun(componentSpec?: ComponentSpec) {
@@ -29,9 +86,30 @@ export function useRerunPipelineRun(componentSpec?: ComponentSpec) {
const { backendUrl } = useBackend();
const { awaitAuthorization, isAuthorized } = useAwaitAuthorization();
const { getToken } = useAuthLocalStorage();
- const { rootDetails, metadata } = useExecutionData();
+ const executionData = useExecutionDataOptional();
const runAnnotations = useRunSubmissionAnnotations();
const rerunProjectIds = useRerunProjectIds();
+ const [isReviewOpen, setIsReviewOpen] = useState(false);
+
+ const runId = executionData?.metadata?.id;
+ const taskSpec = executionData?.rootDetails?.task_spec;
+ const taskArguments = taskSpec?.arguments as
+ Record | undefined;
+
+ const createdBy = executionData?.metadata?.created_by;
+ const { data: currentUser } = useUserDetails();
+
+ const runTeam = useRunTeam(runId);
+ const identity = useRunIdentity(runTeam, [componentSpec, taskArguments]);
+ const { runAs: reviewedRunAs, choose: chooseRunAs } =
+ useRunAsChoice(identity);
+
+ const ranAsTeam = teamThatRan(createdBy, runTeam);
+ const blocked = rerunBlock(
+ ranAsTeam,
+ runTeam,
+ !!currentUser?.id && createdBy === currentUser.id,
+ );
const getAuthToken = async (): Promise => {
if (isAuthorizationRequired() && !isAuthorized) {
@@ -48,14 +126,16 @@ export function useRerunPipelineRun(componentSpec?: ComponentSpec) {
componentSpec,
canonicalName,
taskArguments,
+ teamId,
}: RerunVariables) => {
const authorizationToken = await getAuthToken();
- const projectIds = await rerunProjectIds(metadata?.id);
+ const projectIds = await rerunProjectIds(runId);
return new Promise((resolve, reject) => {
submitPipelineRun(componentSpec, backendUrl, {
canonicalName,
taskArguments,
authorizationToken,
+ teamId,
runAnnotations: {
...runAnnotations,
...projectRunAnnotations(projectIds),
@@ -66,7 +146,7 @@ export function useRerunPipelineRun(componentSpec?: ComponentSpec) {
});
},
onSuccess: (response) => {
- navigate({ to: `${APP_ROUTES.RUNS}/${response.id}` });
+ navigate({ to: getDefaultRunPath(response.id) });
},
onError: (error) => {
const message = `Failed to submit pipeline. ${error instanceof Error ? error.message : String(error)}`;
@@ -74,20 +154,50 @@ export function useRerunPipelineRun(componentSpec?: ComponentSpec) {
},
});
- const rerun = () => {
+ const submitRerun = (teamId: string | undefined) => {
if (!componentSpec) return;
- const canonicalName = extractCanonicalName(
- buildTaskSpecShape(rootDetails?.task_spec, componentSpec),
- );
-
rerunPipeline({
componentSpec,
- canonicalName,
- taskArguments: rootDetails?.task_spec.arguments as
- Record | undefined,
+ canonicalName: extractCanonicalName(
+ buildTaskSpecShape(taskSpec, componentSpec),
+ ),
+ taskArguments,
+ teamId,
});
};
- return { rerun, isRerunning };
+ const rerun = () => {
+ if (ranAsTeam) {
+ submitRerun(ranAsTeam.id);
+ return;
+ }
+ if (needsReview(identity)) {
+ chooseRunAs(undefined);
+ setIsReviewOpen(true);
+ return;
+ }
+ submitRerun(teamIdFor(identity, runAsFor(identity)));
+ };
+
+ const canRerun =
+ !blocked &&
+ runTeam.status !== "resolving" &&
+ runTeam.status !== "conflict" &&
+ !identity.isChecking;
+
+ const review: RerunReview = {
+ isOpen: isReviewOpen,
+ team: runTeam,
+ identity,
+ runAs: reviewedRunAs,
+ chooseRunAs,
+ confirm: () => {
+ setIsReviewOpen(false);
+ submitRerun(teamIdFor(identity, reviewedRunAs));
+ },
+ dismiss: () => setIsReviewOpen(false),
+ };
+
+ return { rerun, isRerunning, canRerun, blocked, review };
}
diff --git a/src/routes/v2/pages/RunView/hooks/useRunViewActions.ts b/src/routes/v2/pages/RunView/hooks/useRunViewActions.ts
index 7ac2be45d3..e41f040ea3 100644
--- a/src/routes/v2/pages/RunView/hooks/useRunViewActions.ts
+++ b/src/routes/v2/pages/RunView/hooks/useRunViewActions.ts
@@ -9,6 +9,11 @@ import { useUserDetails } from "@/hooks/useUserDetails";
import { useComponentSpec } from "@/providers/ComponentSpecProvider";
import { useExecutionData } from "@/providers/ExecutionDataProvider";
import { getDefaultEditorPath } from "@/routes/editorRoutes";
+import {
+ type RunManagement,
+ runManagement,
+} from "@/services/teams/runManagement";
+import { useRunTeam } from "@/services/teams/useRunTeam";
import { extractCanonicalName } from "@/utils/canonicalPipelineName";
import type { ComponentSpec } from "@/utils/componentSpec";
import {
@@ -22,7 +27,7 @@ interface RunViewActionsReady {
componentSpec: ComponentSpec;
runId: string | null | undefined;
canAccessEditorSpec: boolean;
- isRunCreator: boolean | "" | undefined;
+ management: RunManagement;
isInProgress: boolean;
isComplete: boolean;
pipelineName: string | undefined;
@@ -40,12 +45,9 @@ function resolveActions(
runId: string | null | undefined,
metadata: PipelineRunResponse | undefined,
details: GetExecutionInfoResponse | undefined,
- currentUserId: string | undefined,
+ management: RunManagement,
canAccessEditorSpec: boolean,
): RunViewActionsReady {
- const isRunCreator =
- !!currentUserId && metadata?.created_by === currentUserId;
-
const executionStatusStats =
metadata?.execution_status_stats ??
flattenExecutionStatusStats(state.child_execution_status_stats);
@@ -63,7 +65,7 @@ function resolveActions(
componentSpec,
runId,
canAccessEditorSpec,
- isRunCreator,
+ management,
isInProgress,
isComplete,
pipelineName,
@@ -79,6 +81,7 @@ export function useRunViewActions(): RunViewActions {
rootDetails: details,
} = useExecutionData();
const { data: currentUserDetails } = useUserDetails();
+ const runTeam = useRunTeam(metadata?.id);
const editorRoute = componentSpec?.name
? getDefaultEditorPath(componentSpec.name)
@@ -99,7 +102,7 @@ export function useRunViewActions(): RunViewActions {
runId,
metadata,
details,
- currentUserDetails?.id,
+ runManagement(metadata?.created_by, currentUserDetails?.id, runTeam),
canAccessEditorSpec,
);
}
diff --git a/src/services/pipelineRunService.test.ts b/src/services/pipelineRunService.test.ts
index 5fc666bbc8..77601a90b4 100644
--- a/src/services/pipelineRunService.test.ts
+++ b/src/services/pipelineRunService.test.ts
@@ -95,6 +95,41 @@ describe("createPipelineRun", () => {
});
});
+describe("changing a run", () => {
+ let mockFetch: Mock;
+
+ beforeEach(() => {
+ mockFetch = vi.fn();
+ global.fetch = mockFetch;
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("rejects a cancel with the reason the backend gave", async () => {
+ mockFetch.mockResolvedValue(
+ jsonResponse(403, { detail: "Only the run's creator can cancel it." }),
+ );
+
+ await expect(cancelPipelineRun("42", BACKEND_URL)).rejects.toThrow(
+ "Failed to cancel the run (403): Only the run's creator can cancel it.",
+ );
+ });
+
+ it("rejects an annotation with the reason the backend gave", async () => {
+ mockFetch.mockResolvedValue(
+ jsonResponse(403, { detail: "Only the run's creator can annotate it." }),
+ );
+
+ await expect(
+ updateRunAnnotation("42", BACKEND_URL, { key: "notes", value: "Hi" }),
+ ).rejects.toThrow(
+ "Failed to save the run's notes (403): Only the run's creator can annotate it.",
+ );
+ });
+});
+
describe("acting as a team", () => {
const TEAM_ID = "ml-team@project.iam.gserviceaccount.com";
const HEADER_NAME = "x-tangle-impersonate-team-id";
diff --git a/src/services/pipelineRunService.ts b/src/services/pipelineRunService.ts
index e387beffa6..45e16b732b 100644
--- a/src/services/pipelineRunService.ts
+++ b/src/services/pipelineRunService.ts
@@ -208,13 +208,19 @@ export const cancelPipelineRun = async (
backendUrl: string,
teamId?: string,
) => {
- await fetchWithErrorHandling(
+ const response = await fetch(
`${backendUrl}/api/pipeline_runs/${runId}/cancel`,
{
method: "POST",
headers: teamRequestHeaders(teamId),
},
);
+
+ if (!response.ok) {
+ throw new Error(
+ await requestFailureMessage(response, "Failed to cancel the run"),
+ );
+ }
};
export const fetchRunAnnotations = async (
@@ -239,8 +245,17 @@ export const updateRunAnnotation = async (
);
url.searchParams.append("value", annotation.value);
- await fetchWithErrorHandling(url.toString(), {
+ const response = await fetch(url.toString(), {
method: "PUT",
headers: teamRequestHeaders(teamId),
});
+
+ if (!response.ok) {
+ throw new Error(
+ await requestFailureMessage(
+ response,
+ `Failed to save the run's ${annotation.key}`,
+ ),
+ );
+ }
};
diff --git a/src/services/teams/runManagement.test.ts b/src/services/teams/runManagement.test.ts
new file mode 100644
index 0000000000..0d79883168
--- /dev/null
+++ b/src/services/teams/runManagement.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest";
+
+import { runManagement } from "./runManagement";
+import type { TeamResolution } from "./types";
+
+const TEAM = { id: "ml-team@example.com", label: "ML Research" };
+const activeTeam = (canWrite: boolean): TeamResolution => ({
+ status: "active",
+ team: { ...TEAM, canWrite },
+});
+
+describe("runManagement", () => {
+ it("lets you manage a run you created, as you", () => {
+ expect(runManagement("morgan", "morgan", { status: "none" })).toEqual({
+ canManage: true,
+ teamId: undefined,
+ });
+ });
+
+ it("lets a member manage a run the team created, as the team", () => {
+ expect(runManagement(TEAM.id, "morgan", activeTeam(true))).toEqual({
+ canManage: true,
+ teamId: TEAM.id,
+ });
+ });
+
+ it("keeps a run the team created from a member who can't write", () => {
+ expect(runManagement(TEAM.id, "morgan", activeTeam(false))).toEqual({
+ canManage: false,
+ teamId: TEAM.id,
+ });
+ });
+
+ it("keeps a run the team created from someone outside the team", () => {
+ expect(
+ runManagement(TEAM.id, "morgan", { status: "not-member", team: TEAM }),
+ ).toEqual({ canManage: false, teamId: undefined });
+ });
+
+ it("manages a run you created in a team's project as you", () => {
+ expect(runManagement("morgan", "morgan", activeTeam(true))).toEqual({
+ canManage: true,
+ teamId: undefined,
+ });
+ });
+
+ it("keeps someone else's run from you", () => {
+ expect(runManagement("riley", "morgan", activeTeam(true))).toEqual({
+ canManage: false,
+ teamId: undefined,
+ });
+ });
+
+ it("keeps a run from you until you're known", () => {
+ expect(runManagement(undefined, undefined, { status: "none" })).toEqual({
+ canManage: false,
+ teamId: undefined,
+ });
+ });
+});
diff --git a/src/services/teams/runManagement.ts b/src/services/teams/runManagement.ts
new file mode 100644
index 0000000000..86d4fff01a
--- /dev/null
+++ b/src/services/teams/runManagement.ts
@@ -0,0 +1,20 @@
+import type { TeamResolution } from "./types";
+
+export interface RunManagement {
+ canManage: boolean;
+ teamId: string | undefined;
+}
+
+export function runManagement(
+ createdBy: string | null | undefined,
+ currentUserId: string | undefined,
+ team: TeamResolution,
+): RunManagement {
+ if (team.status === "active" && createdBy === team.team.id) {
+ return { canManage: team.team.canWrite, teamId: team.team.id };
+ }
+ return {
+ canManage: !!currentUserId && createdBy === currentUserId,
+ teamId: undefined,
+ };
+}
diff --git a/src/services/teams/useRunTeam.test.tsx b/src/services/teams/useRunTeam.test.tsx
new file mode 100644
index 0000000000..3b9f03c090
--- /dev/null
+++ b/src/services/teams/useRunTeam.test.tsx
@@ -0,0 +1,91 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { renderHook, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { useProjectsEnabled } from "@/components/shared/Settings/useFlags";
+import { fetchRunAnnotations } from "@/services/pipelineRunService";
+import { projectRunAnnotations } from "@/utils/projectRunAnnotation";
+
+import { useRunTeam } from "./useRunTeam";
+import { useTeamForSubmission } from "./useTeamForSubmission";
+
+vi.mock("./useTeamForSubmission", () => ({
+ useTeamForSubmission: vi.fn(() => ({ status: "none" })),
+}));
+
+vi.mock("@/services/pipelineRunService", () => ({
+ fetchRunAnnotations: vi.fn(),
+}));
+
+vi.mock("@/components/shared/Settings/useFlags", () => ({
+ useProjectsEnabled: vi.fn(),
+}));
+
+vi.mock("@/providers/BackendProvider", () => ({
+ useBackend: () => ({ backendUrl: "https://backend.test" }),
+}));
+
+function resolveRunTeam(runId: string | undefined) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return renderHook(() => useRunTeam(runId), {
+ wrapper: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ });
+}
+
+describe("useRunTeam", () => {
+ beforeEach(() => {
+ vi.mocked(useProjectsEnabled).mockReturnValue(true);
+ });
+
+ afterEach(() => vi.clearAllMocks());
+
+ it("resolves the team of the projects the run is filed under", async () => {
+ vi.mocked(fetchRunAnnotations).mockResolvedValue(
+ projectRunAnnotations(["research-project"]),
+ );
+
+ const { result } = resolveRunTeam("42");
+
+ expect(result.current).toEqual({ status: "resolving" });
+ await waitFor(() => expect(result.current).toEqual({ status: "none" }));
+ expect(useTeamForSubmission).toHaveBeenLastCalledWith(["research-project"]);
+ expect(fetchRunAnnotations).toHaveBeenCalledWith(
+ "42",
+ "https://backend.test",
+ );
+ });
+
+ it("says the team couldn't be checked when the run's projects can't be read", async () => {
+ vi.mocked(fetchRunAnnotations).mockRejectedValue(new Error("backend down"));
+
+ const { result } = resolveRunTeam("42");
+
+ await waitFor(() =>
+ expect(result.current).toEqual({
+ status: "error",
+ error: new Error("backend down"),
+ }),
+ );
+ });
+
+ it("reads nothing without projects", () => {
+ vi.mocked(useProjectsEnabled).mockReturnValue(false);
+
+ const { result } = resolveRunTeam("42");
+
+ expect(result.current).toEqual({ status: "none" });
+ expect(fetchRunAnnotations).not.toHaveBeenCalled();
+ });
+
+ it("reads nothing before the run is known", () => {
+ const { result } = resolveRunTeam(undefined);
+
+ expect(result.current).toEqual({ status: "none" });
+ expect(fetchRunAnnotations).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/services/teams/useRunTeam.ts b/src/services/teams/useRunTeam.ts
new file mode 100644
index 0000000000..7b14fe6780
--- /dev/null
+++ b/src/services/teams/useRunTeam.ts
@@ -0,0 +1,29 @@
+import { useQuery } from "@tanstack/react-query";
+
+import { useProjectsEnabled } from "@/components/shared/Settings/useFlags";
+import { useBackend } from "@/providers/BackendProvider";
+import { runAnnotationsQueryOptions } from "@/services/runAnnotations";
+import { projectIdsFromAnnotations } from "@/utils/projectRunAnnotation";
+
+import type { TeamResolution } from "./types";
+import { useTeamForSubmission } from "./useTeamForSubmission";
+
+export function useRunTeam(
+ runId: string | number | null | undefined,
+): TeamResolution {
+ const { backendUrl } = useBackend();
+ const projectsEnabled = useProjectsEnabled();
+ const annotations = useQuery({
+ ...runAnnotationsQueryOptions(runId ?? undefined, backendUrl),
+ enabled: projectsEnabled && runId != null && String(runId) !== "",
+ });
+ const team = useTeamForSubmission(
+ projectIdsFromAnnotations(annotations.data),
+ );
+
+ if (annotations.isLoading) return { status: "resolving" };
+ if (annotations.isError) {
+ return { status: "error", error: annotations.error };
+ }
+ return team;
+}
diff --git a/src/services/teams/useTeamForRunAnnotations.test.ts b/src/services/teams/useTeamForRunAnnotations.test.ts
deleted file mode 100644
index 9253d1c544..0000000000
--- a/src/services/teams/useTeamForRunAnnotations.test.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { renderHook } from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
-
-vi.mock("./useTeamForProjects", () => ({
- useTeamForProjects: vi.fn(() => ({ status: "none" })),
-}));
-
-import { projectRunAnnotations } from "@/utils/projectRunAnnotation";
-
-import { useTeamForProjects } from "./useTeamForProjects";
-import { useTeamForRunAnnotations } from "./useTeamForRunAnnotations";
-
-describe("useTeamForRunAnnotations", () => {
- it("resolves the team of the projects a run is filed under", () => {
- const annotations = {
- ...projectRunAnnotations(["research-project", "data-project"]),
- source: "web-app",
- };
-
- renderHook(() => useTeamForRunAnnotations(annotations));
-
- expect(useTeamForProjects).toHaveBeenCalledWith([
- "research-project",
- "data-project",
- ]);
- });
-
- it("resolves no projects for a run filed under none", () => {
- renderHook(() => useTeamForRunAnnotations(null));
-
- expect(useTeamForProjects).toHaveBeenLastCalledWith([]);
- });
-});
diff --git a/src/services/teams/useTeamForRunAnnotations.ts b/src/services/teams/useTeamForRunAnnotations.ts
deleted file mode 100644
index e2062bbc19..0000000000
--- a/src/services/teams/useTeamForRunAnnotations.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { projectIdsFromAnnotations } from "@/utils/projectRunAnnotation";
-
-import type { TeamResolution } from "./types";
-import { useTeamForProjects } from "./useTeamForProjects";
-
-export function useTeamForRunAnnotations(
- annotations: Record | null | undefined,
-): TeamResolution {
- return useTeamForProjects(projectIdsFromAnnotations(annotations));
-}