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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- **`tableDetailSnapshot` says what its requests did when it fails** (#741). The file has failed in CI several times with a warehouse request that ended as a connection failure although a handler was registered for it; `detectWarehouse` swallows the error, so the log showed only the run view. Each test now records MSW's account of every request and what `fetch` rejected with, and prints it only if the test fails. Diagnostic only — the cause is not known yet and nothing in the application changed.
- **The contract drift test checks the `X-Provider-Key` header and accepts Builder's coming error codes** (#727). From the contract version that declares the header as a parameter (1.80.0, builder#994), every operation Studio sends it to — the five the probe finds — must carry it. The error examples Builder is adding (`build_queue_full`, `auth_throttled`, `server_overloaded`, and `unauthorized` on the 401; builder#1000, #994) are listed as shown by their message, each with the contract version that introduces it: Studio's CI reads Builder's main, so an entry may now name a version, and before that version the example may be absent or carry no code without failing the check either way. No application code changed.
- **The contract drift test checks that every route the client calls exists** (#727). The test compared response bodies only; nothing asserted that the method and path of a `builderApi` call are in Builder's contract. Each of the 59 client functions is now called with placeholder arguments against a stubbed `fetch`, and the method and path it sends must match a contract operation; a function the probe cannot drive to a request has to be listed, so a new one is not skipped silently. It runs in the same CI step as the rest of the drift test. No application code changed.
- **A publish whose outcome is unknown can be settled from the publish page** (#728). When a publish ends `publish_state_unknown`, Builder blocks sending it again, and Studio only said not to retry and to ask an operator — the two ways out Builder offers were not called from anywhere. The failure card now has a recovery panel: **Check the remote** (`POST …/publish/reconcile`) tells whether the destination exists — if it does the publish went through, if not the record is removed and the publish can be prepared again — and **Reset the record** (`DELETE …/publish/receipt`) deletes the record without checking, after a second click and a warning that nothing already published is undone. Neither runs on its own. In a deployment that takes the publish credential from the request, the remote check asks for the token again and sends it in `X-Publish-Credential` for that one request. `builderApi` gains `reconcilePublish` and `resetPublishReceipt`. The routes are declared in Builder's contract by builder#1009; until that is on Builder's main the contract drift check does not know them.
- **The minimum Builder API version is the one the client needs, and an older Builder is said on every page** (#725). `MIN_BUILDER_API_VERSION` said `1.18.0`; it is now `1.59.0`, measured as the first contract version that declares every route `builderApi` calls (the newest are the revision routes). A Builder below it was reported only by one line on Settings, while screens calling routes it does not have simply failed. The app-wide version banner now shows a non-dismissible alert naming the Builder's contract version and the minimum; the page under it still renders, since the routes an older Builder does serve keep working. Settings keeps its line.
- **`scripts/dev-with-builder.sh --real` starts a Builder Studio can use** (#730). It started Builder with neither `KPUBDATA_BUILDER_ALLOWED_ORIGINS` nor `--warehouse`: Builder's CORS is default-deny and Vite has no proxy, so every call from `localhost:5173` was refused, and without a warehouse the Tables and SQL screens got 404. It now allows `http://localhost:5173` and `http://127.0.0.1:5173` and passes `--warehouse build/warehouse` (a value already exported for either wins), and stops with an error when Builder is not ready in 30 seconds instead of starting Studio anyway. Without `--real` it no longer starts a Builder that demo mode never calls. The READMEs' manual steps carry the same two settings.
- **The contract drift test checks the provider list** (#727). Builder's `listProviders` response is `ProviderListResponse` in the contract and `providersResponseSchema` in Studio, so the name pairing never matched it and nothing compared the two. It is now mapped in `OPERATION_SCHEMAS` and every contract-valid provider list must parse. No application code changed.
Expand Down
2 changes: 2 additions & 0 deletions __tests__/contractConformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ const EXPECTED_OPERATIONS = [
"getBuildQuality",
"getPublishReadiness",
"publishBuild",
"reconcilePublish",
"resetPublishReceipt",
"getDatasetQualityHistory",
"query",
"getBuildSpecSnapshot",
Expand Down
181 changes: 181 additions & 0 deletions __tests__/publishRecovery.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* A publish that ended `publish_state_unknown` can be settled from the page (#728).
*
* Builder blocks a retry while it does not know whether the publish went through. Studio
* only said "do not retry"; the two ways out Builder offers — check the remote, or reset
* the record — were not called from anywhere. Neither may ever run on its own, and the
* reset needs a second, explicit click.
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { PublishRecoveryPanel } from "@/features/publish/PublishRecoveryPanel";
import { reconcilePublish, resetPublishReceipt } from "@/features/publish/api";

function response(status: number, body: unknown): Response {
return { ok: status >= 200 && status < 300, status, headers: new Headers(), text: async () => JSON.stringify(body) } as unknown as Response;
}

const RECONCILED = { run_id: "run-1", state: "succeeded", reconciled: true, fingerprint: "f1" };
const ABSENT = { run_id: "run-1", state: "reset", reconciled: true, retry_allowed: true, fingerprint: "f1" };
const RESET = { run_id: "run-1", state: "reset", retry_allowed: true, fingerprint: "f1" };

let fetchMock: ReturnType<typeof vi.fn>;

beforeEach(() => {
vi.stubEnv("VITE_USE_REAL_BUILDER", "true");
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
});

afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});

describe("publish recovery calls (#728)", () => {
it("reconcile posts the target and destination and reads a confirmed publish", async () => {
fetchMock.mockResolvedValue(response(200, RECONCILED));

const outcome = await reconcilePublish("run-1", "owner/data", undefined, { HF_TOKEN: "hf_secret" });

expect(outcome).toEqual({ kind: "confirmed" });
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toMatch(/\/builds\/run-1\/publish\/reconcile$/);
expect(init.method).toBe("POST");
expect(JSON.parse(init.body)).toEqual({ target: "huggingface", destination: "owner/data" });
// The credential travels in the header, never in the body or the URL (#615).
expect(init.headers["X-Publish-Credential"]).toBe("HF_TOKEN=hf_secret");
expect(String(url)).not.toContain("hf_secret");
expect(init.body).not.toContain("hf_secret");
});

it("reconcile reports a publish that never arrived as absent", async () => {
fetchMock.mockResolvedValue(response(200, ABSENT));
expect(await reconcilePublish("run-1", "owner/data")).toEqual({ kind: "absent" });
});

it("reset deletes the receipt named by target and destination", async () => {
fetchMock.mockResolvedValue(response(200, RESET));

expect(await resetPublishReceipt("run-1", "owner/data")).toEqual({ kind: "reset" });
const [url, init] = fetchMock.mock.calls[0];
expect(init.method).toBe("DELETE");
expect(new URL(String(url)).searchParams.get("target")).toBe("huggingface");
expect(new URL(String(url)).searchParams.get("destination")).toBe("owner/data");
});

it.each([
[404, { error: "publish receipt not found", code: "receipt_not_found" }, "nothing_to_settle"],
[503, { error: "remote state could not be determined; nothing was changed", code: "reconcile_unavailable" }, "unavailable"],
])("reads %i as %s, without retrying", async (status, body, kind) => {
fetchMock.mockResolvedValue(response(status as number, body));

expect((await reconcilePublish("run-1", "owner/data")).kind).toBe(kind);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("does not trust a response for another run", async () => {
fetchMock.mockResolvedValue(response(200, { ...RECONCILED, run_id: "someone-elses-run" }));
expect((await reconcilePublish("run-1", "owner/data")).kind).toBe("failed");
});

it("never echoes Builder's own error text", async () => {
fetchMock.mockResolvedValue(response(500, { error: "Traceback: /srv/secret/path" }));

const outcome = await reconcilePublish("run-1", "owner/data");

expect(outcome.kind).toBe("failed");
expect(JSON.stringify(outcome)).not.toContain("/srv/secret/path");
});
});

describe("PublishRecoveryPanel (#728)", () => {
function renderPanel(overrides: Partial<Parameters<typeof PublishRecoveryPanel>[0]> = {}) {
const onRetryAllowed = vi.fn();
const takeCredential = vi.fn(() => undefined);
render(
<PublishRecoveryPanel
runId="run-1"
destination="owner/data"
needsCredential={false}
takeCredential={takeCredential}
onRetryAllowed={onRetryAllowed}
{...overrides}
/>,
);
return { onRetryAllowed, takeCredential };
}

it("calls nothing until a button is pressed", () => {
renderPanel();
expect(fetchMock).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "원격 확인" })).toBeInTheDocument();
});

it("a confirmed publish offers no second publish", async () => {
fetchMock.mockResolvedValue(response(200, RECONCILED));
const { onRetryAllowed } = renderPanel();

fireEvent.click(screen.getByRole("button", { name: "원격 확인" }));

expect(await screen.findByText(/게시는 이루어졌습니다/)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "다시 게시 준비" })).not.toBeInTheDocument();
expect(onRetryAllowed).not.toHaveBeenCalled();
});

it("an absent publish can be prepared again, by the user's own click", async () => {
fetchMock.mockResolvedValue(response(200, ABSENT));
const { onRetryAllowed } = renderPanel();

fireEvent.click(screen.getByRole("button", { name: "원격 확인" }));
fireEvent.click(await screen.findByRole("button", { name: "다시 게시 준비" }));

expect(onRetryAllowed).toHaveBeenCalledTimes(1);
});

it("reset needs a second, explicit click and says what it does not undo", async () => {
fetchMock.mockResolvedValue(response(200, RESET));
renderPanel();

fireEvent.click(screen.getByRole("button", { name: "기록 초기화" }));
expect(fetchMock).not.toHaveBeenCalled();
expect(screen.getByText(/이미 올라간 것이 있다면 지워지지 않고/)).toBeInTheDocument();

fireEvent.click(screen.getByRole("button", { name: "초기화 확정" }));

await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
expect(fetchMock.mock.calls[0][1].method).toBe("DELETE");
expect(await screen.findByText(/원격에 이미 올라간 것은 그대로입니다/)).toBeInTheDocument();
});

it("an unreadable remote changes nothing and offers no publish", async () => {
fetchMock.mockResolvedValue(response(503, { error: "x", code: "reconcile_unavailable" }));
renderPanel();

fireEvent.click(screen.getByRole("button", { name: "원격 확인" }));

expect(await screen.findByText(/아무것도 바뀌지 않았습니다/)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "다시 게시 준비" })).not.toBeInTheDocument();
});

it("does not check the remote without the token a request-credential deployment needs", () => {
const { takeCredential } = renderPanel({ needsCredential: true });

fireEvent.click(screen.getByRole("button", { name: "원격 확인" }));

expect(takeCredential).toHaveBeenCalledTimes(1);
expect(fetchMock).not.toHaveBeenCalled();
expect(screen.getByRole("alert")).toHaveTextContent("게시 토큰이 필요합니다");
});

it("sends the token it was handed in the header, for that one request", async () => {
fetchMock.mockResolvedValue(response(200, RECONCILED));
renderPanel({ needsCredential: true, takeCredential: vi.fn(() => ({ HF_TOKEN: "hf_once" })) });

fireEvent.click(screen.getByRole("button", { name: "원격 확인" }));

await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
expect(fetchMock.mock.calls[0][1].headers["X-Publish-Credential"]).toBe("HF_TOKEN=hf_once");
});
});
104 changes: 104 additions & 0 deletions src/features/publish/PublishRecoveryPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* What a user can do when a publish ends `publish_state_unknown` (#728).
*
* Builder blocks a retry while it does not know whether the publish went through: sending
* it again could publish twice. It offers two ways out, and this panel is where Studio
* calls them:
*
* - **Check the remote** (`reconcile`): Builder looks at the destination. If it is there,
* the publish did go through; if not, the receipt is removed and the publish can be sent
* again.
* - **Reset the record** (`reset`): the receipt is deleted without looking. Nothing is
* undone remotely, so this asks for a second, explicit click.
*
* Neither is ever called automatically. A deployment that takes the publish credential
* from the request needs the token again for the remote check — Studio dropped it when
* the publish started (#615).
*/
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";

import {
reconcilePublish,
resetPublishReceipt,
type PublishCredential,
type PublishRecoveryOutcome,
} from "@/features/publish/api";
import { Button } from "@/shared/ui";

interface Props {
runId: string;
destination: string;
/** The deployment reads the publish credential from the request, so the check needs a token. */
needsCredential: boolean;
/** Takes the token entered on the page, and clears it there: used for one request only. */
takeCredential: () => PublishCredential | undefined;
/** Called when nothing blocks a new publish any more. */
onRetryAllowed: () => void;
}

const RETRY_ALLOWED: ReadonlySet<PublishRecoveryOutcome["kind"]> = new Set(["absent", "reset", "nothing_to_settle"]);

export function PublishRecoveryPanel({ runId, destination, needsCredential, takeCredential, onRetryAllowed }: Props) {
const { t } = useTranslation();
const [working, setWorking] = useState<"reconcile" | "reset" | null>(null);
const [outcome, setOutcome] = useState<PublishRecoveryOutcome>();
const [confirmingReset, setConfirmingReset] = useState(false);
const [credentialMissing, setCredentialMissing] = useState(false);
const inFlight = useRef(false);

async function run(action: "reconcile" | "reset") {
if (inFlight.current) return;
const credential = needsCredential ? takeCredential() : undefined;
// The remote can only be read with the requester's own credential (builder#925).
if (action === "reconcile" && needsCredential && !credential) {
setCredentialMissing(true);
return;
}
inFlight.current = true;
setCredentialMissing(false);
setWorking(action);
setOutcome(undefined);
try {
const call = action === "reconcile" ? reconcilePublish : resetPublishReceipt;
setOutcome(await call(runId, destination, undefined, credential));
} finally {
inFlight.current = false;
setWorking(null);
setConfirmingReset(false);
}
}

return (
<div className="mt-4 rounded-lg border border-border bg-card p-4 text-sm text-foreground" data-publish-recovery>
<h3 className="font-semibold">{t("buildPublish.recovery.title")}</h3>
<p className="mt-1 text-muted-foreground">{t("buildPublish.recovery.explain")}</p>
<div className="mt-3 flex flex-wrap gap-3">
<Button size="sm" loading={working === "reconcile"} disabled={working !== null} onClick={() => void run("reconcile")}>
{t("buildPublish.recovery.reconcile")}
</Button>
{confirmingReset ? (
<Button size="sm" variant="secondary" loading={working === "reset"} disabled={working !== null} onClick={() => void run("reset")}>
{t("buildPublish.recovery.resetConfirm")}
</Button>
) : (
<Button size="sm" variant="ghost" disabled={working !== null} onClick={() => setConfirmingReset(true)}>
{t("buildPublish.recovery.reset")}
</Button>
)}
</div>
{confirmingReset ? <p className="mt-2 text-xs text-status-warning">{t("buildPublish.recovery.resetWarning")}</p> : null}
{credentialMissing ? <p className="mt-2 text-xs text-status-warning" role="alert">{t("buildPublish.recovery.tokenNeeded")}</p> : null}
{outcome ? (
<div className="mt-3" role="status" data-recovery-outcome={outcome.kind}>
<p>{outcome.kind === "failed" ? outcome.message : t(`buildPublish.recovery.outcome.${outcome.kind}`)}</p>
{RETRY_ALLOWED.has(outcome.kind) ? (
<Button className="mt-2" size="sm" variant="secondary" onClick={onRetryAllowed}>
{t("buildPublish.recovery.publishAgain")}
</Button>
) : null}
</div>
) : null}
</div>
);
}
Loading
Loading