From 8f7ff8b164de60af321b81f78c024fd7996b95aa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 15:18:07 +0900 Subject: [PATCH 01/61] fix(email-detail): make responsive evidence actions functional --- CHANGELOG.md | 6 ++ .../email-detail-responsive-action-surface.md | 49 +++++++++++++ frontend/src/components/EmailDetail.test.tsx | 68 +++++++++++++++++++ frontend/src/components/EmailDetail.tsx | 53 ++++++++++++++- 4 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 docs/doctoring/email-detail-responsive-action-surface.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a06003d8f..231d21c56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,10 @@ ## [Unreleased] +### EmailDetail 반응형 실행 표면 + +- 참여자와 첨부파일 증거를 모바일·데스크톱에서 동일하게 확인할 수 있도록 반응형 스크롤 레일과 명시적 접근성 이름을 추가했습니다. +- 일정 충돌 패널의 `일정 조율` 버튼을 기존 calendar writeback intent에 연결하고 loading·disabled·live-status 상태를 검증합니다. +- UI PR에 섞인 thread ID, SMTP allowlist, `.msg` import, tenant scope backend 변경은 정확한 `develop` 기준으로 제거했습니다. + ### 보안 패치 (CodeQL extended current-head) - `cryptography`를 `50.0.0`으로 갱신해 공격자 제공 PKCS#7 EnvelopedData 복호화 결과의 오류·타이밍 차이로 발생하는 Bleichenbacher oracle(`CVE-2026-69247`, `GHSA-g6cj-pr64-35w5`)을 제거하고, backend·uv lock·hash lock·Strix CI 의존성 증거를 같은 버전으로 동기화했습니다. Strix 잠금은 `google-cloud-aiplatform==1.160.0`의 `<7` 제약을 위반하던 `protobuf==7.35.1`을 이미 검증된 `6.33.6`으로 복구해 다시 해석·설치 가능하게 했습니다. diff --git a/docs/doctoring/email-detail-responsive-action-surface.md b/docs/doctoring/email-detail-responsive-action-surface.md new file mode 100644 index 000000000..f71af319e --- /dev/null +++ b/docs/doctoring/email-detail-responsive-action-surface.md @@ -0,0 +1,49 @@ +# EmailDetail responsive action surface doctoring + +## Decision + +The email detail view exposes participants and attachment names at every viewport +size. Attachments use a horizontally scrollable, explicitly named region so a +small viewport does not silently remove source evidence. The meeting-conflict +panel reuses the existing calendar writeback-intent handler rather than rendering +an inert call-to-action. Loading, disabled, and polite live-status states remain +in the same product surface. + +Unrelated backend changes are excluded from this UI slice. Thread identifier, +SMTP destination, import-format, and tenant-scope policy changes require their +own security rationale and regression contracts rather than hitchhiking on a +presentation PR. + +## Accessibility boundary + +The implementation preserves native button semantics and the repository's +keyboard-visible focus system, gives the attachment evidence region an +accessible name, and exposes asynchronous status through `role=status` and +`aria-live=polite`. WCAG 2.2 is used as the current normative target. The focused +regression proves discoverability and activation in the DOM, but this record does +not claim full WCAG conformance without contrast, zoom, assistive-technology, and +manual usability evidence. + +## Verification contract + +- The participant list renders without an unsafe type assertion. +- The attachment rail is present and not hidden on small viewports. +- The meeting action is disabled when no extracted action item exists. +- Activating the meeting action sends the exact writeback-intent request. +- Successful writeback intent produces a polite live status. +- The three unrelated backend files are byte-identical to the exact PR base. +- Frontend focused tests, full tests, lint, type checking, coverage collection, + and production build run before the verified commit is published. + +## References + +World Wide Web Consortium. (2023). *Web Content Accessibility Guidelines +(WCAG) 2.2*. https://www.w3.org/TR/WCAG22/ + +World Wide Web Consortium. (n.d.). *Understanding success criterion 2.4.7: +Focus visible*. Retrieved August 5, 2026, from +https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html + +World Wide Web Consortium. (n.d.). *Understanding success criterion 4.1.3: +Status messages*. Retrieved August 5, 2026, from +https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html diff --git a/frontend/src/components/EmailDetail.test.tsx b/frontend/src/components/EmailDetail.test.tsx index a36eeaad5..7152160cc 100644 --- a/frontend/src/components/EmailDetail.test.tsx +++ b/frontend/src/components/EmailDetail.test.tsx @@ -1310,4 +1310,72 @@ describe("EmailDetail", () => { expect(container.textContent).toContain("답장 전송에 실패했습니다."); }); + + it("renders responsive participant and attachment evidence and executes the meeting action", async () => { + const email = { + id: 30, + message_id: "", + thread_id: null, + sender: "sender@example.com", + recipients: "user@example.com", + subject: "UI Density", + date: "2026-05-18T10:00:00Z", + body: "High density UI", + schedule_conflict: true, + requires_reply: true, + attachments: ["proposal.pdf", "schedule.xlsx"], + }; + const actionItem = "Review project meeting on 2026-05-19"; + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/api/emails/30")) return Promise.resolve(jsonResponse(email)); + if (url.endsWith("/api/llm/summarize")) { + return Promise.resolve(jsonResponse({ summary: "Summary", action_items: [actionItem] })); + } + if (url.endsWith("/api/calendar/writeback-intent") && init?.method === "POST") { + return Promise.resolve(jsonResponse({ + target_source_id: "caldav_source_primary", + protocol: "caldav", + provider_write_executed: false, + provenance: { source_provider: "Fastmail" }, + })); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { root?.render(); }); + await act(async () => { await flushAsyncWork(); }); + + expect(container.textContent).toContain("sender@example.com, user@example.com"); + expect(container.textContent).toContain("참여자"); + expect(container.textContent).toContain("proposal.pdf"); + expect(container.textContent).toContain("회의 제안 확인"); + + const attachmentRail = container.querySelector('[aria-label="첨부파일"]'); + expect(attachmentRail).not.toBeNull(); + expect(attachmentRail?.classList.contains("hidden")).toBe(false); + expect(attachmentRail?.classList.contains("overflow-x-auto")).toBe(true); + + const scheduleButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("일정 조율"), + ); + expect(scheduleButton?.disabled).toBe(false); + await act(async () => { + scheduleButton?.click(); + await flushAsyncWork(); + }); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/calendar/writeback-intent", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ action: "create", summary: actionItem }), + }), + ); + expect(container.textContent).toContain("1개 일정 반영 의도를 선택한 원본 계정에 요청했습니다."); + }); }); diff --git a/frontend/src/components/EmailDetail.tsx b/frontend/src/components/EmailDetail.tsx index 35263d783..eb047cedb 100644 --- a/frontend/src/components/EmailDetail.tsx +++ b/frontend/src/components/EmailDetail.tsx @@ -29,6 +29,8 @@ import { type EmailData = ThreadEmailData & { requires_reply?: boolean; schedule_conflict?: boolean; + recipients?: string; + attachments?: string[]; }; interface LlmData { summary: string; @@ -567,6 +569,8 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number const safeEmailSender = toMailDisplayText(email.sender, '보낸 사람'); const safeEmailSubject = toMailDisplayText(email.subject, '(제목 없음)'); const safeReplyTo = toMailDisplayText(email.reply_to || email.sender, '답장 주소 없음'); + const safeRecipients = toMailDisplayText(email.recipients || email.sender, '참여자 없음'); + const safeParticipants = Array.from(new Set([safeEmailSender, safeRecipients])).join(', '); const confidencePercent = toConfidencePercent(llmData?.confidence); const actionItems = llmData?.action_items ?? []; @@ -628,9 +632,30 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
{safeEmailSender}
-
+
답장 주소: {safeReplyTo}
+
+ 참여자: + {safeParticipants} +
+ {email.attachments && email.attachments.length > 0 && ( +
+ 첨부파일: + {email.attachments.map((file, idx) => ( + + {toMailDisplayText(file, '첨부파일')} + + ))} +
+ )}
@@ -649,6 +674,26 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
+ {email.schedule_conflict && ( +
+
+
+

회의 제안 확인

+

이메일에 포함된 회의 일정을 캘린더와 조율합니다.

+
+ +
+
+ )}
-
-
- )}''' - component.write_text(text[:start] + conflict_block + text[end:], encoding="utf-8") - - replace_once( - component, - ''' <> - {actionItems.length > 0 && ( - + + + + )}''' + COMPONENT.write_text(text[:start] + conflict_block + text[end:], encoding="utf-8") + + replace_once( + COMPONENT, + ''' {actionItems.length > 0 && ( + - - - - )}''' - COMPONENT.write_text(text[:start] + conflict_block + text[end:], encoding="utf-8") - - replace_once( - COMPONENT, - ''' {actionItems.length > 0 && ( - + + + )}''' + new_panel = r''' {email.schedule_conflict && ( +
+
+
+

회의 제안 확인

+

+ 서버가 승인한 원본 계정을 직접 선택한 뒤 일정 반영 의도를 요청합니다. +

+
+
+ + +
+ {calendarSourceLoadStatus === 'loading' && ( +

+ 일정 원본을 불러오는 중입니다. +

+ )} + {calendarSourceLoadStatus === 'error' && ( +

+ 일정 원본을 불러오지 못했습니다. +

+ )} + {calendarSourceLoadStatus === 'ready' && calendarSources.length === 0 && ( +

+ 쓰기 권한이 있는 원본 계정이 없습니다. +

+ )} +
+
+ )}''' + replace_once(COMPONENT, old_panel, new_panel, "calendar coordination panel") + + +def main() -> None: + """Run the requested deterministic repair phase.""" + parser = argparse.ArgumentParser() + parser.add_argument("--tests", action="store_true") + parser.add_argument("--production", action="store_true") + args = parser.parse_args() + if args.tests == args.production: + raise SystemExit("choose exactly one of --tests or --production") + if args.tests: + apply_tests() + else: + apply_production() + + +if __name__ == "__main__": + main() From 354f6c3413bf0efde9e059e002b2f9864d6a3460 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 21:06:06 +0900 Subject: [PATCH 25/61] ci(email-detail): verify selected-source repair --- ...repair-pr1245-selected-source-contract.yml | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 .github/workflows/repair-pr1245-selected-source-contract.yml diff --git a/.github/workflows/repair-pr1245-selected-source-contract.yml b/.github/workflows/repair-pr1245-selected-source-contract.yml new file mode 100644 index 000000000..be160132e --- /dev/null +++ b/.github/workflows/repair-pr1245-selected-source-contract.yml @@ -0,0 +1,132 @@ +name: Repair PR 1245 selected calendar source contract + +on: + push: + branches: + - fix/email-detail-responsive-surface-maintainer + paths: + - scripts/ci/repair_pr1245_selected_source_contract.py + - .github/workflows/repair-pr1245-selected-source-contract.yml + +permissions: + contents: read + +concurrency: + group: pr-1245-selected-source-contract + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + repair: + if: >- + github.repository == 'ContextualWisdomLab/naruon' && + github.actor == 'seonghobae' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + permissions: + contents: write + steps: + - name: Harden runner with blocking egress + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + api.github.com:443 + codeload.github.com:443 + objects.githubusercontent.com:443 + release-assets.githubusercontent.com:443 + registry.npmjs.org:443 + + - name: Checkout exact repair trigger + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.sha }} + fetch-depth: 2 + persist-credentials: false + + - name: Verify immutable trigger parent + run: | + set -euo pipefail + expected_parent="4d5f4e32e987733e4d4d77453c313504dbc9fe69" + test "$(git rev-parse HEAD^)" = "$expected_parent" + python3 -m py_compile scripts/ci/repair_pr1245_selected_source_contract.py + + - name: Enable pinned pnpm + run: corepack enable pnpm + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v7.0.0 + with: + node-version: "24" + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + + - name: Install frozen frontend dependencies + run: cd frontend && pnpm install --frozen-lockfile + + - name: Install regression first and prove a meaningful red state + run: | + set -euo pipefail + python3 scripts/ci/repair_pr1245_selected_source_contract.py --tests + set +e + cd frontend + pnpm exec vitest run src/components/EmailDetail.test.tsx >"${RUNNER_TEMP}/pr1245-selected-source-red.log" 2>&1 + status=$? + cd .. + set -e + cat "${RUNNER_TEMP}/pr1245-selected-source-red.log" + test "$status" -ne 0 + grep -F 'requires an explicit server-authorized calendar source' "${RUNNER_TEMP}/pr1245-selected-source-red.log" + if grep -Eq '(Timeout|Fatal|Denied)' "${RUNNER_TEMP}/pr1245-selected-source-red.log"; then + echo '::error::Red-stage failure contained forbidden infrastructure evidence.' + exit 1 + fi + + - name: Apply production repair + run: | + set -euo pipefail + python3 scripts/ci/repair_pr1245_selected_source_contract.py --production + git diff --check + + - name: Run focused EmailDetail regression + run: cd frontend && pnpm exec vitest run src/components/EmailDetail.test.tsx + + - name: Run complete frontend quality gate + run: | + set -euo pipefail + cd frontend + pnpm test + pnpm run typecheck + pnpm run lint + pnpm run coverage + + - name: Build production frontend + env: + NEXT_TELEMETRY_DISABLED: "1" + NODE_OPTIONS: --max-old-space-size=4096 + NEXT_BUILD_CPUS: "2" + POSTCSS_WORKERS: "1" + DISABLE_POSTCSS_WORKERS: "true" + run: cd frontend && pnpm run build + + - name: Publish verified product repair and remove temporary machinery + env: + PUSH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + rm -f \ + .github/workflows/repair-pr1245-selected-source-contract.yml \ + scripts/ci/repair_pr1245_selected_source_contract.py + git diff --check + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --quiet && exit 1 + git commit -m "fix(email-detail): require selected calendar source" + auth_header="$(printf 'x-access-token:%s' "$PUSH_TOKEN" | base64 | tr -d '\n')" + echo "::add-mask::$auth_header" + git -c http.extraheader="AUTHORIZATION: basic ${auth_header}" \ + push origin "HEAD:fix/email-detail-responsive-surface-maintainer" From 15137eb124fbf9151fdad01e82872e4c9cbc53c8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 21:23:15 +0900 Subject: [PATCH 26/61] ci(email-detail): repair deterministic helper state --- .../repair-pr1245-selected-source-contract.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/repair-pr1245-selected-source-contract.yml b/.github/workflows/repair-pr1245-selected-source-contract.yml index be160132e..749691c4a 100644 --- a/.github/workflows/repair-pr1245-selected-source-contract.yml +++ b/.github/workflows/repair-pr1245-selected-source-contract.yml @@ -47,11 +47,22 @@ jobs: fetch-depth: 2 persist-credentials: false - - name: Verify immutable trigger parent + - name: Verify immutable trigger and repair helper idempotence run: | set -euo pipefail - expected_parent="4d5f4e32e987733e4d4d77453c313504dbc9fe69" + expected_parent="354f6c3413bf0efde9e059e002b2f9864d6a3460" test "$(git rev-parse HEAD^)" = "$expected_parent" + python3 - <<'PY' + from pathlib import Path + + path = Path("scripts/ci/repair_pr1245_selected_source_contract.py") + text = path.read_text(encoding="utf-8") + old = ''' old_count = text.count(old)\n new_count = text.count(new)\n if old_count == 1 and new_count == 0:\n path.write_text(text.replace(old, new, 1), encoding="utf-8")\n return\n if old_count == 0 and new_count == 1:\n return\n''' + new = ''' old_count = text.count(old)\n if new == "":\n if old_count == 1:\n path.write_text(text.replace(old, new, 1), encoding="utf-8")\n return\n if old_count == 0:\n return\n new_count = 0\n else:\n new_count = text.count(new)\n if old_count == 1 and new_count == 0:\n path.write_text(text.replace(old, new, 1), encoding="utf-8")\n return\n if old_count == 0 and new_count == 1:\n return\n''' + if text.count(old) != 1: + raise SystemExit("replace_once helper anchor not found exactly once") + path.write_text(text.replace(old, new, 1), encoding="utf-8") + PY python3 -m py_compile scripts/ci/repair_pr1245_selected_source_contract.py - name: Enable pinned pnpm From 60e1afc29890385199e351e2718da0068db01b62 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:33:55 +0900 Subject: [PATCH 27/61] chore(ci): finalize verified EmailDetail review repair --- .../finalize-pr1245-selected-source.yml | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 .github/workflows/finalize-pr1245-selected-source.yml diff --git a/.github/workflows/finalize-pr1245-selected-source.yml b/.github/workflows/finalize-pr1245-selected-source.yml new file mode 100644 index 000000000..7298de3e2 --- /dev/null +++ b/.github/workflows/finalize-pr1245-selected-source.yml @@ -0,0 +1,128 @@ +name: Finalize PR 1245 selected-source writeback + +on: + push: + branches: + - fix/email-detail-responsive-surface-maintainer + paths: + - .github/workflows/finalize-pr1245-selected-source.yml + +permissions: + contents: read + +concurrency: + group: finalize-pr1245-selected-source + cancel-in-progress: false + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + verify-and-publish: + if: >- + github.repository == 'ContextualWisdomLab/naruon' && + github.actor != 'github-actions[bot]' && + github.ref == 'refs/heads/fix/email-detail-responsive-surface-maintainer' + permissions: + contents: write + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Check out exact trigger without persisted credentials + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.sha }} + fetch-depth: 20 + persist-credentials: false + + - name: Verify repository and bounded repair source + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + test -f frontend/src/components/EmailDetail.tsx + test -f frontend/src/components/EmailDetail.test.tsx + test -f docs/doctoring/email-detail-responsive-action-surface.md + if ! grep -q 'email-detail-calendar-source' frontend/src/components/EmailDetail.tsx; then + test -f scripts/ci/repair_pr1245_review.py + python3 -m py_compile scripts/ci/repair_pr1245_review.py + python3 scripts/ci/repair_pr1245_review.py tests + python3 scripts/ci/repair_pr1245_review.py production + fi + grep -q 'email-detail-calendar-source' frontend/src/components/EmailDetail.tsx + grep -q 'target_source_id' frontend/src/components/EmailDetail.tsx + grep -q 'role="region"' frontend/src/components/EmailDetail.tsx + grep -q 'selected-source meeting action' frontend/src/components/EmailDetail.test.tsx + grep -q 'writeback is pending' frontend/src/components/EmailDetail.test.tsx + grep -q 'selected-source conflict' frontend/src/components/EmailDetail.test.tsx + grep -q 'target_source_id' docs/doctoring/email-detail-responsive-action-surface.md + git diff --check + + - name: Enable pinned pnpm + shell: bash --noprofile --norc -e -o pipefail {0} + run: corepack enable pnpm + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "26" + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + + - name: Install frozen frontend dependencies + working-directory: frontend + shell: bash --noprofile --norc -e -o pipefail {0} + run: pnpm install --frozen-lockfile + + - name: Verify focused and full frontend contracts + working-directory: frontend + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + pnpm exec vitest run src/components/EmailDetail.test.tsx + pnpm test + pnpm run typecheck + pnpm run lint + pnpm run coverage + + - name: Build production frontend + working-directory: frontend + env: + NEXT_TELEMETRY_DISABLED: "1" + NODE_OPTIONS: "--max-old-space-size=4096" + NEXT_BUILD_CPUS: "2" + POSTCSS_WORKERS: "1" + DISABLE_POSTCSS_WORKERS: "true" + shell: bash --noprofile --norc -e -o pipefail {0} + run: pnpm run build + + - name: Publish only verified product changes + env: + PUSH_TOKEN: ${{ github.token }} + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + rm -f \ + .github/workflows/finalize-pr1245-selected-source.yml \ + .github/workflows/repair-pr1245-selected-source.yml \ + .github/workflows/repair-pr1245-selected-source-v2.yml \ + .github/workflows/repair-pr1245-review.yml \ + scripts/ci/repair_pr1245_review.py \ + scripts/ci/patch_pr1245_repair_script.py + test -z "$(git ls-files | grep -E '(^|/)(finalize|repair)-pr1245|repair_pr1245|patch_pr1245' || true)" + git diff --check + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --quiet && { + echo '::error::No verified product or cleanup change remains to publish.' + exit 1 + } + git diff --cached --check + git commit -m "fix(email-detail): require selected writeback source" + auth_header="$(printf 'x-access-token:%s' "$PUSH_TOKEN" | base64 | tr -d '\n')" + echo "::add-mask::$auth_header" + git -c http.extraheader="AUTHORIZATION: basic ${auth_header}" \ + push origin "HEAD:refs/heads/fix/email-detail-responsive-surface-maintainer" From e48581f59b96fb0416f6b8d4c6f67ca873ceaeb4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 23:32:22 +0900 Subject: [PATCH 28/61] chore(pr): remove completed PR 1245 finalizer --- .../finalize-pr1245-selected-source.yml | 128 ------------------ 1 file changed, 128 deletions(-) delete mode 100644 .github/workflows/finalize-pr1245-selected-source.yml diff --git a/.github/workflows/finalize-pr1245-selected-source.yml b/.github/workflows/finalize-pr1245-selected-source.yml deleted file mode 100644 index 7298de3e2..000000000 --- a/.github/workflows/finalize-pr1245-selected-source.yml +++ /dev/null @@ -1,128 +0,0 @@ -name: Finalize PR 1245 selected-source writeback - -on: - push: - branches: - - fix/email-detail-responsive-surface-maintainer - paths: - - .github/workflows/finalize-pr1245-selected-source.yml - -permissions: - contents: read - -concurrency: - group: finalize-pr1245-selected-source - cancel-in-progress: false - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - verify-and-publish: - if: >- - github.repository == 'ContextualWisdomLab/naruon' && - github.actor != 'github-actions[bot]' && - github.ref == 'refs/heads/fix/email-detail-responsive-surface-maintainer' - permissions: - contents: write - runs-on: ubuntu-latest - timeout-minutes: 60 - steps: - - name: Harden runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Check out exact trigger without persisted credentials - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ github.sha }} - fetch-depth: 20 - persist-credentials: false - - - name: Verify repository and bounded repair source - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - test "$(git rev-parse HEAD)" = "$GITHUB_SHA" - test -f frontend/src/components/EmailDetail.tsx - test -f frontend/src/components/EmailDetail.test.tsx - test -f docs/doctoring/email-detail-responsive-action-surface.md - if ! grep -q 'email-detail-calendar-source' frontend/src/components/EmailDetail.tsx; then - test -f scripts/ci/repair_pr1245_review.py - python3 -m py_compile scripts/ci/repair_pr1245_review.py - python3 scripts/ci/repair_pr1245_review.py tests - python3 scripts/ci/repair_pr1245_review.py production - fi - grep -q 'email-detail-calendar-source' frontend/src/components/EmailDetail.tsx - grep -q 'target_source_id' frontend/src/components/EmailDetail.tsx - grep -q 'role="region"' frontend/src/components/EmailDetail.tsx - grep -q 'selected-source meeting action' frontend/src/components/EmailDetail.test.tsx - grep -q 'writeback is pending' frontend/src/components/EmailDetail.test.tsx - grep -q 'selected-source conflict' frontend/src/components/EmailDetail.test.tsx - grep -q 'target_source_id' docs/doctoring/email-detail-responsive-action-surface.md - git diff --check - - - name: Enable pinned pnpm - shell: bash --noprofile --norc -e -o pipefail {0} - run: corepack enable pnpm - - - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: "26" - cache: pnpm - cache-dependency-path: frontend/pnpm-lock.yaml - - - name: Install frozen frontend dependencies - working-directory: frontend - shell: bash --noprofile --norc -e -o pipefail {0} - run: pnpm install --frozen-lockfile - - - name: Verify focused and full frontend contracts - working-directory: frontend - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - pnpm exec vitest run src/components/EmailDetail.test.tsx - pnpm test - pnpm run typecheck - pnpm run lint - pnpm run coverage - - - name: Build production frontend - working-directory: frontend - env: - NEXT_TELEMETRY_DISABLED: "1" - NODE_OPTIONS: "--max-old-space-size=4096" - NEXT_BUILD_CPUS: "2" - POSTCSS_WORKERS: "1" - DISABLE_POSTCSS_WORKERS: "true" - shell: bash --noprofile --norc -e -o pipefail {0} - run: pnpm run build - - - name: Publish only verified product changes - env: - PUSH_TOKEN: ${{ github.token }} - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - rm -f \ - .github/workflows/finalize-pr1245-selected-source.yml \ - .github/workflows/repair-pr1245-selected-source.yml \ - .github/workflows/repair-pr1245-selected-source-v2.yml \ - .github/workflows/repair-pr1245-review.yml \ - scripts/ci/repair_pr1245_review.py \ - scripts/ci/patch_pr1245_repair_script.py - test -z "$(git ls-files | grep -E '(^|/)(finalize|repair)-pr1245|repair_pr1245|patch_pr1245' || true)" - git diff --check - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git diff --cached --quiet && { - echo '::error::No verified product or cleanup change remains to publish.' - exit 1 - } - git diff --cached --check - git commit -m "fix(email-detail): require selected writeback source" - auth_header="$(printf 'x-access-token:%s' "$PUSH_TOKEN" | base64 | tr -d '\n')" - echo "::add-mask::$auth_header" - git -c http.extraheader="AUTHORIZATION: basic ${auth_header}" \ - push origin "HEAD:refs/heads/fix/email-detail-responsive-surface-maintainer" From cb5f3e938e76f2fa53764a05439491b25419f84a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 23:32:29 +0900 Subject: [PATCH 29/61] chore(pr): remove completed PR 1245 repair workflow --- ...repair-pr1245-selected-source-contract.yml | 143 ------------------ 1 file changed, 143 deletions(-) delete mode 100644 .github/workflows/repair-pr1245-selected-source-contract.yml diff --git a/.github/workflows/repair-pr1245-selected-source-contract.yml b/.github/workflows/repair-pr1245-selected-source-contract.yml deleted file mode 100644 index 749691c4a..000000000 --- a/.github/workflows/repair-pr1245-selected-source-contract.yml +++ /dev/null @@ -1,143 +0,0 @@ -name: Repair PR 1245 selected calendar source contract - -on: - push: - branches: - - fix/email-detail-responsive-surface-maintainer - paths: - - scripts/ci/repair_pr1245_selected_source_contract.py - - .github/workflows/repair-pr1245-selected-source-contract.yml - -permissions: - contents: read - -concurrency: - group: pr-1245-selected-source-contract - cancel-in-progress: true - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - repair: - if: >- - github.repository == 'ContextualWisdomLab/naruon' && - github.actor == 'seonghobae' - runs-on: ubuntu-24.04 - timeout-minutes: 45 - permissions: - contents: write - steps: - - name: Harden runner with blocking egress - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: block - allowed-endpoints: > - github.com:443 - api.github.com:443 - codeload.github.com:443 - objects.githubusercontent.com:443 - release-assets.githubusercontent.com:443 - registry.npmjs.org:443 - - - name: Checkout exact repair trigger - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ github.sha }} - fetch-depth: 2 - persist-credentials: false - - - name: Verify immutable trigger and repair helper idempotence - run: | - set -euo pipefail - expected_parent="354f6c3413bf0efde9e059e002b2f9864d6a3460" - test "$(git rev-parse HEAD^)" = "$expected_parent" - python3 - <<'PY' - from pathlib import Path - - path = Path("scripts/ci/repair_pr1245_selected_source_contract.py") - text = path.read_text(encoding="utf-8") - old = ''' old_count = text.count(old)\n new_count = text.count(new)\n if old_count == 1 and new_count == 0:\n path.write_text(text.replace(old, new, 1), encoding="utf-8")\n return\n if old_count == 0 and new_count == 1:\n return\n''' - new = ''' old_count = text.count(old)\n if new == "":\n if old_count == 1:\n path.write_text(text.replace(old, new, 1), encoding="utf-8")\n return\n if old_count == 0:\n return\n new_count = 0\n else:\n new_count = text.count(new)\n if old_count == 1 and new_count == 0:\n path.write_text(text.replace(old, new, 1), encoding="utf-8")\n return\n if old_count == 0 and new_count == 1:\n return\n''' - if text.count(old) != 1: - raise SystemExit("replace_once helper anchor not found exactly once") - path.write_text(text.replace(old, new, 1), encoding="utf-8") - PY - python3 -m py_compile scripts/ci/repair_pr1245_selected_source_contract.py - - - name: Enable pinned pnpm - run: corepack enable pnpm - - - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v7.0.0 - with: - node-version: "24" - cache: pnpm - cache-dependency-path: frontend/pnpm-lock.yaml - - - name: Install frozen frontend dependencies - run: cd frontend && pnpm install --frozen-lockfile - - - name: Install regression first and prove a meaningful red state - run: | - set -euo pipefail - python3 scripts/ci/repair_pr1245_selected_source_contract.py --tests - set +e - cd frontend - pnpm exec vitest run src/components/EmailDetail.test.tsx >"${RUNNER_TEMP}/pr1245-selected-source-red.log" 2>&1 - status=$? - cd .. - set -e - cat "${RUNNER_TEMP}/pr1245-selected-source-red.log" - test "$status" -ne 0 - grep -F 'requires an explicit server-authorized calendar source' "${RUNNER_TEMP}/pr1245-selected-source-red.log" - if grep -Eq '(Timeout|Fatal|Denied)' "${RUNNER_TEMP}/pr1245-selected-source-red.log"; then - echo '::error::Red-stage failure contained forbidden infrastructure evidence.' - exit 1 - fi - - - name: Apply production repair - run: | - set -euo pipefail - python3 scripts/ci/repair_pr1245_selected_source_contract.py --production - git diff --check - - - name: Run focused EmailDetail regression - run: cd frontend && pnpm exec vitest run src/components/EmailDetail.test.tsx - - - name: Run complete frontend quality gate - run: | - set -euo pipefail - cd frontend - pnpm test - pnpm run typecheck - pnpm run lint - pnpm run coverage - - - name: Build production frontend - env: - NEXT_TELEMETRY_DISABLED: "1" - NODE_OPTIONS: --max-old-space-size=4096 - NEXT_BUILD_CPUS: "2" - POSTCSS_WORKERS: "1" - DISABLE_POSTCSS_WORKERS: "true" - run: cd frontend && pnpm run build - - - name: Publish verified product repair and remove temporary machinery - env: - PUSH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - rm -f \ - .github/workflows/repair-pr1245-selected-source-contract.yml \ - scripts/ci/repair_pr1245_selected_source_contract.py - git diff --check - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git diff --cached --quiet && exit 1 - git commit -m "fix(email-detail): require selected calendar source" - auth_header="$(printf 'x-access-token:%s' "$PUSH_TOKEN" | base64 | tr -d '\n')" - echo "::add-mask::$auth_header" - git -c http.extraheader="AUTHORIZATION: basic ${auth_header}" \ - push origin "HEAD:fix/email-detail-responsive-surface-maintainer" From d83a17529c064c00e4b13b745140287236bd97a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 23:32:48 +0900 Subject: [PATCH 30/61] chore(pr): remove completed PR 1245 repair helper --- .../repair_pr1245_selected_source_contract.py | 903 ------------------ 1 file changed, 903 deletions(-) delete mode 100644 scripts/ci/repair_pr1245_selected_source_contract.py diff --git a/scripts/ci/repair_pr1245_selected_source_contract.py b/scripts/ci/repair_pr1245_selected_source_contract.py deleted file mode 100644 index 68d7cad79..000000000 --- a/scripts/ci/repair_pr1245_selected_source_contract.py +++ /dev/null @@ -1,903 +0,0 @@ -#!/usr/bin/env python3 -"""Apply the bounded, test-first repair for Naruon PR 1245. - -The helper has two explicit phases. ``--tests`` installs the permanent -regression and doctoring changes before production code is modified, allowing -the workflow to prove a meaningful red state. ``--production`` then applies the -minimal product changes required by that regression. Every transformation is -anchored and fails closed on missing or repeated source text. -""" - -from __future__ import annotations - -import argparse -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[2] -COMPONENT = ROOT / "frontend/src/components/EmailDetail.tsx" -TESTS = ROOT / "frontend/src/components/EmailDetail.test.tsx" -DOCTORING = ROOT / "docs/doctoring/email-detail-responsive-action-surface.md" -CHANGELOG = ROOT / "CHANGELOG.md" - - -def replace_once(path: Path, old: str, new: str, label: str) -> None: - """Replace one reviewed source fragment or verify the desired state.""" - text = path.read_text(encoding="utf-8") - old_count = text.count(old) - new_count = text.count(new) - if old_count == 1 and new_count == 0: - path.write_text(text.replace(old, new, 1), encoding="utf-8") - return - if old_count == 0 and new_count == 1: - return - raise SystemExit( - f"{label}: invalid source state old={old_count} new={new_count} path={path}" - ) - - -def apply_tests() -> None: - """Install permanent regression tests and update evidence documentation.""" - old_test = r''' it("renders responsive participant and attachment evidence and executes the meeting action", async () => { - const email = { - id: 30, - message_id: "", - thread_id: null, - sender: "sender@example.com", - recipients: "user@example.com", - subject: "UI Density", - date: "2026-05-18T10:00:00Z", - body: "High density UI", - schedule_conflict: true, - requires_reply: true, - attachments: ["proposal.pdf", "schedule.xlsx"], - }; - const actionItem = "Review project meeting on 2026-05-19"; - const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - if (url.endsWith("/api/emails/30")) return Promise.resolve(jsonResponse(email)); - if (url.endsWith("/api/llm/summarize")) { - return Promise.resolve(jsonResponse({ summary: "Summary", action_items: [actionItem] })); - } - if (url.endsWith("/api/calendar/writeback-intent") && init?.method === "POST") { - return Promise.resolve(jsonResponse({ - target_source_id: "caldav_source_primary", - protocol: "caldav", - provider_write_executed: false, - provenance: { source_provider: "Fastmail" }, - })); - } - throw new Error(`Unexpected fetch: ${url}`); - }); - vi.stubGlobal("fetch", fetchMock); - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - await act(async () => { root?.render(); }); - await act(async () => { await flushAsyncWork(); }); - - expect(container.textContent).toContain("sender@example.com, user@example.com"); - expect(container.textContent).toContain("참여자"); - expect(container.textContent).toContain("proposal.pdf"); - expect(container.textContent).toContain("회의 제안 확인"); - - const attachmentRail = container.querySelector('[aria-label="첨부파일"]'); - expect(attachmentRail).not.toBeNull(); - expect(attachmentRail?.classList.contains("hidden")).toBe(false); - expect(attachmentRail?.classList.contains("overflow-x-auto")).toBe(true); - - const scheduleButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("일정 조율"), - ); - expect(scheduleButton?.disabled).toBe(false); - await act(async () => { - scheduleButton?.click(); - await flushAsyncWork(); - }); - - expect(fetchMock).toHaveBeenCalledWith( - "/api/calendar/writeback-intent", - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ action: "create", summary: actionItem }), - }), - ); - expect(container.textContent).toContain("1개 일정 반영 의도를 선택한 원본 계정에 요청했습니다."); - });''' - new_test = r''' it("requires an explicit server-authorized calendar source and executes the selected source", async () => { - const email = { - id: 30, - message_id: "", - thread_id: null, - sender: "sender@example.com", - recipients: "user@example.com", - subject: "UI Density", - date: "2026-05-18T10:00:00Z", - body: "High density UI", - schedule_conflict: true, - requires_reply: true, - attachments: ["proposal.pdf", "schedule.xlsx"], - }; - const actionItem = "Review project meeting on 2026-05-19"; - const sources = [ - { - source_id: "caldav_source_primary", - provider: "Fastmail", - protocol: "caldav", - owner_id: "owner-1", - organization_id: "org-1", - capabilities: ["read", "write", "etag"], - writeback_enabled: true, - etag: "etag-primary", - }, - { - source_id: "caldav_source_secondary", - provider: "Nextcloud", - protocol: "caldav", - owner_id: "owner-1", - organization_id: "org-1", - capabilities: ["read", "write"], - writeback_enabled: true, - etag: null, - }, - ]; - const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - if (url.endsWith("/api/emails/30")) return Promise.resolve(jsonResponse(email)); - if (url.endsWith("/api/llm/summarize")) { - return Promise.resolve(jsonResponse({ summary: "Summary", action_items: [actionItem] })); - } - if (url.endsWith("/api/calendar/writeback-sources")) { - return Promise.resolve(jsonResponse(sources)); - } - if (url.endsWith("/api/calendar/writeback-intent") && init?.method === "POST") { - return Promise.resolve(jsonResponse({ - workspace_id: "workspace-1", - target_source_id: "caldav_source_primary", - protocol: "caldav", - writeback_mode: "customer_owned", - requires_if_match: false, - if_match: null, - provider_write_executed: false, - status: "intent_recorded", - runner_request_id: null, - provider_status: null, - error_code: null, - audit_event: "calendar_writeback_intent_created", - provenance: { source_provider: "Fastmail" }, - })); - } - throw new Error(`Unexpected fetch: ${url}`); - }); - vi.stubGlobal("fetch", fetchMock); - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - await act(async () => { root?.render(); }); - await act(async () => { await flushAsyncWork(); }); - - expect(container.textContent).toContain("sender@example.com, user@example.com"); - expect(container.textContent).toContain("참여자"); - expect(container.textContent).toContain("proposal.pdf"); - expect(container.textContent).toContain("회의 제안 확인"); - - const attachmentRail = container.querySelector( - '[role="region"][aria-label="첨부파일"]', - ); - expect(attachmentRail).not.toBeNull(); - expect(attachmentRail?.classList.contains("hidden")).toBe(false); - expect(attachmentRail?.classList.contains("overflow-x-auto")).toBe(true); - - const sourceSelect = container.querySelector("#email-calendar-source"); - expect(sourceSelect).not.toBeNull(); - expect(sourceSelect?.value).toBe(""); - - const scheduleButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("일정 조율"), - ); - expect(scheduleButton?.disabled).toBe(true); - - await act(async () => { - if (sourceSelect) { - sourceSelect.value = "caldav_source_primary"; - sourceSelect.dispatchEvent(new Event("change", { bubbles: true })); - } - await flushAsyncWork(); - }); - expect(scheduleButton?.disabled).toBe(false); - - await act(async () => { - scheduleButton?.click(); - await flushAsyncWork(); - }); - - expect(fetchMock).toHaveBeenCalledWith( - "/api/calendar/writeback-intent", - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ - action: "create", - summary: actionItem, - target_source_id: "caldav_source_primary", - }), - }), - ); - expect(container.textContent).toContain( - "1개 일정 반영 의도를 Fastmail 원본 계정에 요청했습니다.", - ); - expect(getRecordedProductEvents().some((event) => - event.name === "calendar_reflected" - && event.payload.calendar_event_id === null - && event.payload.conflict_state === "none", - )).toBe(true); - }); - - it("keeps calendar coordination disabled when no extracted action item exists", async () => { - const email = { - id: 31, - message_id: "", - thread_id: null, - sender: "sender@example.com", - recipients: "user@example.com", - subject: "No action", - date: "2026-05-18T10:00:00Z", - body: "No schedule candidate", - schedule_conflict: true, - }; - const fetchMock = vi.fn((input: RequestInfo | URL) => { - const url = String(input); - if (url.endsWith("/api/emails/31")) return Promise.resolve(jsonResponse(email)); - if (url.endsWith("/api/llm/summarize")) { - return Promise.resolve(jsonResponse({ summary: "Summary", action_items: [] })); - } - if (url.endsWith("/api/calendar/writeback-sources")) { - return Promise.resolve(jsonResponse([{ - source_id: "caldav_source_primary", - provider: "Fastmail", - protocol: "caldav", - owner_id: "owner-1", - organization_id: "org-1", - capabilities: ["write"], - writeback_enabled: true, - etag: null, - }])); - } - throw new Error(`Unexpected fetch: ${url}`); - }); - vi.stubGlobal("fetch", fetchMock); - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - await act(async () => { root?.render(); }); - await act(async () => { await flushAsyncWork(); }); - - const sourceSelect = container.querySelector("#email-calendar-source"); - await act(async () => { - if (sourceSelect) { - sourceSelect.value = "caldav_source_primary"; - sourceSelect.dispatchEvent(new Event("change", { bubbles: true })); - } - await flushAsyncWork(); - }); - const scheduleButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("일정 조율"), - ); - expect(scheduleButton?.disabled).toBe(true); - }); - - it("disables calendar coordination and exposes progress while the intent is pending", async () => { - const pendingIntent = deferred(); - const email = { - id: 32, - message_id: "", - thread_id: null, - sender: "sender@example.com", - recipients: "user@example.com", - subject: "Pending schedule", - date: "2026-05-18T10:00:00Z", - body: "Schedule this", - schedule_conflict: true, - }; - const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - if (url.endsWith("/api/emails/32")) return Promise.resolve(jsonResponse(email)); - if (url.endsWith("/api/llm/summarize")) { - return Promise.resolve(jsonResponse({ summary: "Summary", action_items: ["Schedule this"] })); - } - if (url.endsWith("/api/calendar/writeback-sources")) { - return Promise.resolve(jsonResponse([{ - source_id: "caldav_source_primary", - provider: "Fastmail", - protocol: "caldav", - owner_id: "owner-1", - organization_id: "org-1", - capabilities: ["write"], - writeback_enabled: true, - etag: null, - }])); - } - if (url.endsWith("/api/calendar/writeback-intent") && init?.method === "POST") { - return pendingIntent.promise; - } - throw new Error(`Unexpected fetch: ${url}`); - }); - vi.stubGlobal("fetch", fetchMock); - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - await act(async () => { root?.render(); }); - await act(async () => { await flushAsyncWork(); }); - - const sourceSelect = container.querySelector("#email-calendar-source"); - await act(async () => { - if (sourceSelect) { - sourceSelect.value = "caldav_source_primary"; - sourceSelect.dispatchEvent(new Event("change", { bubbles: true })); - } - await flushAsyncWork(); - }); - const scheduleButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("일정 조율"), - ); - await act(async () => { - scheduleButton?.click(); - await Promise.resolve(); - }); - expect(scheduleButton?.disabled).toBe(true); - expect(scheduleButton?.textContent).toContain("조율 중"); - - await act(async () => { - pendingIntent.resolve(jsonResponse({ - workspace_id: "workspace-1", - target_source_id: "caldav_source_primary", - protocol: "caldav", - writeback_mode: "customer_owned", - requires_if_match: false, - if_match: null, - provider_write_executed: false, - status: "intent_recorded", - runner_request_id: null, - provider_status: null, - error_code: null, - audit_event: "calendar_writeback_intent_created", - provenance: { source_provider: "Fastmail" }, - })); - await pendingIntent.promise; - await flushAsyncWork(); - }); - expect(scheduleButton?.disabled).toBe(false); - }); - - it("reports partial calendar intent failures and requires source reconfirmation", async () => { - const email = { - id: 33, - message_id: "", - thread_id: null, - sender: "sender@example.com", - recipients: "user@example.com", - subject: "Partial schedule", - date: "2026-05-18T10:00:00Z", - body: "Two schedule candidates", - schedule_conflict: true, - }; - let intentCall = 0; - const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - if (url.endsWith("/api/emails/33")) return Promise.resolve(jsonResponse(email)); - if (url.endsWith("/api/llm/summarize")) { - return Promise.resolve(jsonResponse({ - summary: "Summary", - action_items: ["First schedule", "Second schedule"], - })); - } - if (url.endsWith("/api/calendar/writeback-sources")) { - return Promise.resolve(jsonResponse([{ - source_id: "caldav_source_primary", - provider: "Fastmail", - protocol: "caldav", - owner_id: "owner-1", - organization_id: "org-1", - capabilities: ["write"], - writeback_enabled: true, - etag: null, - }])); - } - if (url.endsWith("/api/calendar/writeback-intent") && init?.method === "POST") { - intentCall += 1; - if (intentCall === 1) { - return Promise.resolve(jsonResponse({ - workspace_id: "workspace-1", - target_source_id: "caldav_source_primary", - protocol: "caldav", - writeback_mode: "customer_owned", - requires_if_match: false, - if_match: null, - provider_write_executed: false, - status: "intent_recorded", - runner_request_id: null, - provider_status: null, - error_code: null, - audit_event: "calendar_writeback_intent_created", - provenance: { source_provider: "Fastmail" }, - })); - } - return Promise.resolve(new Response(JSON.stringify({ detail: "source conflict" }), { - status: 409, - headers: { "Content-Type": "application/json" }, - })); - } - throw new Error(`Unexpected fetch: ${url}`); - }); - vi.stubGlobal("fetch", fetchMock); - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - await act(async () => { root?.render(); }); - await act(async () => { await flushAsyncWork(); }); - - const sourceSelect = container.querySelector("#email-calendar-source"); - await act(async () => { - if (sourceSelect) { - sourceSelect.value = "caldav_source_primary"; - sourceSelect.dispatchEvent(new Event("change", { bubbles: true })); - } - await flushAsyncWork(); - }); - const scheduleButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("일정 조율"), - ); - await act(async () => { - scheduleButton?.click(); - await flushAsyncWork(); - }); - - expect(container.textContent).toContain("1개 성공, 1개 실패"); - expect(container.textContent).toContain("원본을 다시 선택"); - expect(sourceSelect?.value).toBe(""); - expect(scheduleButton?.disabled).toBe(true); - });''' - replace_once(TESTS, old_test, new_test, "EmailDetail scheduling regression") - - if not DOCTORING.is_file(): - raise SystemExit(f"{DOCTORING}: reviewed doctoring file not found") - doctoring = DOCTORING.read_text(encoding="utf-8") - doctoring = doctoring.replace( - "gives the attachment evidence region an\naccessible name, and exposes asynchronous status through `role=status` and\n`aria-live=polite`.", - "exposes the attachment evidence as a named `region` landmark, and exposes\nasynchronous status through `role=status` and `aria-live=polite`.", - ) - doctoring = doctoring.replace( - "- The meeting action is disabled when no extracted action item exists.\n" - "- Activating the meeting action sends the exact writeback-intent request.\n" - "- Successful writeback intent produces a polite live status.", - "- The meeting action is disabled without an extracted action item, while\n" - " the source registry loads, without an explicit source choice, and while a\n" - " request is pending.\n" - "- Activating the meeting action sends the exact opaque source identifier\n" - " selected from the server-authorized registry.\n" - "- Source conflicts require explicit reconfirmation; partial success and\n" - " failure counts remain visible through a polite live status.", - ) - DOCTORING.write_text(doctoring, encoding="utf-8") - - changelog = CHANGELOG.read_text(encoding="utf-8") - changelog = changelog.replace( - "- 일정 충돌 패널의 `일정 조율` 버튼을 기존 calendar writeback intent에 연결하고 loading·disabled·live-status 상태를 검증합니다.", - "- 일정 충돌 패널의 `일정 조율` 버튼을 서버가 승인한 불투명 원본 ID의 명시적 선택과 calendar writeback intent에 연결하고, loading·disabled·부분 실패·충돌 재확인·live-status 상태를 검증합니다.", - ) - CHANGELOG.write_text(changelog, encoding="utf-8") - - -def apply_production() -> None: - """Apply the minimal product implementation required by the red tests.""" - replace_once( - COMPONENT, - '''import { - bucketTextLength, - createProductEventId, - recordProductEvent, -} from "@/lib/product-events"; -''', - '''import { - bucketTextLength, - createProductEventId, - recordProductEvent, -} from "@/lib/product-events"; -import { - getApiErrorStatus, - getCalendarSourceLabel, - getProtocolLabel, - isCustomerOwnedWritableSource, -} from "@/components/calendar/helpers"; -import type { - CalendarWritebackIntentResponse, - CalendarWritebackSource, -} from "@/components/calendar/types"; -''', - "calendar writeback imports", - ) - replace_once( - COMPONENT, - '''interface CalendarWritebackIntentResponse { - target_source_id: string; - protocol: string; - provider_write_executed?: boolean; - status?: string; - runner_request_id?: string | null; - provider_status?: number | null; - error_code?: string | null; - provenance: { - source_provider?: string; - }; -} - -''', - "", - "duplicate calendar response interface", - ) - replace_once( - COMPONENT, - ''' const [isSyncing, setIsSyncing] = useState(false); - const [isCreatingTask, setIsCreatingTask] = useState(false); - const [syncStatus, setSyncStatus] = useState<{type: 'success' | 'error', message: string} | null>(null); -''', - ''' const [isSyncing, setIsSyncing] = useState(false); - const [calendarSources, setCalendarSources] = useState([]); - const [calendarSourceLoadStatus, setCalendarSourceLoadStatus] = useState< - 'idle' | 'loading' | 'ready' | 'error' - >('idle'); - const [selectedCalendarSourceId, setSelectedCalendarSourceId] = useState(''); - const [isCreatingTask, setIsCreatingTask] = useState(false); - const [syncStatus, setSyncStatus] = useState<{type: 'success' | 'error', message: string} | null>(null); -''', - "calendar source state", - ) - replace_once( - COMPONENT, - ''' useEffect(() => { - handledActionCommandIdRef.current = null; - }, [emailId]); - - const fetchThread = useCallback(async (currentEmail: EmailData) => { -''', - ''' useEffect(() => { - handledActionCommandIdRef.current = null; - }, [emailId]); - - useEffect(() => { - if (!email?.schedule_conflict) { - setCalendarSources([]); - setCalendarSourceLoadStatus('idle'); - setSelectedCalendarSourceId(''); - return; - } - - let isActive = true; - setCalendarSources([]); - setCalendarSourceLoadStatus('loading'); - setSelectedCalendarSourceId(''); - setSyncStatus(null); - - void apiClient.get('/api/calendar/writeback-sources') - .then((sources) => { - if (!isActive) return; - setCalendarSources(sources.filter(isCustomerOwnedWritableSource)); - setCalendarSourceLoadStatus('ready'); - }) - .catch(() => { - if (!isActive) return; - setCalendarSources([]); - setCalendarSourceLoadStatus('error'); - }); - - return () => { - isActive = false; - }; - }, [email?.id, email?.schedule_conflict]); - - const fetchThread = useCallback(async (currentEmail: EmailData) => { -''', - "calendar source registry effect", - ) - - old_handler = r''' const handleSyncCalendar = useCallback(async () => { - const actionEmailId = emailId; - const isCurrentEmail = () => currentEmailIdRef.current === actionEmailId; - const actionItems = llmData?.action_items ?? []; - if (!actionItems.length) { - setSyncStatus({ type: 'error', message: '캘린더에 반영할 실행 항목이 없습니다.' }); - return; - } - setIsSyncing(true); - setSyncStatus(null); - const startedAt = nowMs(); - try { - const intents = await Promise.all( - actionItems.map((summary) => - apiClient.post('/api/calendar/writeback-intent', { - action: 'create', - summary, - }), - ), - ); - if (!isCurrentEmail()) return; - setSyncStatus({ type: 'success', message: `${intents.length}개 일정 반영 의도를 선택한 원본 계정에 요청했습니다.` }); - recordProductEvent("calendar_reflected", { - surface: "mail_detail", - calendar_candidate_id: `mail-calendar:${actionEmailId ?? "unknown"}`, - calendar_event_id: intents[0]?.target_source_id ?? null, - thread_id: email ? getThreadEventId(email) : null, - conflict_state: "none", - provider_write_executed: intents.some((intent) => Boolean(intent.provider_write_executed)), - }); - recordProductEvent("latency_guardrail_recorded", { - surface: "mail_detail", - request_trace_id: createProductEventId("calendar_trace"), - operation: "calendar_reflection", - duration_ms: Math.round(nowMs() - startedAt), - status: "success", - }); - } catch { - if (!isCurrentEmail()) return; - setSyncStatus({ type: 'error', message: '일정 반영 의도 요청에 실패했습니다.' }); - recordProductEvent("latency_guardrail_recorded", { - surface: "mail_detail", - request_trace_id: createProductEventId("calendar_trace"), - operation: "calendar_reflection", - duration_ms: Math.round(nowMs() - startedAt), - status: "error", - }); - } finally { - if (isCurrentEmail()) setIsSyncing(false); - } - }, [email, emailId, llmData]); -''' - new_handler = r''' const handleSyncCalendar = useCallback(async () => { - const actionEmailId = emailId; - const isCurrentEmail = () => currentEmailIdRef.current === actionEmailId; - const actionItems = llmData?.action_items ?? []; - if (!actionItems.length) { - setSyncStatus({ type: 'error', message: '캘린더에 반영할 실행 항목이 없습니다.' }); - return; - } - if (calendarSourceLoadStatus !== 'ready') { - setSyncStatus({ - type: 'error', - message: calendarSourceLoadStatus === 'error' - ? '일정 원본을 불러오지 못했습니다.' - : '일정 원본을 불러오는 중입니다.', - }); - return; - } - - const selectedSource = calendarSources.find( - (source) => source.source_id === selectedCalendarSourceId, - ); - if (!selectedSource || !isCustomerOwnedWritableSource(selectedSource)) { - setSyncStatus({ type: 'error', message: '일정을 반영할 원본 계정을 먼저 선택하세요.' }); - return; - } - - setIsSyncing(true); - setSyncStatus(null); - const startedAt = nowMs(); - try { - const settledIntents = await Promise.allSettled( - actionItems.map((summary) => - apiClient.post('/api/calendar/writeback-intent', { - action: 'create', - summary, - target_source_id: selectedSource.source_id, - }), - ), - ); - if (!isCurrentEmail()) return; - - const successfulIntents: CalendarWritebackIntentResponse[] = []; - let rejectedCount = 0; - let conflictCount = 0; - settledIntents.forEach((result) => { - if (result.status === 'rejected') { - rejectedCount += 1; - if (getApiErrorStatus(result.reason) === 409) conflictCount += 1; - return; - } - if (result.value.target_source_id !== selectedSource.source_id) { - conflictCount += 1; - return; - } - successfulIntents.push(result.value); - }); - - const failedCount = rejectedCount + conflictCount; - if (conflictCount > 0) setSelectedCalendarSourceId(''); - const sourceProvider = toMailDisplayText( - successfulIntents[0]?.provenance?.source_provider || selectedSource.provider, - '선택한 원본', - ); - - if (successfulIntents.length === 0) { - setSyncStatus({ - type: 'error', - message: conflictCount > 0 - ? '선택한 일정 원본이 변경되었습니다. 원본을 다시 선택하세요.' - : '일정 반영 의도 요청에 실패했습니다.', - }); - recordProductEvent("latency_guardrail_recorded", { - surface: "mail_detail", - request_trace_id: createProductEventId("calendar_trace"), - operation: "calendar_reflection", - duration_ms: Math.round(nowMs() - startedAt), - status: "error", - }); - return; - } - - setSyncStatus({ - type: failedCount > 0 ? 'error' : 'success', - message: failedCount > 0 - ? `${successfulIntents.length}개 성공, ${failedCount}개 실패했습니다. 원본을 다시 선택해 실패 항목을 재시도하세요.` - : `${successfulIntents.length}개 일정 반영 의도를 ${sourceProvider} 원본 계정에 요청했습니다.`, - }); - recordProductEvent("calendar_reflected", { - surface: "mail_detail", - calendar_candidate_id: `mail-calendar:${actionEmailId ?? "unknown"}`, - calendar_event_id: null, - thread_id: email ? getThreadEventId(email) : null, - conflict_state: conflictCount > 0 ? "conflict" : failedCount > 0 ? "warning" : "none", - provider_write_executed: successfulIntents.some( - (intent) => Boolean(intent.provider_write_executed), - ), - }); - recordProductEvent("latency_guardrail_recorded", { - surface: "mail_detail", - request_trace_id: createProductEventId("calendar_trace"), - operation: "calendar_reflection", - duration_ms: Math.round(nowMs() - startedAt), - status: failedCount > 0 ? "error" : "success", - }); - } catch { - if (!isCurrentEmail()) return; - setSyncStatus({ type: 'error', message: '일정 반영 의도 요청에 실패했습니다.' }); - recordProductEvent("latency_guardrail_recorded", { - surface: "mail_detail", - request_trace_id: createProductEventId("calendar_trace"), - operation: "calendar_reflection", - duration_ms: Math.round(nowMs() - startedAt), - status: "error", - }); - } finally { - if (isCurrentEmail()) setIsSyncing(false); - } - }, [ - calendarSourceLoadStatus, - calendarSources, - email, - emailId, - llmData, - selectedCalendarSourceId, - ]); -''' - replace_once(COMPONENT, old_handler, new_handler, "calendar writeback handler") - - replace_once( - COMPONENT, - '''
''', - '''
''', - "attachment region landmark", - ) - - old_panel = r''' {email.schedule_conflict && ( -
-
-
-

회의 제안 확인

-

이메일에 포함된 회의 일정을 캘린더와 조율합니다.

-
- -
-
- )}''' - new_panel = r''' {email.schedule_conflict && ( -
-
-
-

회의 제안 확인

-

- 서버가 승인한 원본 계정을 직접 선택한 뒤 일정 반영 의도를 요청합니다. -

-
-
- - -
- {calendarSourceLoadStatus === 'loading' && ( -

- 일정 원본을 불러오는 중입니다. -

- )} - {calendarSourceLoadStatus === 'error' && ( -

- 일정 원본을 불러오지 못했습니다. -

- )} - {calendarSourceLoadStatus === 'ready' && calendarSources.length === 0 && ( -

- 쓰기 권한이 있는 원본 계정이 없습니다. -

- )} -
-
- )}''' - replace_once(COMPONENT, old_panel, new_panel, "calendar coordination panel") - - -def main() -> None: - """Run the requested deterministic repair phase.""" - parser = argparse.ArgumentParser() - parser.add_argument("--tests", action="store_true") - parser.add_argument("--production", action="store_true") - args = parser.parse_args() - if args.tests == args.production: - raise SystemExit("choose exactly one of --tests or --production") - if args.tests: - apply_tests() - else: - apply_production() - - -if __name__ == "__main__": - main() From e086f752f8a5640c36ba1405524233c79ff9b4a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 11:09:42 +0900 Subject: [PATCH 31/61] test(email-detail): require explicit calendar source writeback --- .../EmailDetail.calendar-writeback.test.tsx | 333 ++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 frontend/src/components/EmailDetail.calendar-writeback.test.tsx diff --git a/frontend/src/components/EmailDetail.calendar-writeback.test.tsx b/frontend/src/components/EmailDetail.calendar-writeback.test.tsx new file mode 100644 index 000000000..23e280e50 --- /dev/null +++ b/frontend/src/components/EmailDetail.calendar-writeback.test.tsx @@ -0,0 +1,333 @@ +/* @vitest-environment jsdom */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/components/ui/separator", () => ({ Separator: () =>
})); +vi.mock("@/components/ui/avatar", () => ({ + Avatar: ({ children }: { children: React.ReactNode }) =>
{children}
, + AvatarFallback: ({ children }: { children: React.ReactNode }) => {children}, +})); +vi.mock("@/components/ui/scroll-area", () => ({ + ScrollArea: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); +vi.mock("@/components/ui/badge", () => ({ + Badge: ({ children }: { children: React.ReactNode }) => {children}, +})); +vi.mock("@/components/ui/checkbox", () => ({ + Checkbox: (props: React.InputHTMLAttributes) => , +})); +vi.mock("@/components/ui/button", () => ({ + Button: ({ children, ...props }: React.ButtonHTMLAttributes) => ( + + ), +})); +vi.mock("@/components/ui/textarea", () => ({ + Textarea: (props: React.TextareaHTMLAttributes) =>