From 4ecd9b71ee78c855659f6747570c27797b1a6ba7 Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Thu, 22 Oct 2026 15:02:37 -0700 Subject: [PATCH 1/2] feat(runs): submit as the project's team A run now acts as the team of the projects it's filed under. The submit button, the arguments dialog and a project's Run pipeline button all send the team header when the team resolves to one you're in, and write the run's notes and tags as that team too, since the backend only lets the run's creator annotate it. When a run would fall back to you (you're not in the team, the team can't be acted as, or the team couldn't be checked), or a secret the pipeline references isn't in the secrets the run will see, the quick submit button opens the arguments dialog instead of submitting. The dialog says who the run runs as, explains a fallback, and names the missing secrets. Missing secrets warn rather than block: the list can lag behind a secret added elsewhere. Submit waits while the team resolves and while the secrets are checked, and is refused for projects on different teams. The arguments button stays available in that case, so another project can be chosen for the run. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Project/RunPipelineButton.test.tsx | 40 +++ src/components/Project/RunPipelineButton.tsx | 22 +- .../useMissingSecrets.test.tsx | 109 ++++++++ .../SecretsManagement/useMissingSecrets.ts | 39 +++ .../Tangle/TangleSubmitter.test.tsx | 247 ++++++++++++++++++ .../Submitters/Tangle/TangleSubmitter.tsx | 61 ++++- .../components/RunIdentitySummary.test.tsx | 85 ++++++ .../Tangle/components/RunIdentitySummary.tsx | 76 ++++++ .../components/SubmitTaskArgumentsDialog.tsx | 17 +- .../Submitters/Tangle/useSubmitPipeline.ts | 3 + src/services/teams/teamResolution.test.ts | 24 +- src/services/teams/teamResolution.ts | 8 + 12 files changed, 719 insertions(+), 12 deletions(-) create mode 100644 src/components/shared/SecretsManagement/useMissingSecrets.test.tsx create mode 100644 src/components/shared/SecretsManagement/useMissingSecrets.ts create mode 100644 src/components/shared/Submitters/Tangle/TangleSubmitter.test.tsx create mode 100644 src/components/shared/Submitters/Tangle/components/RunIdentitySummary.test.tsx create mode 100644 src/components/shared/Submitters/Tangle/components/RunIdentitySummary.tsx diff --git a/src/components/Project/RunPipelineButton.test.tsx b/src/components/Project/RunPipelineButton.test.tsx index d692ab9ef8..b979a93546 100644 --- a/src/components/Project/RunPipelineButton.test.tsx +++ b/src/components/Project/RunPipelineButton.test.tsx @@ -178,6 +178,46 @@ describe("RunPipelineButton", () => { "42", "https://backend.test", expect.objectContaining({ notes: "why this run" }), + undefined, + ); + }); + + it("starts the run as the project's team", async () => { + vi.mocked(useTeamForSubmission).mockReturnValue({ + status: "active", + team: { id: "ml-team@example.com", label: "ML Research", canWrite: true }, + }); + renderButton({}); + const user = userEvent.setup(); + + await user.click(runButton()); + await user.click(screen.getByRole("button", { name: "Submit Run" })); + submit.mock.calls[0][0].onSuccess({ id: 42 }); + + expect(submit).toHaveBeenCalledWith( + expect.objectContaining({ teamId: "ml-team@example.com" }), + ); + expect(saveAnnotations).toHaveBeenCalledWith( + "42", + "https://backend.test", + expect.objectContaining({ notes: "why this run" }), + "ml-team@example.com", + ); + }); + + it("starts the run as you when you're not in the project's team", async () => { + vi.mocked(useTeamForSubmission).mockReturnValue({ + status: "not-member", + team: { id: "ml-team@example.com", label: "ML Research" }, + }); + renderButton({}); + const user = userEvent.setup(); + + await user.click(runButton()); + await user.click(screen.getByRole("button", { name: "Submit Run" })); + + expect(submit).toHaveBeenCalledWith( + expect.objectContaining({ teamId: undefined }), ); }); diff --git a/src/components/Project/RunPipelineButton.tsx b/src/components/Project/RunPipelineButton.tsx index e3efea5258..489b2f6e76 100644 --- a/src/components/Project/RunPipelineButton.tsx +++ b/src/components/Project/RunPipelineButton.tsx @@ -9,6 +9,7 @@ import { Icon } from "@/components/ui/icon"; import useToastNotification from "@/hooks/useToastNotification"; import { useAnalytics } from "@/providers/AnalyticsProvider"; import { useBackend } from "@/providers/BackendProvider"; +import { actingTeamId } from "@/services/teams/teamResolution"; import { useTeamForSubmission } from "@/services/teams/useTeamForSubmission"; import type { PipelineRun } from "@/types/pipelineRun"; import type { ArgumentType, ComponentSpec } from "@/utils/componentSpec"; @@ -35,20 +36,35 @@ export function RunPipelineButton({ const runTeam = useTeamForSubmission([projectId]); const { mutate: saveAnnotations } = useMutation({ - mutationFn: ({ runId, notes }: { runId: string; notes: string }) => - saveRunAnnotations(runId, backendUrl, { notes, componentSpec: spec }), + mutationFn: ({ + runId, + notes, + teamId, + }: { + runId: string; + notes: string; + teamId?: string; + }) => + saveRunAnnotations( + runId, + backendUrl, + { notes, componentSpec: spec }, + teamId, + ), }); const runnable = pipelineValidity(spec) !== "invalid"; const run = (taskArguments: Record, notes: string) => { setDialogOpen(false); + const teamId = actingTeamId(runTeam); submit({ componentSpec: spec, taskArguments, projectIds: [projectId], + teamId, onSuccess: (run: PipelineRun) => { - saveAnnotations({ runId: run.id.toString(), notes }); + saveAnnotations({ runId: run.id.toString(), notes, teamId }); track("projects.run_pipeline_completed", {}); notify("Run started in this project", "success"); }, diff --git a/src/components/shared/SecretsManagement/useMissingSecrets.test.tsx b/src/components/shared/SecretsManagement/useMissingSecrets.test.tsx new file mode 100644 index 0000000000..b466bd5e74 --- /dev/null +++ b/src/components/shared/SecretsManagement/useMissingSecrets.test.tsx @@ -0,0 +1,109 @@ +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 type { TeamResolution } from "@/services/teams/types"; + +import { createSecretArgument, type Secret } from "./types"; +import { useMissingSecrets } from "./useMissingSecrets"; + +const { fetchSecrets } = vi.hoisted(() => ({ fetchSecrets: vi.fn() })); + +vi.mock("./secretsStorage", () => ({ + secretsListQuery: (teamId?: string) => ({ + queryKey: ["secrets", teamId ?? "user"], + queryFn: () => fetchSecrets(teamId), + }), +})); + +const TEAM = { id: "ml-team@example.com", label: "ML Research" }; +const ACTIVE: TeamResolution = { + status: "active", + team: { ...TEAM, canWrite: true }, +}; + +const secret = (name: string): Secret => ({ + id: name, + name, + createdAt: new Date("2026-10-01T09:00:00Z"), + updatedAt: new Date("2026-10-02T09:00:00Z"), +}); + +const usingSecrets = (...names: string[]) => + Object.fromEntries(names.map((name) => [name, createSecretArgument(name)])); + +function check(resolution: TeamResolution, sources: unknown[]) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return renderHook(() => useMissingSecrets(resolution, sources), { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); +} + +describe("useMissingSecrets", () => { + beforeEach(() => { + fetchSecrets.mockResolvedValue([secret("API_KEY")]); + }); + + afterEach(() => vi.clearAllMocks()); + + it("names the referenced secrets the team doesn't have", async () => { + const { result } = check(ACTIVE, [usingSecrets("API_KEY", "DB_PASSWORD")]); + + await waitFor(() => expect(result.current.isChecking).toBe(false)); + expect(result.current.missing).toEqual(["DB_PASSWORD"]); + expect(fetchSecrets).toHaveBeenCalledWith(TEAM.id); + }); + + it("checks your own secrets when the run falls back to you", async () => { + const { result } = check({ status: "not-member", team: TEAM }, [ + usingSecrets("API_KEY"), + ]); + + await waitFor(() => expect(result.current.isChecking).toBe(false)); + expect(result.current.missing).toEqual([]); + expect(fetchSecrets).toHaveBeenCalledWith(undefined); + }); + + it("checks nothing for a run that uses no secrets", () => { + const { result } = check(ACTIVE, [{ greeting: "hello" }]); + + expect(result.current).toEqual({ missing: [], isChecking: false }); + expect(fetchSecrets).not.toHaveBeenCalled(); + }); + + it("waits for the team before checking", () => { + const { result } = check({ status: "resolving" }, [ + usingSecrets("API_KEY"), + ]); + + expect(result.current).toEqual({ missing: [], isChecking: true }); + expect(fetchSecrets).not.toHaveBeenCalled(); + }); + + it("checks nothing for projects on different teams", () => { + const { result } = check( + { + status: "conflict", + teams: [TEAM, { id: "data@example.com", label: "Data" }], + }, + [usingSecrets("API_KEY")], + ); + + expect(result.current).toEqual({ missing: [], isChecking: false }); + expect(fetchSecrets).not.toHaveBeenCalled(); + }); + + it("names nothing when the secrets can't be listed", async () => { + fetchSecrets.mockRejectedValue(new Error("backend down")); + + const { result } = check(ACTIVE, [usingSecrets("API_KEY")]); + + await waitFor(() => expect(result.current.isChecking).toBe(false)); + expect(result.current.missing).toEqual([]); + }); +}); diff --git a/src/components/shared/SecretsManagement/useMissingSecrets.ts b/src/components/shared/SecretsManagement/useMissingSecrets.ts new file mode 100644 index 0000000000..f46593871b --- /dev/null +++ b/src/components/shared/SecretsManagement/useMissingSecrets.ts @@ -0,0 +1,39 @@ +import { useQuery } from "@tanstack/react-query"; + +import { actingTeamId } from "@/services/teams/teamResolution"; +import type { TeamResolution } from "@/services/teams/types"; + +import { missingSecretNames, referencedSecretNames } from "./secretReferences"; +import { secretsListQuery } from "./secretsStorage"; + +interface MissingSecrets { + missing: string[]; + isChecking: boolean; +} + +export function useMissingSecrets( + resolution: TeamResolution, + sources: readonly unknown[], +): MissingSecrets { + const referenced = referencedSecretNames(...sources); + const settled = + resolution.status !== "resolving" && resolution.status !== "conflict"; + const shouldCheck = settled && referenced.length > 0; + + const { data: secrets, isPending } = useQuery({ + ...secretsListQuery(actingTeamId(resolution)), + enabled: shouldCheck, + }); + + if (referenced.length === 0) return { missing: [], isChecking: false }; + if (resolution.status === "resolving") + return { missing: [], isChecking: true }; + if (!shouldCheck) return { missing: [], isChecking: false }; + if (isPending) return { missing: [], isChecking: true }; + if (!secrets) return { missing: [], isChecking: false }; + + return { + missing: missingSecretNames(referenced, secrets), + isChecking: false, + }; +} diff --git a/src/components/shared/Submitters/Tangle/TangleSubmitter.test.tsx b/src/components/shared/Submitters/Tangle/TangleSubmitter.test.tsx new file mode 100644 index 0000000000..45e7d55bcf --- /dev/null +++ b/src/components/shared/Submitters/Tangle/TangleSubmitter.test.tsx @@ -0,0 +1,247 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useMissingSecrets } from "@/components/shared/SecretsManagement/useMissingSecrets"; +import type { TeamResolution } from "@/services/teams/types"; +import { useTeamForSubmission } from "@/services/teams/useTeamForSubmission"; +import type { ArgumentType, ComponentSpec } from "@/utils/componentSpec"; + +import TangleSubmitter from "./TangleSubmitter"; + +const submit = vi.fn(); +const saveAnnotations = vi.fn(); + +vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() })); + +vi.mock("@tanstack/react-query", () => ({ + useMutation: ({ + mutationFn, + }: { + mutationFn: (variables: unknown) => unknown; + }) => ({ mutate: mutationFn }), +})); + +vi.mock("@/components/shared/Authentication/useAwaitAuthorization", () => ({ + useAwaitAuthorization: () => ({ isAuthorized: true }), +})); + +vi.mock("@/components/shared/Settings/useFlags", () => ({ + useFlagValue: () => false, +})); + +vi.mock("@/hooks/useToastNotification", () => ({ default: () => vi.fn() })); + +vi.mock("@/providers/BackendProvider", () => ({ + useBackend: () => ({ + backendUrl: "https://backend.test", + configured: true, + available: true, + }), +})); + +vi.mock("@/providers/TourProvider/tourMockBackend", () => ({ + useTourMockBackend: () => false, +})); + +vi.mock("@/components/shared/SecretsManagement/useMissingSecrets", () => ({ + useMissingSecrets: vi.fn(), +})); + +vi.mock("@/services/teams/useTeamForSubmission", () => ({ + useTeamForSubmission: vi.fn(), +})); + +vi.mock("@/components/Project/RunProjectField", () => ({ + RunProjectField: () => null, +})); + +vi.mock("./useRunAttribution", () => ({ + useRunAttribution: () => ({ + projectId: "project-chosen", + setProjectId: vi.fn(), + resetToContext: vi.fn(), + defaultProjectIds: ["project-page"], + chosenProjectIds: ["project-chosen"], + }), +})); + +vi.mock("./useSubmitPipeline", () => ({ + useSubmitPipeline: () => ({ mutate: submit, isPending: false }), +})); + +vi.mock("./saveRunAnnotations", () => ({ + saveRunAnnotations: (...args: unknown[]) => saveAnnotations(...args), +})); + +vi.mock("./components/SubmitTaskArgumentsDialog", () => ({ + SubmitTaskArgumentsDialog: ({ + open, + onConfirm, + team, + }: { + open: boolean; + onConfirm: (args: Record, notes: string) => void; + team: TeamResolution; + }) => + open ? ( +
+ +
+ ) : null, +})); + +const spec: ComponentSpec = { + name: "churn-training", + implementation: { + graph: { + tasks: { + Greet: { + componentRef: { + spec: { + name: "Greet", + implementation: { + container: { image: "python:3.11", command: ["echo"] }, + }, + }, + }, + }, + }, + }, + }, +}; + +const ML_TEAM = { id: "ml-team@example.com", label: "ML Research" }; +const DATA_TEAM = { id: "data@example.com", label: "Data" }; + +function withTeams(page: TeamResolution, chosen: TeamResolution = page) { + vi.mocked(useTeamForSubmission).mockImplementation((projectIds) => + projectIds.includes("project-page") ? page : chosen, + ); +} + +const submitButton = () => screen.getByRole("button", { name: /Submit Run/ }); + +describe("TangleSubmitter", () => { + beforeEach(() => { + withTeams({ status: "none" }); + vi.mocked(useMissingSecrets).mockReturnValue({ + missing: [], + isChecking: false, + }); + }); + + afterEach(() => vi.clearAllMocks()); + + it("submits as the page's team and says so", async () => { + withTeams({ status: "active", team: { ...ML_TEAM, canWrite: true } }); + render(); + const user = userEvent.setup(); + + expect(screen.getByText("as ML Research")).toHaveAttribute( + "title", + ML_TEAM.id, + ); + await user.click(submitButton()); + + expect(submit).toHaveBeenCalledWith( + expect.objectContaining({ + projectIds: ["project-page"], + teamId: ML_TEAM.id, + }), + ); + submit.mock.calls[0][0].onSuccess({ id: 42 }); + expect(saveAnnotations).toHaveBeenCalledWith( + "42", + "https://backend.test", + expect.anything(), + ML_TEAM.id, + ); + }); + + it("asks you to review a run that would run as you instead of the team", async () => { + withTeams({ status: "not-member", team: ML_TEAM }); + render(); + const user = userEvent.setup(); + + await user.click(submitButton()); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(submit).not.toHaveBeenCalled(); + }); + + it("asks you to review a run whose secrets are missing", async () => { + vi.mocked(useMissingSecrets).mockReturnValue({ + missing: ["API_KEY"], + isChecking: false, + }); + render(); + const user = userEvent.setup(); + + await user.click(submitButton()); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(submit).not.toHaveBeenCalled(); + }); + + it("submits straight away outside a team", async () => { + render(); + const user = userEvent.setup(); + + await user.click(submitButton()); + + expect(submit).toHaveBeenCalledWith( + expect.objectContaining({ teamId: undefined }), + ); + }); + + it("waits for the page's team before submitting", () => { + withTeams({ status: "resolving" }); + render(); + + expect(submitButton()).toBeDisabled(); + }); + + it("waits for the secrets check before submitting", () => { + vi.mocked(useMissingSecrets).mockReturnValue({ + missing: [], + isChecking: true, + }); + render(); + + expect(submitButton()).toBeDisabled(); + }); + + it("won't submit for projects on different teams", () => { + withTeams({ status: "conflict", teams: [ML_TEAM, DATA_TEAM] }); + render(); + + expect(submitButton()).toBeDisabled(); + expect(screen.getByText("(projects on different teams)")).toBeVisible(); + }); + + it("submits a reviewed run as the team of the project chosen for it", async () => { + withTeams( + { status: "not-member", team: ML_TEAM }, + { status: "active", team: { ...DATA_TEAM, canWrite: true } }, + ); + render(); + const user = userEvent.setup(); + + await user.click(submitButton()); + expect(screen.getByRole("dialog")).toHaveAttribute( + "data-team-status", + "active", + ); + await user.click(screen.getByRole("button", { name: "Confirm run" })); + + expect(submit).toHaveBeenCalledWith( + expect.objectContaining({ + projectIds: ["project-chosen"], + teamId: DATA_TEAM.id, + }), + ); + }); +}); diff --git a/src/components/shared/Submitters/Tangle/TangleSubmitter.tsx b/src/components/shared/Submitters/Tangle/TangleSubmitter.tsx index 3767edff6d..4f136c2c10 100644 --- a/src/components/shared/Submitters/Tangle/TangleSubmitter.tsx +++ b/src/components/shared/Submitters/Tangle/TangleSubmitter.tsx @@ -5,6 +5,7 @@ import { type MouseEvent, useRef, useState } from "react"; import { RunProjectField } from "@/components/Project/RunProjectField"; import { useAwaitAuthorization } from "@/components/shared/Authentication/useAwaitAuthorization"; +import { useMissingSecrets } from "@/components/shared/SecretsManagement/useMissingSecrets"; import { useFlagValue } from "@/components/shared/Settings/useFlags"; import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; @@ -15,6 +16,7 @@ import { cn } from "@/lib/utils"; import { useBackend } from "@/providers/BackendProvider"; import { useTourMockBackend } from "@/providers/TourProvider/tourMockBackend"; import { getDefaultRunPath } from "@/routes/runRoutes"; +import { actingTeamId, fallsBackToYou } from "@/services/teams/teamResolution"; import { useTeamForSubmission } from "@/services/teams/useTeamForSubmission"; import type { PipelineRun } from "@/types/pipelineRun"; import { @@ -50,7 +52,10 @@ const TangleSubmitter = ({ const { mutate: submit, isPending: isSubmitting } = useSubmitPipeline(); const isAutoRedirect = useFlagValue("redirect-on-new-pipeline-run"); const attribution = useRunAttribution(); + const pageTeam = useTeamForSubmission(attribution.defaultProjectIds); const chosenProjectsTeam = useTeamForSubmission(attribution.chosenProjectIds); + const { missing: missingPageSecrets, isChecking: isCheckingPageSecrets } = + useMissingSecrets(pageTeam, [componentSpec]); const [submitSuccess, setSubmitSuccess] = useState(null); const [isArgumentsDialogOpen, setIsArgumentsDialogOpen] = useState(false); @@ -59,13 +64,19 @@ const TangleSubmitter = ({ const navigate = useNavigate(); const runNotes = useRef(""); + const runTeamId = useRef(undefined); const { mutate: saveAnnotations } = useMutation({ mutationFn: (runId: string) => - saveRunAnnotations(runId, backendUrl, { - notes: runNotes.current, - componentSpec, - }), + saveRunAnnotations( + runId, + backendUrl, + { + notes: runNotes.current, + componentSpec, + }, + runTeamId.current, + ), }); const handleError = (message: string) => { @@ -130,6 +141,7 @@ const TangleSubmitter = ({ const handleSubmit = async ( taskArguments?: Record, runProjectIds: string[] = attribution.defaultProjectIds, + teamId: string | undefined = actingTeamId(pageTeam), ) => { if (!componentSpec) { handleError("No pipeline to submit"); @@ -152,10 +164,12 @@ const TangleSubmitter = ({ } setSubmitSuccess(null); + runTeamId.current = teamId; submit({ componentSpec, taskArguments, projectIds: runProjectIds, + teamId, onSuccess, onError, }); @@ -167,7 +181,22 @@ const TangleSubmitter = ({ ) => { runNotes.current = notes; setIsArgumentsDialogOpen(false); - handleSubmit(args, attribution.chosenProjectIds); + handleSubmit( + args, + attribution.chosenProjectIds, + actingTeamId(chosenProjectsTeam), + ); + }; + + const pageTeamNeedsReview = + fallsBackToYou(pageTeam) || missingPageSecrets.length > 0; + + const handleQuickSubmit = () => { + if (pageTeamNeedsReview) { + openArgumentsDialog(); + return; + } + handleSubmit(); }; const hasConfigurableInputs = (componentSpec?.inputs?.length ?? 0) > 0; @@ -191,6 +220,11 @@ const TangleSubmitter = ({ isGraphImplementation(componentSpec.implementation) && Object.keys(componentSpec.implementation.graph.tasks).length > 0; + const isPageTeamUnsettled = + pageTeam.status === "resolving" || + pageTeam.status === "conflict" || + isCheckingPageSecrets; + const isButtonDisabled = isSubmitting || !isSubmittable; const isArgumentsButtonVisible = @@ -216,13 +250,26 @@ const TangleSubmitter = ({ <>