Skip to content
Draft
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
df4f669
fix(home): expose retryable backend unavailable state
seonghobae Sep 4, 2026
be7b6d4
fix(home): preserve partial source evidence
seonghobae Sep 4, 2026
cc9c7ef
test(home): pin retry stale-response ordering
seonghobae Sep 4, 2026
32a1e0b
test(home): cover dashboard retry recovery
seonghobae Sep 4, 2026
6e02f83
test(home): pin dashboard recovery succession contracts
seonghobae Sep 5, 2026
387787d
fix(home): settle dashboard sources independently
seonghobae Sep 5, 2026
a37b9c5
fix(home): preserve startup-view reset after recovery refactor
seonghobae Sep 5, 2026
1c08090
fix(home): unify source authentication recovery
seonghobae Sep 5, 2026
a48e8ba
test(home): preserve malformed dashboard fail-closed contract
seonghobae Sep 5, 2026
b88161f
fix: cancel obsolete dashboard read requests
seonghobae Sep 6, 2026
c298e4d
fix: validate mail and dashboard response members
seonghobae Sep 6, 2026
91a91c2
chore(governance): restore canonical AGENTS ownership
seonghobae Sep 6, 2026
d510f5a
test(home): reproduce missing AbortSignal combinators
seonghobae Sep 6, 2026
2e937e4
fix(home): fall back when AbortSignal combinators are unavailable
seonghobae Sep 6, 2026
82c0913
docs(home): record AbortSignal compatibility repair
seonghobae Sep 6, 2026
a6e6ac5
docs(home): anchor abort fallback to DOM standard
seonghobae Sep 6, 2026
187f332
docs(ci): record dashboard gate recovery evidence
seonghobae Sep 8, 2026
aeeda1e
fix(home): restore canonical governance ownership
seonghobae Sep 9, 2026
9ff6a2a
fix(home): adopt canonical frontend security prerequisite
seonghobae Sep 9, 2026
e03f67d
test(home): reject malformed project-folder evidence
seonghobae Sep 9, 2026
b211a45
fix(home): validate project-folder response members
seonghobae Sep 9, 2026
66ba284
test(home): cover project-folder contract branches
seonghobae Sep 9, 2026
8c4c53f
chore(stack): adopt security repair and restore release-note ownership
seonghobae Sep 9, 2026
eadb882
chore(stack): adopt current frontend security owner
seonghobae Sep 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,10 @@ in this repo.
## PR automation and review defaults

- Follow `docs/development/merge-gate-policy.md` for PR gate interpretation.
- Do not assume a Draft-to-ready toggle reruns required checks. Verify that the
repository workflows subscribe to `ready_for_review` and confirm fresh run ids;
when they do not, add a genuine evidence or repair delta instead of an empty
commit, then validate the unchanged product behavior on the new exact head.
- PR Governance must stay metadata-only: no PR-head checkout, no admin merge, no
review dismissal, and no security-check suppression.
- Pending/queued checks, pending CodeRabbit evidence, and a missing structured
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
## [Unreleased]
- 홈에서 다시 조회하거나 다른 화면으로 이동하면 더 이상 필요하지 않은 이전 조회를 취소합니다. 새 조회와 기존 응답 대기 제한은 유지합니다.
- 홈 업무 API가 실패하면 0건으로 오인하지 않고 메일·답변 대기·작업별 오류와 재시도 동작을 표시합니다. 이전 요청의 늦은 응답은 새 결과를 덮지 못하며, 현재 viewport의 홈만 mount해 중복 API 요청을 막습니다.
- 긴 이메일·첨부 본문을 의미 단위 청크로 임베딩한 뒤 기존 email/attachment 벡터 계약으로 평균화하고, 청크 요청·벡터 누적을 제한된 창으로 처리합니다. OpenAI `text-embedding-3-*`에는 저장 차원(`1536`)을 직접 요청하도록 보강했습니다. 합성 메일 fixture 5건(70청크)과 provider 요청 계약으로 1,536차원 벡터 경로를 검증했으며, 실행 시 선택한 임베딩 제공자에 본문·파싱된 첨부 텍스트를 전송할 수 있습니다. 회사 기밀 데이터는 fixture·commit·PR·log에 포함하지 않습니다.
- EmailDetail 테스트가 지원하지 않는 스레드 병합/분리 버튼을 `textContent`뿐 아니라 `aria-label`과 `title` 접근 가능 이름으로도 검출하도록 바꿔, 아이콘 전용 버튼 회귀를 놓치지 않습니다.

Expand Down
143 changes: 143 additions & 0 deletions docs/doctoring/dashboard_request_cancellation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# Dashboard request cancellation

## Evidence and decision

PR #1570 at `a48e8ba85b6d3a4ceb78e110582b3d5bc76d0d7c` protected state
updates with a cancelled flag and request generation, but its five dashboard
GET requests shared only a 15-second timeout signal. Retrying or leaving the
dashboard made responses irrelevant without aborting those fetches. The same
file's startup-search effect already aborts its controller during cleanup.

Reuse that native pattern for the dashboard generation. Combine its controller
with the existing timeout signal, then mark the generation cancelled before
aborting in cleanup. This preserves the five API routes, signed-cookie transport,
independent source states and request-version guard. It does not change model
timeouts or claim that abort rolls back work already accepted by a server. These
are read requests, not provider writes.

When both `AbortSignal.any` and `AbortSignal.timeout` exist, use the native
combinators. If either static method is unavailable, compose the same boundary
with a generation-local `AbortController`, the request-controller abort event,
and the unchanged 15,000 ms timer. Abort clears the fallback timer and listener;
timeout uses a `TimeoutError` DOMException. This avoids inventing a repository-
wide browser floor solely for this feature while keeping modern browsers on the
native path.

Do not replace the timeout, increase its duration, add a client wrapper, or
depend on ignoring stale responses alone. A retry gets a fresh controller;
cleanup must never abort that newer generation.

## Verification and limits

The existing retry-race harness now inspects all five old signals and all five
new signals, still delivers a late old response, and checks final unmount.
A StrictMode case checks discarded-mount cancellation, active-mount survival,
absence of a spurious error alert, and final cleanup. Both tests failed before
the source repair. Afterward, those cases plus dashboard, succession (including
the existing 15,000 ms timeout test), and API-client contracts passed: 30 tests.
Mock signal assertions prove cancellation delivery, not server-side cleanup or
measured bandwidth savings. Hosted checks and protected merge remain separate.

Focused lint with zero allowed warnings and TypeScript checking passed.
Native signal smoke checks passed in installed Chromium 151.0.7922.34,
Firefox 153.0 and WebKit 26.5 for both manual abort and timeout reason.
The full suite passed 53 files / 448 tests, but retained three CalendarLayout
and four EmailDetail act diagnostics outside this repair. Their existing owner
repairs must be inherited and revalidated; this is not warning-free full-suite
evidence or permission to suppress those diagnostics.

Review `3944626310` later identified that direct use of the two static
combinators made dashboard startup depend on browser support that the repository
had never declared. MDN marks both static methods as Baseline 2024 but explicitly
notes that older devices or browsers may not support them. The WHATWG DOM Living
Standard defines `AbortSignal.any()` as propagating the first source abort reason
and `AbortSignal.timeout()` as aborting with a `TimeoutError` DOMException. The
fallback therefore preserves the relevant native contract instead of silently
changing timeout or cancellation semantics.

Source-order RED commit `d510f5a99cf69f50db3bacfb3105260e78981345`
adds a focused regression that removes those static methods and requires five
dashboard request signals to remain cancellable and to abort at the existing
15-second deadline. The predecessor implementation calls `AbortSignal.any`
unconditionally, so that scenario cannot reach the dashboard requests. Causal
fix `2e937e49ccabda6e848a824d1bc7cee4ceb3ae2c` adds the guarded composition
above. These commits establish test-first source provenance; they are not
by themselves a claim that hosted CI has executed the final exact head.

## Follow-up: malformed array members

Review [3944460600](https://github.com/ContextualWisdomLab/naruon/pull/1570#discussion_r3944460600)
described a gap still present at `b88161f0c9515b39b101fe3fa8444be3b1895022`.
The envelope checks accepted arrays containing null or invalid records. Render
filters then dereferenced those values outside the promise rejection handler,
so an unavailable source could crash the dashboard instead of offering retry.

Validate the consumed email, task and calendar shapes before their state
setters; both email endpoints share one predicate. Use native type checks and
reject the whole invalid source rather than silently filtering members, which
would misreport counts and task completion rates. Preserve valid empty arrays,
independent sibling responses, cancellation and signed-cookie transport.
No dependency or general-purpose schema framework is needed for these three
local shapes. Project folders currently consume only length and are unchanged.

The first 12 null/empty-record/number cases failed before the repair. Additional
cases cover numeric email subject, object date, array task status (no string
coercion), and string calendar capabilities. Existing successful payloads and
retry/StrictMode tests remain regression evidence. Backend contracts checked:
`EmailListItem` in `backend/api/emails.py`, `TicketTaskResponse` in
`backend/api/tasks.py`, and `WritebackSource` in `backend/api/calendar.py`.
The guards cover consumed fields, not backend semantic correctness or
schema-wide conformance.

The first production-browser run disproved the initial local-only repair:
the three HTTP 503 recovery cases passed, but all three HTTP 200/null-member
cases failed. The trace recorded `Cannot read properties of null (reading 'id')`
and the page error boundary replaced the dashboard. `EmailList` was mounted
alongside the dashboard and accepted `data.emails || []` before mapping
`email.id`; dashboard unit tests mocked that component and missed the path.
Keep that failed trace and result; do not omit the malformed-response case.

Three additional EmailList inbox/sent/search regressions failed before the
sibling repair. Move the mail predicate to `frontend/src/lib/mail-response.ts`
and apply it to both dashboard mail sources and every located `/api/search`
consumer (EmailList, WorkspaceHome startup search, MobileApiPanel, SearchLayout).
This is a now-demonstrated shared responsibility, not a speculative wrapper.
Keep task/calendar predicates local. EmailList reports a fixed user-facing
message on validation failure rather than displaying payload values or internal
validator details. Optional nullable reply counts remain accepted because the
backend emits them. Original cancellation and envelope failures stay covered.

Scoped visual follow-up: the initial successful 503 desktop capture still
shows `source-linked` under pending tasks and `충돌 토큰 있음` under calendar
sources. These are unresolved user-facing implementation-detail leaks, not
acceptance of the complete product copy. Record them with the product Gap;
do not expand this availability repair into a translation framework or redesign.

## Exact-head gate recovery on 2026-09-08

The source head `a6e6ac59a72173793a834627b8489bea79805f16` retained successful
application, image, Semgrep, Trivy, and repository CodeQL evidence. Its central
compatibility CodeQL shards remained red because the first attempt intentionally
returned `pending` after dispatch and no authenticated terminal callback reached
the consumer run. OpenCode likewise had no current-head verdict. Strix reported
`STRIX_SANDBOX_UNAVAILABLE` after two Caido `loginAsGuest` bootstrap failures;
the displayed zero count preceded the transport failure and is not a clean scan.

Central `.github` PR #2028 later repaired missing dispatch-verdict recovery, but
that merge does not rewrite old consumer evidence. Converting this PR to Draft
and back to ready produced no new runs because the current callers do not
subscribe to `ready_for_review`. Therefore, never cite the UI state transition as
revalidation. This documentation delta records the causal logs and creates a
normal synchronize event without an empty commit. Acceptance still requires new
run ids on this exact successor head, terminal security analysis, and an
independent current-head review; no stale success is inherited.

## References

MDN contributors. (2026, September 1). *AbortSignal: any() static method*.
MDN Web Docs. https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/any_static

MDN contributors. (2026, September 1). *AbortSignal: timeout() static method*.
MDN Web Docs. https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static

WHATWG. (2026, August 25). *DOM Living Standard*. https://dom.spec.whatwg.org/
11 changes: 6 additions & 5 deletions frontend/src/app/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ describe("Home workspace action bridge", () => {
expect(fetch).toHaveBeenCalledWith("/api/search", expect.objectContaining({ method: "POST" }));
});

it("shows startup dashboard empty states and ignores malformed API payloads", async () => {
it("shows an unavailable dashboard state for malformed API payloads", async () => {
localStorage.setItem("naruon_startup_view", "dashboard");
vi.stubGlobal("fetch", vi.fn(() => Promise.resolve({
ok: true,
Expand All @@ -486,11 +486,12 @@ describe("Home workspace action bridge", () => {
await act(async () => {
root?.render(<Home />);
});
await waitForCondition(() => container?.textContent?.includes("수신된 메일이 없습니다.") ?? false);
await waitForCondition(() => Boolean(container?.querySelector('[role="alert"][aria-label="대시보드 데이터 상태"]')));

expect(container.textContent).toContain("수신된 메일이 없습니다.");
expect(container.textContent).toContain("답변 대기 중인 보낸 메일이 없습니다.");
expect(container.textContent).toContain("대기 작업이 없습니다.");
expect(container.textContent).toContain("업무 현황을 모두 불러오지 못했습니다.");
expect(container.textContent).not.toContain("수신된 메일이 없습니다.");
expect(container.textContent).not.toContain("답변 대기 중인 보낸 메일이 없습니다.");
expect(container.textContent).not.toContain("대기 작업이 없습니다.");
});

it("shows desktop calendar empty and error states from the search API", async () => {
Expand Down
23 changes: 23 additions & 0 deletions frontend/src/components/EmailList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,29 @@ describe("EmailList", () => {
vi.unstubAllGlobals();
});

it.each((['inbox', 'sent', 'search'] as const).flatMap((source) => [false, true].map((nullEnvelope) => ({ source, nullEnvelope }))))('rejects malformed $source data (null envelope: $nullEnvelope)', async ({ source, nullEnvelope }) => {
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => Promise.resolve(jsonResponse(
String(input).endsWith('/api/search') ? (nullEnvelope ? null : { results: [null] })
: source === 'search' ? { emails: [] } : nullEnvelope ? null : { emails: [null] },
))));
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(<EmailList onSelectEmail={vi.fn()} folder={source === 'sent' ? 'sent' : 'inbox'} />);
});
await flushAsyncWork();
if (source === 'search') {
const input = container.querySelector<HTMLInputElement>('#email-search')!;
await act(async () => setInputValue(input, 'example'));
await act(async () => input.closest('form')?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })));
await flushAsyncWork();
}
expect(container.querySelector('[role="alert"]')?.textContent).toContain('메일 목록을 확인하지 못했습니다.');
expect(container.querySelector('[role="alert"]')?.textContent).not.toMatch(/TypeError|Cannot read|emails|results/);
expect(container.textContent).not.toContain('메일이 없습니다');
});

it("renders the branded dense inbox and selected thread state", async () => {
const fetchMock = vi.fn(() =>
Promise.resolve(
Expand Down
17 changes: 13 additions & 4 deletions frontend/src/components/EmailList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { CheckCircle2, Loader2, Mail, MessagesSquare, Network, Search, Sparkles, X } from "lucide-react";
import { formatEmailDate } from "@/lib/email-threading";
import { toMailDisplayText } from "@/lib/mail-text";
import { isMailListItem } from '@/lib/mail-response';

interface EmailItem {
id: number;
Expand Down Expand Up @@ -37,7 +38,12 @@ async function fetchFolderEmails(folder: MailFolder) {
if (folderRequests.has(folder)) return folderRequests.get(folder)!;

const request = apiClient.get<{ emails: EmailItem[] }>(folderEndpoint(folder))
.then((data) => data.emails || [])
.then((data) => {
if (!Array.isArray(data.emails) || !data.emails.every(isMailListItem)) {
throw new Error('메일 목록을 확인하지 못했습니다. 다시 조회하세요.');
}
return data.emails;
})
.finally(() => {
folderRequests.delete(folder);
});
Expand Down Expand Up @@ -135,10 +141,13 @@ export function EmailList({
} else {
setIsSearching(true);
const data = await apiClient.post<{ results: EmailItem[] }>('/api/search', { query });
setEmails(data.results || []);
if (!Array.isArray(data.results) || !data.results.every(isMailListItem)) {
throw new Error('메일 목록을 확인하지 못했습니다. 다시 조회하세요.');
}
setEmails(data.results);
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to load emails");
} catch {
setError('메일 목록을 확인하지 못했습니다. 다시 조회하세요.');
} finally {
setLoading(false);
setIsSearching(false);
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/SearchLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import dynamic from "next/dynamic";
import Link from "next/link";

import { apiClient } from "@/lib/api-client";
import { isMailListItem } from '@/lib/mail-response';
import {
bucketSearchRank,
bucketTextLength,
Expand Down Expand Up @@ -391,6 +392,7 @@ export function SearchLayout() {
)
.then((response) => {
if (controller.signal.aborted) return;
if (!Array.isArray(response.results) || !response.results.every(isMailListItem)) throw new Error('Invalid search response');
setResults(response.results);
setActiveResultId(response.results[0]?.id ?? null);
recordProductEvent("latency_guardrail_recorded", {
Expand Down
Loading
Loading