From fbcd696cdfcab0d757dc36db5d98b0167b017162 Mon Sep 17 00:00:00 2001 From: Eomdahyeon <213566566+Eomdahyeon@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:11:48 +0900 Subject: [PATCH 1/2] feat(publish): settle a publish whose outcome is unknown from the publish page Closes #728 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/contractConformance.test.ts | 2 + __tests__/publishRecovery.test.tsx | 181 ++++++++++++++++++ src/features/publish/PublishRecoveryPanel.tsx | 104 ++++++++++ src/features/publish/api/index.ts | 65 +++++++ src/pages/BuildPublishPage.tsx | 21 ++ src/shared/i18n/locales/en.json | 24 ++- src/shared/i18n/locales/ko.json | 24 ++- src/shared/lib/builderApi.schema.ts | 28 +++ src/shared/lib/builderApi.ts | 36 ++++ 10 files changed, 480 insertions(+), 6 deletions(-) create mode 100644 __tests__/publishRecovery.test.tsx create mode 100644 src/features/publish/PublishRecoveryPanel.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 568a08fc..d1a50210 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,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. diff --git a/__tests__/contractConformance.test.ts b/__tests__/contractConformance.test.ts index 338eae54..76198a3e 100644 --- a/__tests__/contractConformance.test.ts +++ b/__tests__/contractConformance.test.ts @@ -40,6 +40,8 @@ const EXPECTED_OPERATIONS = [ "getBuildQuality", "getPublishReadiness", "publishBuild", + "reconcilePublish", + "resetPublishReceipt", "getDatasetQualityHistory", "query", "getBuildSpecSnapshot", diff --git a/__tests__/publishRecovery.test.tsx b/__tests__/publishRecovery.test.tsx new file mode 100644 index 00000000..c2de61b4 --- /dev/null +++ b/__tests__/publishRecovery.test.tsx @@ -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; + +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[0]> = {}) { + const onRetryAllowed = vi.fn(); + const takeCredential = vi.fn(() => undefined); + render( + , + ); + 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"); + }); +}); diff --git a/src/features/publish/PublishRecoveryPanel.tsx b/src/features/publish/PublishRecoveryPanel.tsx new file mode 100644 index 00000000..a6c74942 --- /dev/null +++ b/src/features/publish/PublishRecoveryPanel.tsx @@ -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 = 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(); + 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 ( +
+

{t("buildPublish.recovery.title")}

+

{t("buildPublish.recovery.explain")}

+
+ + {confirmingReset ? ( + + ) : ( + + )} +
+ {confirmingReset ?

{t("buildPublish.recovery.resetWarning")}

: null} + {credentialMissing ?

{t("buildPublish.recovery.tokenNeeded")}

: null} + {outcome ? ( +
+

{outcome.kind === "failed" ? outcome.message : t(`buildPublish.recovery.outcome.${outcome.kind}`)}

+ {RETRY_ALLOWED.has(outcome.kind) ? ( + + ) : null} +
+ ) : null} +
+ ); +} diff --git a/src/features/publish/api/index.ts b/src/features/publish/api/index.ts index 663ea17f..11ec8698 100644 --- a/src/features/publish/api/index.ts +++ b/src/features/publish/api/index.ts @@ -108,6 +108,71 @@ export async function publishBuild( return mockPublishResult(runId, request.destination, request.options?.private ?? true, readiness.redistribution ?? null, request.options?.confirm_non_commercial === true); } +/** What settling an unknown publish came to (#728). */ +export type PublishRecoveryOutcome = + /** The destination exists remotely: the publish did go through. */ + | { kind: "confirmed" } + /** Nothing was found remotely and the receipt is gone: the publish may be sent again. */ + | { kind: "absent" } + /** The receipt was deleted on request; whatever was published stays published. */ + | { kind: "reset" } + /** No receipt to settle — nothing blocks a new publish. */ + | { kind: "nothing_to_settle" } + /** The remote could not be read, or the outcome not saved; nothing changed. */ + | { kind: "unavailable" } + | { kind: "failed"; message: string }; + +function recoveryFailure(cause: unknown): PublishRecoveryOutcome { + if (cause instanceof ApiError) { + const code = (cause.details as { code?: unknown } | undefined)?.code; + if (cause.status === 404 && code === "receipt_not_found") return { kind: "nothing_to_settle" }; + if (cause.status === 503) return { kind: "unavailable" }; + if (cause.status === 400 && code === "invalid_publish_credential") return { kind: "failed", message: t("invalidCredential") }; + if (cause.status === 403) return { kind: "failed", message: t("forbidden") }; + if (cause.status === 0 || cause.status === 408) return { kind: "failed", message: t("network") }; + } + return { kind: "failed", message: t("recoveryFailed") }; +} + +/** + * Ask Builder to look at the remote and settle a publish that ended + * `publish_state_unknown` (#728). The demo has no remote and no receipts. + */ +export async function reconcilePublish( + runId: string, + destination: string, + signal?: AbortSignal, + credential?: PublishCredential, +): Promise { + if (!isRealBuilderEnabled()) return { kind: "nothing_to_settle" }; + try { + const response = await builderApi.reconcilePublish(runId, { target: "huggingface", destination }, signal, credential); + if (response.run_id !== runId) return { kind: "failed", message: t("mismatch") }; + return { kind: response.state === "succeeded" ? "confirmed" : "absent" }; + } catch (cause) { + if (signal?.aborted) throw cause; + return recoveryFailure(cause); + } +} + +/** Delete the receipt of an unknown publish so it can be sent again (#728). Nothing is undone remotely. */ +export async function resetPublishReceipt( + runId: string, + destination: string, + signal?: AbortSignal, + credential?: PublishCredential, +): Promise { + if (!isRealBuilderEnabled()) return { kind: "nothing_to_settle" }; + try { + const response = await builderApi.resetPublishReceipt(runId, "huggingface", destination, signal, credential); + if (response.run_id !== runId) return { kind: "failed", message: t("mismatch") }; + return { kind: "reset" }; + } catch (cause) { + if (signal?.aborted) throw cause; + return recoveryFailure(cause); + } +} + export type PublishFailureKind = | PublishErrorCode | "invalid_publish_credential" diff --git a/src/pages/BuildPublishPage.tsx b/src/pages/BuildPublishPage.tsx index 6dcd3845..d34b9100 100644 --- a/src/pages/BuildPublishPage.tsx +++ b/src/pages/BuildPublishPage.tsx @@ -18,6 +18,7 @@ import { } from "@/features/publish/api"; import { describePublishIssue, redistributionLabel, type PublishIssueLink } from "@/features/publish/issues"; import { DataCardPreview } from "@/features/publish/DataCardPreview"; +import { PublishRecoveryPanel } from "@/features/publish/PublishRecoveryPanel"; import { usePublishJob } from "@/features/publish/usePublishJob"; import { ensureVersionChecked, useVersionCheckStore } from "@/features/version-check/store"; import { isRealBuilderEnabled } from "@/shared/lib/builderApi"; @@ -300,6 +301,17 @@ export function BuildPublishPage() { void publish.start(runId, request, credential); } + /** The token on the page, for one recovery request; dropped at once, as a publish drops it (#615). */ + function takePublishCredential() { + const credential = publishCredentialFor(publishTokenRef.current); + if (credential) { + publishTokenRef.current = ""; + setPublishToken(""); + setTokenStale(true); + } + return credential; + } + function updatePrivate(value: boolean) { setIsPrivate(value); setConfirmation(undefined); @@ -452,6 +464,15 @@ export function BuildPublishPage() { {t("buildPublish.publishFailed")}

{publish.failure?.message}

{publish.failure?.kind === "publish_state_unknown" ?

{t("buildPublish.noAutoRetry")}

: null} + {publish.failure?.kind === "publish_state_unknown" && confirmation ? ( + + ) : null} {publish.failure?.kind === "redistribution_blocked" && publish.failure.redistribution ?
: null} {publish.failure?.blockers?.length ?
: null} diff --git a/src/shared/i18n/locales/en.json b/src/shared/i18n/locales/en.json index 1b61aae7..6a43561f 100644 --- a/src/shared/i18n/locales/en.json +++ b/src/shared/i18n/locales/en.json @@ -1345,7 +1345,24 @@ "tokenHint": "Kept only in this page's memory and sent only with the readiness check and the publish request. It is never saved, and it is gone when you leave or reload the page.", "tokenStale": "Check again to confirm readiness with this token.", "tokenCheck": "Check with this token", - "tokenForget": "Forget token" + "tokenForget": "Forget token", + "recovery": { + "title": "When the publish outcome is unknown", + "explain": "KPubData Builder does not know whether the publish went through, so it blocks sending it again. You can check the remote, or delete the record and publish again. Neither runs on its own.", + "reconcile": "Check the remote", + "reset": "Reset the record", + "resetConfirm": "Confirm reset", + "resetWarning": "This deletes the record without checking the remote. Anything already published stays published, and publishing again sends it to the same destination once more.", + "tokenNeeded": "Checking the remote needs the publish token. Enter it again above, then press the button.", + "publishAgain": "Prepare to publish again", + "outcome": { + "confirmed": "The destination exists remotely. The publish went through — there is no need to send it again.", + "absent": "Nothing was found remotely. The publish did not go through, and its record was removed. You can publish again.", + "reset": "The record was deleted. Anything already published stays published. You can publish again.", + "nothing_to_settle": "There is no publish record to settle. You can publish again.", + "unavailable": "The remote state could not be read. Nothing changed — try again in a moment." + } + } }, "settings": { "page": { @@ -2065,7 +2082,7 @@ "notReady": "This run is not ready to publish.", "unknown": "The publish request could not be completed.", "inProgress": "The same publish job is already running.", - "retryBlocked": "The previous publish result could not be confirmed, so automatic retry is blocked. Ask an Builder operator to check the state.", + "retryBlocked": "The previous publish result could not be confirmed, so automatic retry is blocked. Check the remote or reset the record below.", "visibilityConflict": "The same run and destination were already published with different visibility.", "externalFailed": "The external publishing service could not finish the job. The outcome may be unclear, so it is not retried automatically.", "readinessChanged": "KPubData Builder's re-check just before publishing found the readiness had changed. Check readiness again.", @@ -2074,7 +2091,8 @@ "network": "No response from KPubData Builder. The remote publish result was not confirmed.", "mismatch": "The run or target in KPubData Builder's response does not match the request.", "invalidCredential": "The publish token is not in a form KPubData Builder accepts. Check the token and enter it again.", - "redistributionBlocked": "KPubData Builder refused the publish because of the sources' redistribution terms. See the reasons below." + "redistributionBlocked": "KPubData Builder refused the publish because of the sources' redistribution terms. See the reasons below.", + "recoveryFailed": "The publish record could not be settled." }, "issues": { "run_not_terminal": { diff --git a/src/shared/i18n/locales/ko.json b/src/shared/i18n/locales/ko.json index 722ef00a..fa8b8b02 100644 --- a/src/shared/i18n/locales/ko.json +++ b/src/shared/i18n/locales/ko.json @@ -1345,7 +1345,24 @@ "tokenHint": "이 화면의 메모리에만 두고, 게시 준비 확인과 게시 요청에만 실어 보냅니다. 저장하지 않으며, 화면을 떠나거나 새로고침하면 사라집니다.", "tokenStale": "이 토큰으로 게시 준비 상태를 다시 확인하세요.", "tokenCheck": "이 토큰으로 확인", - "tokenForget": "토큰 지우기" + "tokenForget": "토큰 지우기", + "recovery": { + "title": "게시 결과를 알 수 없을 때", + "explain": "게시가 실제로 올라갔는지 Builder 가 알지 못해 다시 보내는 것을 막고 있습니다. 원격을 확인하거나, 기록을 지우고 다시 게시할 수 있습니다. 어느 것도 자동으로 실행되지 않습니다.", + "reconcile": "원격 확인", + "reset": "기록 초기화", + "resetConfirm": "초기화 확정", + "resetWarning": "원격을 확인하지 않고 기록만 지웁니다. 이미 올라간 것이 있다면 지워지지 않고, 다시 게시하면 같은 destination 에 한 번 더 올라갑니다.", + "tokenNeeded": "원격을 확인하려면 게시 토큰이 필요합니다. 위에 토큰을 다시 입력한 뒤 눌러 주세요.", + "publishAgain": "다시 게시 준비", + "outcome": { + "confirmed": "원격에 이 destination 이 있습니다. 게시는 이루어졌습니다 — 다시 보낼 필요가 없습니다.", + "absent": "원격에 없습니다. 게시되지 않은 것으로 확인되어 기록을 지웠습니다. 다시 게시할 수 있습니다.", + "reset": "기록을 지웠습니다. 원격에 이미 올라간 것은 그대로입니다. 다시 게시할 수 있습니다.", + "nothing_to_settle": "정리할 게시 기록이 없습니다. 다시 게시할 수 있습니다.", + "unavailable": "원격 상태를 확인하지 못했습니다. 아무것도 바뀌지 않았습니다 — 잠시 후 다시 시도하세요." + } + } }, "settings": { "page": { @@ -2065,7 +2082,7 @@ "notReady": "게시 준비가 되지 않은 Run입니다.", "unknown": "게시 요청을 완료하지 못했습니다.", "inProgress": "같은 게시 작업이 이미 진행 중입니다.", - "retryBlocked": "이전 게시 결과를 확인할 수 없어 자동 재시도가 차단되었습니다. Builder 운영자에게 상태 확인을 요청하세요.", + "retryBlocked": "이전 게시 결과를 확인할 수 없어 자동 재시도가 차단되었습니다. 아래에서 원격을 확인하거나 기록을 초기화할 수 있습니다.", "visibilityConflict": "같은 Run과 destination이 다른 공개 설정으로 이미 게시되었습니다.", "externalFailed": "외부 게시 서비스에서 작업을 완료하지 못했습니다. 결과가 불명확할 수 있으므로 자동 재시도하지 않습니다.", "readinessChanged": "게시 직전 Builder 재검증에서 준비 상태가 변경되었습니다. readiness를 다시 확인하세요.", @@ -2074,7 +2091,8 @@ "network": "Builder 응답을 받지 못했습니다. 원격 게시 결과는 확인되지 않았습니다.", "mismatch": "Builder 응답의 Run 또는 target이 요청과 일치하지 않습니다.", "invalidCredential": "게시 토큰 형식을 KPubData Builder가 받지 않습니다. 토큰을 확인하고 다시 입력하세요.", - "redistributionBlocked": "소스의 재배포 조건 때문에 KPubData Builder가 게시를 거부했습니다. 아래 사유를 확인하세요." + "redistributionBlocked": "소스의 재배포 조건 때문에 KPubData Builder가 게시를 거부했습니다. 아래 사유를 확인하세요.", + "recoveryFailed": "게시 기록을 정리하지 못했습니다." }, "issues": { "run_not_terminal": { diff --git a/src/shared/lib/builderApi.schema.ts b/src/shared/lib/builderApi.schema.ts index b2c31f4d..ec8e2cf8 100644 --- a/src/shared/lib/builderApi.schema.ts +++ b/src/shared/lib/builderApi.schema.ts @@ -970,6 +970,34 @@ export const publishRequestSchema = z.object({ options: publishHuggingFaceOptionsSchema.optional(), }).strict(); +/** + * POST /builds/{run_id}/publish/reconcile (builder#551, declared in the contract by + * builder#994): what looking at the remote settled. `succeeded` — the destination exists + * there, or the receipt already said so (`reconciled: false`). `reset` — nothing was found + * and the receipt was deleted, so the publish may be sent again. + */ +export const publishReconcileResponseSchema = z.object({ + run_id: z.string(), + state: z.enum(["succeeded", "reset"]), + reconciled: z.boolean(), + fingerprint: z.string(), + retry_allowed: z.boolean().optional(), + result: z.record(z.string(), z.json()).optional(), +}); +export type PublishReconcileResponse = z.infer; + +/** + * DELETE /builds/{run_id}/publish/receipt (builder#551): the caller's receipt was deleted. + * Nothing is undone remotely — what was published stays published. + */ +export const publishReceiptResetSchema = z.object({ + run_id: z.string(), + state: z.literal("reset"), + retry_allowed: z.literal(true), + fingerprint: z.string(), +}); +export type PublishReceiptReset = z.infer; + export const publishResponseSchema = z.object({ run_id: z.string(), target: publishTargetSchema, diff --git a/src/shared/lib/builderApi.ts b/src/shared/lib/builderApi.ts index bf61f1e6..47063c4a 100644 --- a/src/shared/lib/builderApi.ts +++ b/src/shared/lib/builderApi.ts @@ -895,6 +895,42 @@ export const builderApi = { schemas.publishResponseSchema, ), + /** + * POST /builds/{run_id}/publish/reconcile — settle a publish whose outcome was not + * learnt (`publish_state_unknown`) by looking at the remote (#728). Not retried: it + * reads the remote with the requester's credential. + */ + reconcilePublish: ( + runId: string, + request: { target: schemas.PublishTarget; destination: string }, + signal?: AbortSignal, + credential?: PublishCredential, + ) => + apiFetch( + `/builds/${encodeURIComponent(runId)}/publish/reconcile`, + { method: "POST", body: request, signal, retries: 0, headers: publishCredentialHeaders(credential) }, + schemas.publishReconcileResponseSchema, + ), + + /** + * DELETE /builds/{run_id}/publish/receipt — delete the caller's receipt so the same + * publish can be claimed again (#728). Nothing is undone remotely. + */ + resetPublishReceipt: ( + runId: string, + target: schemas.PublishTarget, + destination: string, + signal?: AbortSignal, + credential?: PublishCredential, + ) => { + const params = new URLSearchParams({ target, destination }); + return apiFetch( + `/builds/${encodeURIComponent(runId)}/publish/receipt?${params.toString()}`, + { method: "DELETE", signal, retries: 0, headers: publishCredentialHeaders(credential) }, + schemas.publishReceiptResetSchema, + ); + }, + /** GET /datasets/{dataset_id}/quality/history — dataset quality history. */ getDatasetQualityHistory: (datasetId: string, limit?: number, signal?: AbortSignal) => { const query = limit !== undefined ? `?limit=${limit}` : ""; From 4e4d361503c8b3394e6d18da3313e4e331c104a1 Mon Sep 17 00:00:00 2001 From: Eomdahyeon <213566566+Eomdahyeon@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:57:46 +0900 Subject: [PATCH 2/2] test(contract): read the recovery error examples contract 1.81.0 declares Builder's contract now declares the publish recovery routes (builder#1009), with named examples for their responses and errors. Map the two operations Studio does not call as skipped, check the reconcile and reset errors against recoveryFailure, and list the receipt 404 Studio never requests. Co-Authored-By: Claude Opus 5.5 --- src/features/publish/api/index.ts | 3 ++- src/shared/lib/contractDrift.test.ts | 29 +++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/features/publish/api/index.ts b/src/features/publish/api/index.ts index 11ec8698..6f9b8895 100644 --- a/src/features/publish/api/index.ts +++ b/src/features/publish/api/index.ts @@ -122,7 +122,8 @@ export type PublishRecoveryOutcome = | { kind: "unavailable" } | { kind: "failed"; message: string }; -function recoveryFailure(cause: unknown): PublishRecoveryOutcome { +/** What a failed reconcile or reset means for the receipt (#728); read from Builder's error body. */ +export function recoveryFailure(cause: unknown): PublishRecoveryOutcome { if (cause instanceof ApiError) { const code = (cause.details as { code?: unknown } | undefined)?.code; if (cause.status === 404 && code === "receipt_not_found") return { kind: "nothing_to_settle" }; diff --git a/src/shared/lib/contractDrift.test.ts b/src/shared/lib/contractDrift.test.ts index f2819bba..b659b28c 100644 --- a/src/shared/lib/contractDrift.test.ts +++ b/src/shared/lib/contractDrift.test.ts @@ -44,7 +44,7 @@ import { z } from "zod"; import { artifactDownloadRefusal } from "@/features/artifacts/downloadRefusal"; import { revisionErrorOutcome } from "@/features/build-spec/specRevisions"; import { profileRefusal } from "@/features/datasets/profileRefusal"; -import { describePublishFailure } from "@/features/publish/api"; +import { describePublishFailure, recoveryFailure } from "@/features/publish/api"; import { PUBLISH_ISSUE_CODES } from "@/features/publish/issues"; import { classifyQueryError } from "@/features/sql/api"; import { asInvalidDetails } from "@/features/validation/api"; @@ -473,6 +473,10 @@ const OPERATION_SCHEMAS: Record ({ reader: "describePublishFailure (features/publish/api)", check: (status, body) => differs("kind", publishKind(status, body), kind), }); +/** A failed reconcile or reset, as the publish page's recovery panel sees it (#728). */ +const recoveryReader = (kind: string): ErrorReader => ({ + reader: "recoveryFailure (features/publish/api)", + since: PUBLISH_RECOVERY_SINCE, + check: (status, body) => differs("kind", recoveryFailure(httpError(status, body)).kind, kind), +}); + type ErrorReader = { /** The Studio code that reads the body. */ reader: string; + /** The contract version that introduced the example; before it the entry is not stale. */ + since?: string; /** What Studio misreads in this body; empty when it reads it as the contract means. */ check: (status: number, body: unknown) => string[]; }; @@ -678,6 +694,17 @@ const ERROR_READERS: Record = { code: "server_overloaded", since: "1.80.0", }, + // Settling an unknown publish (#728, builder#1009). No receipt means nothing blocks a new + // publish; a 503 means nothing changed and the same action can be tried again. + "reconcilePublish 404 ReceiptNotFound": recoveryReader("nothing_to_settle"), + "reconcilePublish 503 ReconcileUnavailable": recoveryReader("unavailable"), + "resetPublishReceipt 404 ReceiptNotFound": recoveryReader("nothing_to_settle"), + "resetPublishReceipt 503 ReconcileUnavailable": recoveryReader("unavailable"), + "getPublishReceipt 404 ReceiptNotFound": { + notHandled: "Studio does not call getPublishReceipt", + code: "receipt_not_found", + since: PUBLISH_RECOVERY_SINCE, + }, "PiiDeclarationUnavailable 503 PiiDeclarationUnavailable": { reader: "artifactDownloadRefusal, profileRefusal and classifyQueryError (#640, #643)", check: (status, body) => {