Skip to content

feat(publish): settle a publish whose outcome is unknown from the publish page - #744

Merged
yeongseon merged 4 commits into
mainfrom
feat/issue-728-publish-recovery
Oct 5, 2026
Merged

yeongseon merged 4 commits into
mainfrom
feat/issue-728-publish-recovery

Conversation

@Eomdahyeon

Copy link
Copy Markdown
Collaborator

Closes #728

문제

publish 가 publish_state_unknown 으로 끝나면 Studio 는 "자동 재시도하지 마세요, 운영자에게 요청하세요"만 보여 준다. Builder 에는 복구 경로(reconcile, receipt reset)가 있지만 Studio 가 부르지 않는다. 사용자가 UI 에서 할 수 있는 일이 없다.

변경 내용

  • builderApi.reconcilePublish(POST …/publish/reconcile), builderApi.resetPublishReceipt(DELETE …/publish/receipt?target&destination). 둘 다 재시도 없음. 자격 증명은 X-Publish-Credential 헤더로만.
  • 응답 스키마 publishReconcileResponseSchema, publishReceiptResetSchema — 계약의 PublishReconcileResponse, PublishReceiptReset 와 이름으로 짝지어진다.
  • 게시 실패 카드에 복구 패널(PublishRecoveryPanel), publish_state_unknown 일 때만:
    • 원격 확인 → 있으면 "게시는 이루어졌습니다"(다시 보낼 버튼 없음), 없으면 기록이 지워지고 "다시 게시 준비" 버튼.
    • 기록 초기화 → 두 번째 클릭("초기화 확정")이 있어야 실행되고, "이미 올라간 것은 지워지지 않는다"는 경고를 먼저 보여 준다.
    • 원격을 읽지 못하면(503) "아무것도 바뀌지 않았다"고 말하고 재게시 버튼을 주지 않는다.
    • 기록이 없으면(404 receipt_not_found) 정리할 것이 없다고 말한다.
  • 요청에서 자격 증명을 받는 배포에서는 원격 확인에 토큰이 필요하다. Studio 는 게시 시작 때 토큰을 버리므로(feat(publish): send the request-scoped publish token in multi-user deployments #615) 다시 입력하게 하고, 그 한 번의 요청에만 쓰고 다시 버린다.
  • 어느 것도 자동으로 실행되지 않는다. "다시 게시 준비"도 사용자가 누르고, 누른 뒤에는 원래의 확인 단계부터 다시 거친다.
  • ko/en 문구, CHANGELOG, EXPECTED_OPERATIONS 에 두 호출 추가.

의존 — kpubdata-lab/kpubdata-builder#1009

네 라우트는 Builder 에서 1.19/1.20 부터 응답해 왔지만 계약 paths 에는 #1009 가 처음 선언한다. 그 PR 이 Builder main 에 들어가기 전까지:

  • 이 PR 의 드리프트 검사는 두 스키마를 대조할 대상이 없어 그냥 지나간다(실패하지 않는다).
  • test(contract): check that every route the client calls is in the contract #743(클라이언트의 모든 경로가 계약에 있는지 검사)이 먼저 머지되면, 이 PR 은 그 검사에서 실패한다 — 두 경로가 아직 계약에 없으므로. #1009 가 먼저 들어가야 한다.

#1009 의 계약 파일로 드리프트 검사를 로컬에서 돌려 봤다: 스키마 대조 461개 통과. 실패한 2개는 계약 파일만 복사하고 fixture 디렉터리를 같이 두지 않아서 난 "fixture 가 옆에 있는가" 검사다 — 스키마와는 무관하다.

검증

  • __tests__/publishRecovery.test.tsx(16개): 요청의 method·path·body·헤더, 토큰이 URL·body 에 없음, 404/503 분류와 재시도 없음, 다른 run 의 응답 불신, Builder 오류 문구를 그대로 보여 주지 않음, 패널이 누르기 전에는 아무것도 부르지 않음, reset 의 두 단계, 토큰 없이는 원격 확인을 하지 않음.
  • npx vitest run: 238 files passed, 2405 passed | 9 skipped. tsc --noEmit, eslint 통과.
  • 실제 Builder 와 실제 Hugging Face 로 확인한 것은 아니다. 브라우저에서 화면을 직접 보지도 않았다.

review:R3 — 작성자가 아닌 사람의 승인이 필요하다.

🤖 Generated with Claude Code

@Eomdahyeon
Eomdahyeon force-pushed the feat/issue-728-publish-recovery branch from ee71749 to 57fd9c8 Compare October 4, 2026 15:21
@Eomdahyeon

Copy link
Copy Markdown
Collaborator Author

#743 이 머지되어 main 위로 rebase 했습니다. 이제 Builder contract drift 잡이 이 PR 에서 실패합니다 — 의도된 실패입니다: reconcilePublish 와 resetPublishReceipt 가 부르는 두 경로를 Builder 계약이 아직 선언하지 않았고(kpubdata-builder#1009 가 선언), #743 의 경로 검사가 바로 그것을 잡습니다. 로컬에서 Builder main 계약으로 돌리면 every client function sends a method and path the contract declares 1건만 실패합니다(1 failed | 554 passed).

순서: kpubdata-builder#1009 머지 → 이 PR 의 drift 잡 재실행 → 리뷰. 그 전에는 머지하지 않습니다.

@yeongseon yeongseon left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

리뷰 (코멘트 — 승인은 builder#1009 가 main 에 들어가고 이 PR 의 드리프트 검사가 실제 계약과 대조된 뒤에).

diff 를 읽었습니다. 걸리는 로직은 없습니다.

  • 어느 것도 자동으로 실행되지 않고, reset 은 두 번째 클릭과 경고 뒤에만 나갑니다. 테스트가 "누르기 전에는 fetch 0회" 를 확인합니다.
  • 원격을 읽지 못한 503 과 confirmed 에서는 재게시 버튼을 주지 않습니다. 재게시가 허용되는 것은 absent·reset·nothing_to_settle 뿐입니다.
  • 토큰은 헤더로만 가고, 다른 run 의 응답은 믿지 않고, Builder 의 오류 문장을 그대로 보여 주지 않습니다 — 각각 테스트가 있습니다.

승인을 미루는 이유 두 가지:

  1. 계약 대조가 아직 없습니다. publishReconcileResponseSchema·publishReceiptResetSchema 가 짝지을 계약 스키마는 builder#1009 가 처음 선언합니다. 그 PR 은 지금 충돌 상태이고 버전도 다시 잡아야 합니다. 본문대로 #743 이 먼저 들어가면 이 PR 은 그때까지 경로 검사에서 실패합니다.
  2. 화면을 본 사람이 없습니다. 본문이 밝힌 대로 브라우저에서 확인하지 않았고 실제 Builder·Hugging Face 로도 돌리지 않았습니다. 기록을 지우는 동작이 있는 R3 화면이라, 머지 전에 한 번은 눈으로 봐야 합니다.

…lish page

Closes #728

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ares

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 <noreply@anthropic.com>
@Eomdahyeon

Copy link
Copy Markdown
Collaborator Author

kpubdata-builder#1009 가 머지되어(계약 1.81.0) 이 PR 을 그 계약에 맞췄습니다 (4e4d361).

지금 Studio main 의 Builder contract drift 잡은 빨갛습니다. 1.81.0 이 복구 경로의 응답·오류 예시를 선언했고 main 의 drift 테스트는 그것을 모릅니다 — 로컬에서 main 을 Builder main 계약으로 돌리면 응답 예시 5건, 오류 예시 5건 등이 실패합니다. 이 PR 이 들어가면 풀립니다 (kpubdata-builder#1004 가 말한 바로 그 순서 문제입니다).

추가한 것:

  • OPERATION_SCHEMAS: getPublishReceipt, getPublishAudit 를 skip 으로 — Studio 는 둘 다 부르지 않습니다.
  • ERROR_READERS: reconcilePublish / resetPublishReceipt 의 404 ReceiptNotFound → nothing_to_settle, 503 ReconcileUnavailable → unavailable 를 recoveryFailure 로 실제로 읽어 확인합니다 (그래서 그 함수를 export 했습니다). getPublishReceipt 404 는 부르지 않는 경로라 not-handled 로 올렸습니다. 모두 since: 1.81.0.

확인: Builder main (813827d) 의 계약·fixture 로 npx vitest run src/shared/lib/contractDrift.test.ts → Tests 576 passed (576). npx tsc --noEmit, eslint 출력 없음. 1.80.0 계약으로는 2건 실패합니다(복구 경로가 아직 없는 계약이므로 예상된 결과) — CI 는 Builder main 만 읽습니다.

R3 라 승인을 기다립니다.

yeongseon added a commit that referenced this pull request Oct 5, 2026
… test (#746)

Refs kpubdata-lab/kpubdata-builder#994, #728

## 문제

kpubdata-lab/kpubdata-builder#1009 가 Builder `main` 에 들어가면서 계약이 1.81.0 이
됐고, publish 복구 라우트 네 개(`getPublishReceipt`, `resetPublishReceipt`,
`reconcilePublish`, `getPublishAudit`)가 이름 붙은 예시와 함께 선언됐다. Studio 의
`Builder contract drift` 잡은 Builder `main` 의 계약을 읽고, 파싱하지도 목록에 올리지도 않은
예시가 있으면 실패한다. 그래서 지금 `main` 기준의 모든 PR 에서 이 잡이 실패한다.

#744 의 잡 로그(재실행, Builder 1.81.0)가 근거다: `no Studio schema for
getPublishReceipt; map it in OPERATION_SCHEMAS`, 그리고 `ERROR_READERS` 에
없는 에러 예시 5개.

순서를 제가 잘못 잡았다 — builder#1023 때처럼 Studio 가 예시를 먼저 알고 있어야 했는데 builder#1009
를 먼저 머지했다.

## 변경

`src/shared/lib/contractDrift.test.ts` 만 바꾼다. Studio 는 `main` 에서 네 라우트 중
어느 것도 부르지 않는다.

- `OPERATION_SCHEMAS`: 네 operation 을 `skip` 으로 (부르지 않으므로 파싱할 본문이 없다).
- `ERROR_READERS`: 에러 예시 5개를 `notHandled` 로, `code`(`receipt_not_found`,
`reconcile_unavailable`)와 `since: "1.81.0"` 과 함께.
- CHANGELOG `[Unreleased]`.

#728(#744)이 `reconcilePublish`·`resetPublishReceipt` 를 부르기 시작하면 그 둘의
`skip` 과 `notHandled` 항목을 실제 스키마·reader 로 바꿔야 한다.

## 검증

**로컬에서 테스트를 돌리지 못했다** — 이 환경에서 `npm ci` 가 실패한다. 이 PR 의 `Builder contract
drift` 잡이 Builder `main`(1.81.0)으로 도는 것이 첫 실행이자 판정이다.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@yeongseon

Copy link
Copy Markdown
Collaborator

kpubdata-builder#1009 를 머지했습니다 (계약 1.81.0). 그 결과 이 PR 뿐 아니라 main 기준 모든 PR 의 Builder contract drift 가 실패해서, #746 으로 네 라우트의 예시를 drift 검사에 등록해 먼저 풀었습니다 (머지됨).

이 PR 에서 할 일:

  1. main 을 합친다.
  2. test(contract): list Builder's publish recovery examples in the drift test #746 이 넣은 임시 항목 중 이 PR 이 부르는 둘을 실제 것으로 바꾼다 — OPERATION_SCHEMAS 의 reconcilePublish·resetPublishReceipt skip 을 지우면 이름 짝짓기(PublishReconcileResponse, PublishReceiptReset)가 다시 동작하고, ERROR_READERS 의 reconcilePublish/resetPublishReceipt 404·503 네 항목은 recoveryFailure 를 읽는 reader 로 바꿉니다. getPublishReceipt·getPublishAudit 은 그대로 둡니다.

그 뒤 drift 가 녹색이면 다시 리뷰하겠습니다. 화면 확인은 여전히 필요합니다.

…h-recovery

# Conflicts:
#	src/shared/lib/contractDrift.test.ts
@Eomdahyeon

Copy link
Copy Markdown
Collaborator Author

#746 이 머지되어 main 을 합쳤습니다. contractDrift.test.ts 의 충돌은 이렇게 풀었습니다 — #746 본문이 예고한 교체 그대로입니다:

Builder main 계약(1.81.0)으로 contractDrift.test.ts + publishRecovery.test.tsx 를 돌린 결과는 아래 CI 와 같습니다. 리뷰의 두 번째 조건(화면을 눈으로 보는 것)은 제가 대신할 수 없습니다 — 브라우저와 실제 Hugging Face 대상이 없습니다.

Eomdahyeon added a commit that referenced this pull request Oct 5, 2026
…twork (#747)

Refs #741

## #745 의 추적이 잡은 것

#744 의 `Coverage gate` 잡에서 같은 테스트가 다시 실패했고, 이번에는 `[#741]` 블록이 찍혔다:

```
[#741] requests of the failed test, in order:
5ms fetch rejected GET /warehouse/tables: TypeError: fetch failed (cause: AggregateError); signal aborted: false
10ms msw request:start GET /datasets/air
10ms msw request:match GET /datasets/air
11ms msw response:mocked GET /datasets/air
…
44ms msw request:unhandled GET /builds/run-2/spec
86ms fetch rejected GET /builds/run-2/spec: TypeError: fetch failed (cause: AggregateError); signal aborted: false
87ms fetch rejected GET /builds/run-2/stages: TypeError: fetch failed (cause: SocketError: closed); signal aborted: false
```

핸들러가 등록된 `GET /warehouse/tables` 가 **`request:start` 없이** `fetch failed`
로 끝났다. #745 본문이 적은 세 갈래 중 "그 순간 요청이 MSW 를 거치지 않았다" 쪽이다. `AggregateError`
는 실제 `localhost` (::1, 127.0.0.1) 연결 시도가 실패할 때의 원인이다.

## 왜 그럴 수 있는가 (가설)

- 이 MSW(3.0.2, `@mswjs/interceptors` 0.45.6)는 Node 에서 `fetch` 가 아니라 **소켓
수준**에서 가로챈다(`lib/node/net-*.js`).
- 기본 전략 `onUnhandledFrame: "warn"` 은 경고 뒤 요청을 그대로 통과시킨다 — 실제
`localhost:8000` 에 연결한다.
- 이 파일의 테스트들은 `/builds/{run}/spec`, `/stages` 등에 핸들러가 없어 매번 통과 요청을 만들고,
클라이언트가 그것을 0.5초·1초 뒤 재시도한다. 그 재시도가 다음 테스트 시작과 겹친다.
- 통과된 실제 연결이 살아 있는 동안 같은 origin 으로 가는 다음 요청이 그 연결에 실려 MSW 를 거치지 않고 함께
실패한 것으로 본다.

**마지막 단계는 확인하지 못했다.** 로컬에서는 재현되지 않는다: 미처리 요청과 처리 요청을 시점을 바꿔 가며 1,500회 겹쳐
봤지만 처리 요청은 한 번도 실패하지 않았다. 로컬의 `localhost` 는 IPv4 하나로만
풀리고(`[{"address":"127.0.0.1","family":4}]`) CI 는 두 주소를 시도한다는 차이가 있다.

## 변경

- `vitest.setup.ts`: 핸들러가 없는 요청을 전처럼 경고한 뒤, 통과시키지 않고 MSW 안에서 네트워크 오류로
끝낸다 (`onUnhandledFrame` 콜백이 `HttpResponse.error()` 를 던진다). 앱이 보는 결과는 같다
— `fetch` 가 reject 되고 연결 실패로 읽힌다. 달라지는 것은 테스트가 실제 소켓을 열지 않는다는 점뿐이다.
- `__tests__/unhandledRequests.test.ts`: 실제 HTTP 서버를 띄워 놓고 핸들러 없는 요청을
보내, 요청이 reject 되고 경고가 나오고 **서버에는 아무것도 도착하지 않음**을 확인한다.
- CHANGELOG.

## 검증

- 새 테스트는 고치기 전 설정에서 실패한다: `promise resolved "Response { status: 200 … }"
instead of rejecting` — 요청이 실제 서버에 닿았다.
- `npx vitest run` 전체 → `Test Files 237 passed (237)`, `Tests 2391
passed | 12 skipped (2408)`. (건너뛴 것은 계약 없이 돌린 drift 테스트다.) 미처리 요청이 연결
실패로 끝난다는 데 기대던 테스트는 그대로 통과한다.
- `npx tsc --noEmit`, eslint 출력 없음.

## 알아 둘 것

- 이것이 flake 를 끝내는지는 증명되지 않았다. #741 은 열어 두고, #745 의 추적도 그대로 둔다 — 다시 나면
로그가 말해 준다.
- 이 PR 의 `Builder contract drift` 잡은 #744 가 머지될 때까지 실패한다(Builder 계약
1.81.0, #744 코멘트 참고). 이 변경과는 무관하다.
- `__tests__/builderApi.e2e.test.ts` 는 서버를 닫았다가 `"warn"` 으로 다시 여는 곳이 있다.
건드리지 않았다.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Eomdahyeon <213566566+Eomdahyeon@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

@yeongseon yeongseon left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

다시 읽었습니다. 승인합니다.

  • main 을 합치면서 #746 의 임시 항목을 예고대로 바꿨습니다: reconcilePublish·resetPublishReceipt 는 실제 스키마 대조와 recoveryFailure reader 로, getPublishReceipt·getPublishAudit 은 부르지 않으므로 skip/not-handled 그대로.
  • 첫 번째 조건(계약 대조)은 충족됐습니다 — Builder contract drift 가 Builder main(1.81.0)으로 녹색입니다.
  • 두 번째 조건(화면을 눈으로 보는 것)은 충족되지 않았습니다. 저도 이 환경에서 Studio 를 띄울 수 없습니다. 그래도 머지하는 쪽으로 판단했습니다: 패널의 동작 — 누르기 전에는 아무것도 부르지 않음, reset 은 두 번 클릭, 503·confirmed 에서는 재게시 버튼 없음 — 은 컴포넌트를 실제로 렌더하는 테스트가 고정하고 있고, 눈으로만 잡을 수 있는 것은 배치·문구의 어색함이지 잘못된 동작이 아닙니다. 화면 확인은 후속 이슈로 남깁니다.

@yeongseon
yeongseon merged commit 00fa5bf into main Oct 5, 2026
18 checks passed
Eomdahyeon added a commit that referenced this pull request Oct 5, 2026
Closes #749

## 문제

게시 자격 증명을 요청에서 받는 배포(`publish_credential: request`)에서 복구 패널의 **원격 확인**은
토큰이 다시 필요하다. 패널은 "위에 토큰을 다시 입력하라"고 안내했는데, 페이지의 토큰 칸은 값이 바뀌면 게시 폼을 처음으로
되돌린다(`setConfirmation(undefined); publish.reset()`). 패널은 바로 그 두 상태에 걸려
있어서, 안내를 따르면 패널이 사라지고 남는 것은 원격을 보지 않는 **기록 초기화**뿐이었다. #744 에서 제가 넣은
결함이다.

## 변경

- `features/publish/PublishRecoveryPanel.tsx`
- `needsCredential` 일 때 패널 안에 자체 토큰 칸을 둔다. 페이지의 토큰과 같은 규칙이다: 메모리에만 두고,
원격 확인 한 번에 실어 보낸 뒤 바로 비운다. 헤더에 실을 수 없는 값이면 버튼을 막고 보내지 않는다.
- **기록 초기화는 토큰을 보내지도 소모하지도 않는다.** Builder 의 reset 은 원격을 읽지
않는다(`publish_api.py` 의 `reset_publish_receipt` 본문에 자격 증명을 쓰는 곳이 없다 — 코드를
읽어 확인했고 실제 Builder 로 돌려 보지는 않았다).
  - `takeCredential` prop 은 없앴다.
- `pages/BuildPublishPage.tsx`: `takePublishCredential` 제거. 페이지의 토큰 칸과
게시 흐름은 그대로다.
- 문구(ko/en): 패널 토큰 칸의 라벨·설명을 더하고, 안내를 "바로 위 칸에 입력"으로 고쳤다.
- CHANGELOG.

이슈가 제시한 두 방법 중 "패널에 자체 토큰 칸"을 골랐다. 다른 쪽(토큰이 바뀌어도 `publish_state_unknown`
상태 유지)은 페이지 토큰 칸의 기존 규칙 — 토큰이 바뀌면 준비 상태를 다시 확인한다(#615) — 과 얽힌다.

## 테스트 (`__tests__/publishRecovery.test.tsx`)

- 페이지 수준(이슈가 요구한 것): 게시 → `publish_state_unknown` → 토큰 없이 원격 확인은 나가지 않고
안내만 → **패널의 칸에 토큰 입력 후에도 패널이 남아 있음** → 원격 확인이 그 토큰을 헤더에, destination 을
본문에 실어 한 번 나감 → `confirmed` 표시, 재게시 버튼 없음, 토큰은 storage 에 없음.
- 패널: 자격 증명을 서버가 가진 배포에서는 칸이 없다 / 토큰은 한 요청 뒤 비워지고 두 번째 확인은 토큰 없이 나가지 않는다
/ 형식이 틀린 토큰은 보내지 않는다 / reset 은 헤더 없이 `DELETE` 로 나가고 입력한 토큰은 칸에 남는다.

## 검증

- `npx vitest run __tests__/publishRecovery.test.tsx` → `Tests 18 passed
(18)`.
- publish·locale 관련 8개 파일 → `Tests  131 passed (131)`.
- `npx tsc --noEmit`, eslint 출력 없음.
- 전체 스위트는 로컬에서 돌리지 않았다 — CI 에 맡긴다.
- **브라우저에서 보지 않았다.** 새 칸의 배치와 문구는 #748 의 화면 확인 범위에 들어간다.

## 남는 것

사용자가 복구 패널이 떠 있는 동안 **페이지의** 토큰 칸이나 destination 을 바꾸면 패널은 여전히 사라진다(게시 폼을
처음으로 되돌리는 기존 동작). 기록은 Builder 에 남아 있으므로 같은 게시를 다시 실행하면
`publish_state_unknown` 과 함께 패널이 돌아오지만, 그 경로를 테스트로 고정하지는 않았다.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Eomdahyeon <213566566+Eomdahyeon@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Yeongseon Choe <yeongseon.choe@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(publish): publish_state_unknown has no recovery action — reconcile and reset are not wired

2 participants