From 33248484fa695253786f01331bb9494121b27b84 Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Tue, 4 Aug 2026 14:18:54 +0000
Subject: [PATCH 01/15] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20OIDC=20?=
=?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=B0=8F=20=EB=A1=9C=EA=B7=B8?=
=?UTF-8?q?=EC=95=84=EC=9B=83=20=EB=B2=84=ED=8A=BC=EC=9D=98=20=ED=82=A4?=
=?UTF-8?q?=EB=B3=B4=EB=93=9C=20=EC=A0=91=EA=B7=BC=EC=84=B1=20=EA=B0=9C?=
=?UTF-8?q?=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- `SettingsLayout.tsx`의 OIDC 로그인/로그아웃 버튼에 `focus-visible` 스타일(`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40`) 추가
- 키보드 네비게이션 시 포커스 상태를 명확히 인지할 수 있도록 개선
---
.Jules/palette.md | 3 +++
frontend/src/components/SettingsLayout.tsx | 4 ++--
2 files changed, 5 insertions(+), 2 deletions(-)
create mode 100644 .Jules/palette.md
diff --git a/.Jules/palette.md b/.Jules/palette.md
new file mode 100644
index 000000000..0d13213af
--- /dev/null
+++ b/.Jules/palette.md
@@ -0,0 +1,3 @@
+## 2024-08-04 - SettingsLayout OIDC Login/Logout button focus state
+**Learning:** The OIDC login and logout buttons in the SettingsLayout lacked proper `focus-visible` styles, which hindered keyboard navigation accessibility.
+**Action:** Added `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40` classes to interactive elements like buttons to ensure clear visual feedback for keyboard users.
diff --git a/frontend/src/components/SettingsLayout.tsx b/frontend/src/components/SettingsLayout.tsx
index d5f11c1b6..0e5696365 100644
--- a/frontend/src/components/SettingsLayout.tsx
+++ b/frontend/src/components/SettingsLayout.tsx
@@ -1619,7 +1619,7 @@ export function SettingsLayout() {
onClick={handleOidcLogin}
disabled={!oidcBrowserConfig}
title={!oidcBrowserConfig ? "OIDC 브라우저 설정이 없습니다" : "OIDC 로그인"}
- className="rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
+ className="rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
>
OIDC 로그인
@@ -1628,7 +1628,7 @@ export function SettingsLayout() {
onClick={handleOidcLogout}
disabled={!oidcSessionClaims.userId}
title={!oidcSessionClaims.userId ? "로그인된 세션이 없습니다" : "로그아웃"}
- className="rounded-lg border border-border px-4 py-2 text-sm font-bold text-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
+ className="rounded-lg border border-border px-4 py-2 text-sm font-bold text-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
>
로그아웃
From 48e0d86f80f55e803389ab5b1772e5a3880a39c3 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 5 Aug 2026 15:05:24 +0900
Subject: [PATCH 02/15] chore(a11y): remove bot journal artifact
---
.Jules/palette.md | 3 ---
1 file changed, 3 deletions(-)
delete mode 100644 .Jules/palette.md
diff --git a/.Jules/palette.md b/.Jules/palette.md
deleted file mode 100644
index 0d13213af..000000000
--- a/.Jules/palette.md
+++ /dev/null
@@ -1,3 +0,0 @@
-## 2024-08-04 - SettingsLayout OIDC Login/Logout button focus state
-**Learning:** The OIDC login and logout buttons in the SettingsLayout lacked proper `focus-visible` styles, which hindered keyboard navigation accessibility.
-**Action:** Added `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40` classes to interactive elements like buttons to ensure clear visual feedback for keyboard users.
From 04976dcc1b056d4d7e4027cf8731a201cda27a3f Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 5 Aug 2026 15:05:39 +0900
Subject: [PATCH 03/15] test(a11y): lock OIDC focus indicators
---
.../SettingsLayout.oidc-focus.test.ts | 28 +++++++++++++++++++
1 file changed, 28 insertions(+)
create mode 100644 frontend/src/components/SettingsLayout.oidc-focus.test.ts
diff --git a/frontend/src/components/SettingsLayout.oidc-focus.test.ts b/frontend/src/components/SettingsLayout.oidc-focus.test.ts
new file mode 100644
index 000000000..b1b4cb9b7
--- /dev/null
+++ b/frontend/src/components/SettingsLayout.oidc-focus.test.ts
@@ -0,0 +1,28 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+
+const settingsLayoutSource = readFileSync(
+ new URL("./SettingsLayout.tsx", import.meta.url),
+ "utf8",
+);
+
+function buttonSource(label: string): string {
+ const labelIndex = settingsLayoutSource.indexOf(`>${label}`);
+ expect(labelIndex).toBeGreaterThan(-1);
+ const openingButtonIndex = settingsLayoutSource.lastIndexOf("`);
+ const labelIndex = settingsLayoutSource.indexOf(label);
expect(labelIndex).toBeGreaterThan(-1);
const openingButtonIndex = settingsLayoutSource.lastIndexOf("
-
- 첫 관계 보기
-
+ {!firstEdge && (
+
+ 표시할 관계 데이터가 없습니다.
+
+ )}
+
+ 첫 관계 보기
+
+
;
+ edges?: Array;
+}
+
+function textOnlyTooltip(value: unknown): HTMLElement {
+ const tooltip = document.createElement('div');
+ tooltip.textContent = value == null ? '' : String(value);
+ return tooltip;
+}
+
+const HTML_TEXT_ESCAPE_PATTERN = /[&<>"']/g;
+const HTML_TEXT_ESCAPES: Record = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+};
+
+function escapeGraphLabel(value: unknown): string {
+ return String(value ?? '').replace(
+ HTML_TEXT_ESCAPE_PATTERN,
+ (character) => HTML_TEXT_ESCAPES[character] ?? character,
+ );
+}
+
+function sanitizeGraphItem(item: T): T {
+ const sanitized = { ...item };
+
+ if (Object.prototype.hasOwnProperty.call(item, 'title')) {
+ sanitized.title = textOnlyTooltip(item.title);
+ }
+
+ return sanitized;
+}
+
+function escapeVisNetworkLabels(items: T[]): T[] {
+ return items.map((item) => {
+ if (!Object.prototype.hasOwnProperty.call(item, 'label')) return item;
+ return {
+ ...item,
+ label: escapeGraphLabel(item.label),
+ };
+ });
+}
+
+function isGraphId(value: unknown): value is number | string {
+ return typeof value === 'number' || typeof value === 'string';
+}
+
+function graphIdEquals(left: unknown, right: unknown) {
+ return isGraphId(left) && isGraphId(right) && String(left) === String(right);
+}
+
+function stableEdgeId(edge: Edge, index: number) {
+ if (isGraphId(edge.id)) return edge.id;
+ return `relationship-${index}-${String(edge.from)}-${String(edge.to)}`;
+}
+
+function normalizeEdge(edge: ApiEdge): Edge | null {
+ const from = edge.from ?? edge.source;
+ const to = edge.to ?? edge.target;
+
+ if (!isGraphId(from) || !isGraphId(to)) return null;
+
+ const rest = { ...edge };
+ delete rest.source;
+ delete rest.target;
+ return {
+ ...rest,
+ from,
+ to,
+ };
+}
+
+function sanitizeNetworkData(data: NetworkData): NormalizedNetworkData {
+ return {
+ nodes: data.nodes.map(sanitizeGraphItem),
+ edges: data.edges.flatMap((edge, index) => {
+ const normalized = normalizeEdge(edge);
+ return normalized ? [sanitizeGraphItem({ ...normalized, id: stableEdgeId(normalized, index) })] : [];
+ }),
+ };
+}
+
+function titleText(value: unknown) {
+ if (typeof HTMLElement !== 'undefined' && value instanceof HTMLElement) {
+ return value.textContent?.trim() ?? '';
+ }
+ return value == null ? '' : String(value).trim();
+}
+
+function findNodeLabel(nodes: Node[], id: number | string) {
+ const node = nodes.find((candidate) => graphIdEquals(candidate.id, id));
+ return String(node?.label ?? id);
+}
+
+function describeEdge(edge: Edge, nodes: Node[], nodeMap?: Map) {
+ let fromLabel, toLabel;
+ if (nodeMap) {
+ fromLabel = nodeMap.get(String(edge.from)) ?? String(edge.from);
+ toLabel = nodeMap.get(String(edge.to)) ?? String(edge.to);
+ } else {
+ fromLabel = findNodeLabel(nodes, edge.from);
+ toLabel = findNodeLabel(nodes, edge.to);
+ }
+ const title = titleText(edge.title);
+ return title ? `${fromLabel} -> ${toLabel} (${title})` : `${fromLabel} -> ${toLabel}`;
+}
+
+import { apiClient } from '@/lib/api-client';
+
+export default function NetworkGraph() {
+ const containerRef = useRef(null);
+ const networkRef = useRef(null);
+
+ const [nodes, setNodes] = useState([]);
+ const [edges, setEdges] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [selectedGraphDetail, setSelectedGraphDetail] = useState(null);
+ const [graphActionStatus, setGraphActionStatus] = useState('그래프 준비 완료');
+ const [relationshipOptionId, setRelationshipOptionId] = useState('');
+ const [nodeOptionId, setNodeOptionId] = useState('');
+ const nodeMap = useMemo(() => {
+ const map = new Map();
+ for (const node of nodes) {
+ const key = String(node.id);
+ if (!map.has(key)) {
+ map.set(key, String(node.label ?? node.id));
+ }
+ }
+ return map;
+ }, [nodes]);
+
+ useEffect(() => {
+ apiClient.get('/api/network/graph')
+ .then((data) => {
+ const sanitized = sanitizeNetworkData(data);
+ setNodes(sanitized.nodes);
+ setEdges(sanitized.edges);
+ setLoading(false);
+ })
+ .catch((err) => {
+ console.error('Failed to load network graph:', err);
+ setError('관계 맥락을 불러오지 못했습니다.');
+ setLoading(false);
+ });
+ }, []);
+
+ useEffect(() => {
+ if (containerRef.current && nodes.length > 0) {
+ const container = containerRef.current;
+ const network = new Network(container, {
+ nodes: escapeVisNetworkLabels(nodes),
+ edges: escapeVisNetworkLabels(edges),
+ }, {
+ nodes: { shape: 'dot', size: 16 },
+ edges: { arrows: 'to' }
+ });
+ networkRef.current = network;
+
+ const fitGraph = () => {
+ network.fit?.({ animation: false });
+ };
+
+ const selectEdge = (edgeId: number | string) => {
+ const edge = edges.find((candidate) => graphIdEquals(candidate.id, edgeId));
+ if (!edge) return;
+ setRelationshipOptionId(String(edge.id));
+ setNodeOptionId('');
+ setSelectedGraphDetail(`선택된 관계: ${describeEdge(edge, nodes, nodeMap)}`);
+ setGraphActionStatus('그래프에서 관계를 선택했습니다.');
+ };
+
+ const selectNode = (nodeId: number | string) => {
+ setRelationshipOptionId('');
+ setNodeOptionId(String(nodeId));
+ setSelectedGraphDetail(`선택된 노드: ${nodeMap.get(String(nodeId)) ?? String(nodeId)}`);
+ setGraphActionStatus('그래프에서 노드를 선택했습니다.');
+ };
+
+ const handleEdgeSelection = (event: GraphSelectionEvent) => {
+ const edgeId = event.edges?.[0];
+ if (isGraphId(edgeId)) selectEdge(edgeId);
+ };
+
+ const handleNodeSelection = (event: GraphSelectionEvent) => {
+ const nodeId = event.nodes?.[0];
+ if (isGraphId(nodeId)) selectNode(nodeId);
+ };
+
+ const canListenForSelection =
+ typeof network.on === 'function' && typeof network.off === 'function';
+
+ if (canListenForSelection) {
+ network.on('selectEdge', handleEdgeSelection);
+ network.on('selectNode', handleNodeSelection);
+ }
+
+ let resizeTimer: ReturnType | null = null;
+ const resizeObserver = typeof ResizeObserver === 'undefined'
+ ? null
+ : new ResizeObserver(() => {
+ if (resizeTimer !== null) {
+ clearTimeout(resizeTimer);
+ }
+ resizeTimer = setTimeout(fitGraph, 50);
+ });
+
+ resizeObserver?.observe(container);
+
+ return () => {
+ if (resizeTimer !== null) {
+ clearTimeout(resizeTimer);
+ }
+ resizeObserver?.disconnect();
+ if (canListenForSelection) {
+ network.off('selectEdge', handleEdgeSelection);
+ network.off('selectNode', handleNodeSelection);
+ }
+ if (networkRef.current === network) {
+ networkRef.current = null;
+ }
+ network.destroy();
+ };
+ }
+ }, [nodes, edges, nodeMap]);
+
+ const nodeLabels = useMemo(() => {
+ return nodes
+ .map((node) => String(node.label ?? node.id))
+ .filter(Boolean)
+ .slice(0, 5);
+ }, [nodes]);
+
+ const firstEdge = edges[0] ?? null;
+ const relationshipOptions = useMemo(() => {
+ return edges.slice(0, 5).map((edge, index) => ({
+ edge,
+ id: String(edge.id),
+ label: `관계 ${index + 1}: ${describeEdge(edge, nodes, nodeMap)}`,
+ }));
+ }, [edges, nodes, nodeMap]);
+
+ const nodeOptions = useMemo(() => {
+ return nodes.slice(0, 8).map((node) => ({
+ id: String(node.id),
+ label: `노드: ${String(node.label ?? node.id)}`,
+ node,
+ }));
+ }, [nodes]);
+
+ const selectRelationship = (edge: Edge, status: string) => {
+ setRelationshipOptionId(String(edge.id));
+ setNodeOptionId('');
+ setSelectedGraphDetail(`선택된 관계: ${describeEdge(edge, nodes, nodeMap)}`);
+ setGraphActionStatus(status);
+ if (isGraphId(edge.id)) {
+ networkRef.current?.selectEdges?.([edge.id]);
+ }
+ networkRef.current?.fit?.({ nodes: [edge.from, edge.to], animation: false });
+ };
+
+ const selectGraphNode = (node: Node, status: string) => {
+ if (!isGraphId(node.id)) return;
+ setRelationshipOptionId('');
+ setNodeOptionId(String(node.id));
+ setSelectedGraphDetail(`선택된 노드: ${String(node.label ?? node.id)}`);
+ setGraphActionStatus(status);
+ networkRef.current?.selectNodes?.([node.id]);
+ networkRef.current?.fit?.({ nodes: [node.id], animation: false });
+ };
+
+ const handleSelectFirstRelationship = () => {
+ if (!firstEdge) return;
+ selectRelationship(firstEdge, '첫 관계를 선택했습니다.');
+ };
+
+ const handleRelationshipOptionChange = (value: string) => {
+ const edge = edges.find((candidate) => String(candidate.id) === value);
+ if (!edge) return;
+ selectRelationship(edge, '선택한 관계를 열었습니다.');
+ };
+
+ const handleNodeOptionChange = (value: string) => {
+ const node = nodes.find((candidate) => String(candidate.id) === value);
+ if (!node) return;
+ selectGraphNode(node, '선택한 노드를 열었습니다.');
+ };
+
+ const handleZoomGraph = () => {
+ networkRef.current?.moveTo?.({ scale: 1.15, animation: false });
+ setGraphActionStatus('그래프 확대 완료');
+ };
+
+ const handleFitGraph = () => {
+ networkRef.current?.fit?.({ animation: false });
+ setGraphActionStatus('그래프 맞춤 완료');
+ };
+
+ if (loading) {
+ return 관계 맥락을 불러오는 중입니다...
;
+ }
+
+ if (error) {
+ return (
+
+
+
관계 맥락을 불러오지 못했습니다
+
{error}
+
+
+ );
+ }
+
+ if (nodes.length === 0) {
+ return (
+
+
+
✦
+
관계 데이터가 없습니다
+
메일이 연결되면 사람, 주제, 일정의 흐름을 관계 맥락으로 보여줍니다.
+
+
+ );
+ }
+
+ return (
+
+
+
관계 이해
+
+ {nodes.length}개 노드와 {edges.length}개 관계가 이 스레드 맥락에 연결되어 있습니다.
+
+
+
텍스트 관계 맥락 종합
+
+ 관련 노드: {nodeLabels.join(', ')}
+
+
+
+
+ 첫 관계 보기
+
+
+ 그래프 확대
+
+
+ 전체 그래프 맞춤
+
+
+
+
+
+
+
+
관계 상세
+
+ {selectedGraphDetail ?? '관계를 선택하면 담당자와 일정 흐름을 확인합니다.'}
+
+
{graphActionStatus}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/TasksLayout.focus-visible.test.ts b/frontend/src/components/TasksLayout.focus-visible.test.ts
new file mode 100644
index 000000000..d33d33f95
--- /dev/null
+++ b/frontend/src/components/TasksLayout.focus-visible.test.ts
@@ -0,0 +1,38 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+
+const tasksLayoutSource = readFileSync(
+ new URL("./TasksLayout.tsx", import.meta.url),
+ "utf8",
+);
+
+function kanbanTaskButtonOpeningTag(): string {
+ const mapAnchor = "tasksByStatus[col.id].map((task)";
+ const mapIndex = tasksLayoutSource.indexOf(mapAnchor);
+ expect(mapIndex).toBeGreaterThan(-1);
+
+ const buttonIndex = tasksLayoutSource.indexOf("", classNameEnd);
+
+ expect(buttonIndex).toBeGreaterThan(mapIndex);
+ expect(classNameIndex).toBeGreaterThan(buttonIndex);
+ expect(classNameEnd).toBeGreaterThan(classNameIndex);
+ expect(openingTagEnd).toBeGreaterThan(classNameEnd);
+ return tasksLayoutSource.slice(buttonIndex, openingTagEnd);
+}
+
+describe("TasksLayout Kanban keyboard-focus contract", () => {
+ it("keeps a keyboard-only visible focus indicator on each task card", () => {
+ const openingTag = kanbanTaskButtonOpeningTag();
+
+ expect(openingTag).toContain("focus-visible:outline-none");
+ expect(openingTag).toContain("focus-visible:ring-2");
+ expect(openingTag).toContain("focus-visible:ring-ring/40");
+ });
+});
diff --git a/frontend/src/components/TasksLayout.tsx b/frontend/src/components/TasksLayout.tsx
index 034aa6911..98df4788a 100644
--- a/frontend/src/components/TasksLayout.tsx
+++ b/frontend/src/components/TasksLayout.tsx
@@ -328,7 +328,7 @@ export function TasksLayout() {
key={task.id}
type="button"
onClick={() => { setSelectedTaskId(task.id); setViewMode('작업 상세'); }}
- className="w-full rounded-lg border border-border bg-background p-3 text-left shadow-sm transition-all hover:border-primary/50 hover:shadow-md"
+ className="w-full rounded-lg border border-border bg-background p-3 text-left shadow-sm transition-all hover:border-primary/50 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
>
{getTaskSourceLabel(task.source_type)}
@@ -367,7 +367,28 @@ export function TasksLayout() {
), [currentColumns, tasksByStatus, taskSearch, priorityFilter, setSelectedTaskId, setViewMode]);
+
+ // ⚡ Bolt: Wrap My Tasks list in useMemo to prevent O(N) re-renders
+ // 🎯 Why: Mapping over potentially large lists of filtered tasks blocks the main thread during unrelated state updates.
+ const myTasksList = useMemo(() => {
+ if (viewMode !== '내 작업') return null;
+ return filteredTicketTasks.length > 0 ? filteredTicketTasks.map(task => (
+ { setSelectedTaskId(task.id); setViewMode('작업 상세'); }}>
+
+
+
+
{safeTaskTitle(task.title)}
+
근거: {getTaskEvidenceLabel(task)} | 원본: {getTaskSourceLabel(task.source_type)}
+
+
+ {taskStatusLabels[task.status]}
+
+ )) : (
+ 서명 세션에 연결된 내 작업이 없습니다.
+ );
+ }, [filteredTicketTasks, setSelectedTaskId, setViewMode, viewMode]);
const handleViewModeKeyDown = (event: KeyboardEvent, mode: TaskViewMode) => {
+
const currentIndex = TASK_VIEW_MODES.indexOf(mode);
const lastIndex = TASK_VIEW_MODES.length - 1;
let nextIndex: number;
@@ -684,20 +705,7 @@ export function TasksLayout() {
{viewMode === '내 작업' && (
내 작업
- {filteredTicketTasks.length > 0 ? filteredTicketTasks.map(task => (
-
{ setSelectedTaskId(task.id); setViewMode('작업 상세'); }}>
-
-
-
-
{safeTaskTitle(task.title)}
-
근거: {getTaskEvidenceLabel(task)} | 원본: {getTaskSourceLabel(task.source_type)}
-
-
- {taskStatusLabels[task.status]}
-
- )) : (
-
서명 세션에 연결된 내 작업이 없습니다.
- )}
+ {myTasksList}
)}
diff --git a/frontend/src/components/calendar/CalendarCoordinationView.tsx b/frontend/src/components/calendar/CalendarCoordinationView.tsx
index 41bf3b893..afe90a15d 100644
--- a/frontend/src/components/calendar/CalendarCoordinationView.tsx
+++ b/frontend/src/components/calendar/CalendarCoordinationView.tsx
@@ -1,32 +1,68 @@
-export function CalendarCoordinationView() {
+"use client";
+
+import { getCalendarSourceLabel, getCapabilityLabel, getEtagLabel, getProtocolLabel } from './helpers';
+import type { CalendarWritebackSource } from './types';
+
+type CalendarCoordinationViewProps = {
+ writebackSources: CalendarWritebackSource[];
+ selectedSourceId: string | null;
+ setSelectedSourceId: (sourceId: string) => void;
+ sourceLoadStatus: 'loading' | 'ready' | 'error';
+};
+
+export function CalendarCoordinationView({
+ writebackSources,
+ selectedSourceId,
+ setSelectedSourceId,
+ sourceLoadStatus,
+}: CalendarCoordinationViewProps) {
+ const selectedSource = writebackSources.find((source) => source.source_id === selectedSourceId) ?? null;
+
return (
-
+
회의 조율
-
참석자들의 캘린더(CalDAV)를 종합 분석하여 최적의 시간을 제안합니다.
-
-
-
-
1안
-
-
5월 23일 (목) 14:00 - 15:00
-
모든 참석자 참석 가능
-
-
- 제안하기
-
-
-
-
2안
-
-
5월 24일 (금) 10:00 - 11:00
-
1명(김개발) 불참 예상
-
-
- 제안하기
-
+
+ 서명된 고객 일정 원본을 선택합니다. 고정 ICS 예시나 미리 정해 둔 충돌 결과는
+ 조율 증거가 아닙니다. 원본 VEVENT 읽기는 커넥터 조회가 준비될 때까지 대기합니다.
+
+
+ {writebackSources.map((source, index) => {
+ const sourceLabel = getCalendarSourceLabel(index);
+ const sourceSelected = selectedSource?.source_id === source.source_id;
+ return (
+
setSelectedSourceId(source.source_id)}
+ className={`rounded-xl border p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 ${
+ sourceSelected
+ ? 'border-primary bg-primary/10 shadow-sm'
+ : 'border-border bg-background hover:border-primary/40'
+ }`}
+ >
+ {sourceLabel}
+ {getProtocolLabel(source.protocol)}
+
+ {source.capabilities.map(getCapabilityLabel).join(' · ')}
+
+
+ {getEtagLabel(source.etag)}
+
+
+ );
+ })}
+
+ {sourceLoadStatus === 'loading' && '서명된 일정 원본을 확인하는 중입니다.'}
+ {sourceLoadStatus === 'error' && '서명 세션으로 일정 원본을 확인할 수 없습니다. 공개 헤더로는 조율할 수 없습니다.'}
+ {sourceLoadStatus === 'ready' && writebackSources.length === 0 && '서명된 고객 일정 원본이 없어 조율 결과를 보여 주지 않습니다.'}
+ {sourceLoadStatus === 'ready' && selectedSource !== null && '선택한 일정 원본의 서명된 증거만 조율에 사용합니다.'}
+ {sourceLoadStatus === 'ready' && writebackSources.length > 0 && selectedSource === null && '조율에 사용할 서명된 일정 원본을 선택하세요.'}
+
-
+
);
}
diff --git a/frontend/src/components/calendar/constants.ts b/frontend/src/components/calendar/constants.ts
index f7440bfdd..aa418bc12 100644
--- a/frontend/src/components/calendar/constants.ts
+++ b/frontend/src/components/calendar/constants.ts
@@ -1,4 +1,9 @@
-import type { CalendarCandidateEvent, CalendarDefinition, CalendarMonthEvent, CalendarWeekEvent } from './types';
+import type {
+ CalendarCandidateEvent,
+ CalendarDefinition,
+ CalendarMonthEvent,
+ CalendarWeekEvent,
+} from './types';
export const calendarDefinitions: CalendarDefinition[] = [
{ id: 'personal', name: '김나루 (나)', colorClass: 'bg-primary' },
diff --git a/frontend/src/components/calendar/helpers.ts b/frontend/src/components/calendar/helpers.ts
index 1f6578aa9..2b4e627f1 100644
--- a/frontend/src/components/calendar/helpers.ts
+++ b/frontend/src/components/calendar/helpers.ts
@@ -1,5 +1,9 @@
import { calendarDefinitions } from "./constants";
-import { CalendarWritebackSource, CalendarWritebackIntentResponse } from "./types";
+import {
+ CalendarConflictDecisionCode,
+ CalendarWritebackSource,
+ CalendarWritebackIntentResponse,
+} from "./types";
export function buildInitialCalendarVisibility() {
return Object.fromEntries(calendarDefinitions.map((calendar) => [calendar.id, true]));
@@ -66,6 +70,36 @@ export function getProviderRetryLabel(result: CalendarWritebackIntentResponse) {
return '실행 요청 없음';
}
+export function getConflictDecisionLabel(decisionCode: CalendarConflictDecisionCode): string {
+ switch (decisionCode) {
+ case 'available':
+ return '진행 가능';
+ case 'blocked':
+ return '이중 예약 차단';
+ case 'review_required':
+ return '검토 필요';
+ default: {
+ const exhaustiveCheck: never = decisionCode;
+ return exhaustiveCheck;
+ }
+ }
+}
+
+export function getConflictNextActionLabel(decisionCode: CalendarConflictDecisionCode): string {
+ switch (decisionCode) {
+ case 'available':
+ return '이 시간은 비어 있습니다. 일정을 계속 진행하세요.';
+ case 'blocked':
+ return '확정된 일정이 겹칩니다. 다른 시간을 고르거나 기존 확정 일정을 먼저 조정하세요.';
+ case 'review_required':
+ return '잠정 일정이 겹칩니다. 잠정 일정을 조정하거나 유지할지 확인한 뒤 진행하세요.';
+ default: {
+ const exhaustiveCheck: never = decisionCode;
+ return exhaustiveCheck;
+ }
+ }
+}
+
export function getApiErrorStatus(error: unknown) {
const shapedError = error as { status?: unknown; response?: { status?: unknown } } | null;
if (typeof shapedError?.status === 'number') return shapedError.status;
diff --git a/frontend/src/components/calendar/types.ts b/frontend/src/components/calendar/types.ts
index 29006ba01..5481cd8ab 100644
--- a/frontend/src/components/calendar/types.ts
+++ b/frontend/src/components/calendar/types.ts
@@ -30,6 +30,23 @@ export type WritebackStatus = 'idle' | 'loading' | 'success' | 'no_source' | 'co
export type CalendarWritebackActionKey = 'create' | 'update' | 'execute';
+export type CalendarConflictDecisionCode = 'available' | 'blocked' | 'review_required';
+
+export type CalendarConflictEvidence = {
+ commitment_id: string;
+ start_at: string;
+ end_at: string;
+ status: 'confirmed' | 'tentative' | 'desired' | 'cancelled';
+};
+
+export type CalendarConflictResponse = {
+ decision_code: CalendarConflictDecisionCode;
+ reason_code: string;
+ conflicts: CalendarConflictEvidence[];
+ recommended_action: string;
+ policy_version: string;
+};
+
export type CalendarDefinition = {
id: string;
name: string;
diff --git a/frontend/tests/e2e/helpers.ts b/frontend/tests/e2e/helpers.ts
index 4042c15eb..d98fac63d 100644
--- a/frontend/tests/e2e/helpers.ts
+++ b/frontend/tests/e2e/helpers.ts
@@ -1023,6 +1023,40 @@ export async function mockDashboardApi(page: Page, onApiRequest?: (path: string,
return;
}
+ if (path === '/api/calendar/conflicts/evaluate' && request.method() === 'POST') {
+ const payload = JSON.parse(request.postData() || '{}') as {
+ existing_ics?: string;
+ };
+ if (payload.existing_ics?.includes('STATUS:CANCELLED')) {
+ await fulfillJson(route, {
+ decision_code: 'available',
+ reason_code: 'no_overlapping_commitment',
+ conflicts: [],
+ recommended_action: 'Proceed with scheduling.',
+ policy_version: 'status-weighted-v1',
+ });
+ return;
+ }
+ if (payload.existing_ics?.includes('STATUS:TENTATIVE')) {
+ await fulfillJson(route, {
+ decision_code: 'review_required',
+ reason_code: 'lower_priority_conflict_requires_explicit_resolution',
+ conflicts: [],
+ recommended_action: 'Review the lower-priority conflict.',
+ policy_version: 'status-weighted-v1',
+ });
+ return;
+ }
+ await fulfillJson(route, {
+ decision_code: 'blocked',
+ reason_code: 'equal_or_higher_priority_conflict',
+ conflicts: [],
+ recommended_action: 'Choose another time.',
+ policy_version: 'status-weighted-v1',
+ });
+ return;
+ }
+
if (path === '/api/calendar/writeback-intent' && request.method() === 'POST') {
await fulfillJson(route, {
workspace_id: 'default',
diff --git a/plan.md b/plan.md
new file mode 100644
index 000000000..bbc5ddc29
--- /dev/null
+++ b/plan.md
@@ -0,0 +1,21 @@
+# NetworkGraph constant-time lookup plan
+
+1. Pre-compute `edgeMap` and `nodeInstanceMap` with `useMemo`, and keep the existing `nodeMap` as the authoritative node-label lookup for rendered selections.
+ - `selectEdge` uses `edgeMap.get(String(edgeId))`.
+ - `selectNode` uses `nodeMap.get(String(nodeId))` with the node identifier as the no-entry fallback.
+ - `selectGraphNode` uses `nodeMap.get(String(node.id))` with the node identifier as the no-entry fallback.
+ - `handleRelationshipOptionChange` uses `edgeMap.get(value)`.
+ - `handleNodeOptionChange` uses `nodeInstanceMap.get(value)`.
+ - Selection handlers must not fall back to `Array.prototype.find()` or `findNodeLabel()` scans.
+ - `edgeMap` and `nodeInstanceMap` are first-wins, matching `nodeMap` and the previous `.find()` path. Last-wins `new Map(items.map(...))` construction is rejected.
+
+2. Verify the exact branch head from `frontend/` with these commands:
+
+ ```bash
+ pnpm test -- src/components/NetworkGraph.test.tsx src/components/NetworkGraph.map-lookup.test.ts
+ pnpm exec eslint src/components/NetworkGraph.tsx src/components/NetworkGraph.test.tsx src/components/NetworkGraph.map-lookup.test.ts
+ pnpm typecheck
+ pnpm build
+ ```
+
+3. Keep the pull request open until the unchanged exact head has terminal-success required checks, all addressed review threads are resolved, and protected-branch review requirements are satisfied without bypass.
diff --git a/test_parse.py b/test_parse.py
new file mode 100644
index 000000000..374a3c09b
--- /dev/null
+++ b/test_parse.py
@@ -0,0 +1,24 @@
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent / "backend"))
+from services.text_safety import _strip_tag_like_segments, _PlainTextHTMLParser
+
+
+def main() -> None:
+ parser = _PlainTextHTMLParser()
+ parser.feed("-->")
+ parser.close()
+ text = parser.get_text()
+ print("Parsed text:", repr(text))
+ print("Strip tag like segments:", repr(_strip_tag_like_segments(text)))
+
+ # also look at what the parser does with
+ parser2 = _PlainTextHTMLParser()
+ parser2.feed("")
+ parser2.close()
+ print("Parsed :", repr(parser2.get_text()))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/test_parse2.py b/test_parse2.py
new file mode 100644
index 000000000..76c435252
--- /dev/null
+++ b/test_parse2.py
@@ -0,0 +1,14 @@
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent / "backend"))
+from services.text_safety import strip_html_markup
+
+
+def main() -> None:
+ payload = "-->"
+ print(repr(strip_html_markup(payload)))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/test_parse3.py b/test_parse3.py
new file mode 100644
index 000000000..cbdec66c1
--- /dev/null
+++ b/test_parse3.py
@@ -0,0 +1,33 @@
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent / "backend"))
+from services.text_safety import _mask_angle_emails, _PlainTextHTMLParser, _strip_tag_like_segments
+
+
+def main() -> None:
+ payload = "-->"
+
+ decoded = payload
+ masked, placeholders = _mask_angle_emails(decoded)
+ print("masked:", repr(masked))
+ parser = _PlainTextHTMLParser()
+ parser.feed(masked)
+ parser.close()
+ text = parser.get_text()
+ print("text after parser get_text (normalized):", repr(text))
+
+ print("after get_text but raw joins:", repr("".join(parser._parts)))
+ print("just _strip_tag_like_segments directly on parser._parts:", _strip_tag_like_segments("".join(parser._parts)))
+
+ cleaned_lines = []
+ for line in text.splitlines():
+ cleaned_lines.append(_strip_tag_like_segments(line))
+ text = "\n".join(cleaned_lines).strip()
+ for token, original in placeholders.items():
+ text = text.replace(token, original)
+ print("text after second loop:", repr(text))
+
+
+if __name__ == "__main__":
+ main()
From fb7e63dee1d72365db595edb1bc49e097202e707 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 24 Aug 2026 22:16:29 +0900
Subject: [PATCH 15/15] fix(security): bump aiohttp to 3.14.3 and js-yaml to
4.3.1 (#1241)
- aiohttp 3.14.1 in requirements-strix-ci-hashes.txt is flagged by
osv-scan (PYSEC-2026-3545/3546/3547, GHSA-cq5v-8q36-5273,
GHSA-mfx4-hv73-q22v, GHSA-mq44-7p77-q5h7). Splice in the uv-generated
3.14.3 hash block only; all other pins unchanged.
- js-yaml 4.3.0 in frontend/pnpm-lock.yaml is flagged (GHSA-5p4m-2wfm-xmqj,
CVE-2026-59870 fix backported in 4.3.1). Add a js-yaml override in
pnpm-workspace.yaml and refresh the lockfile.
- nanoid@3.3.16 finding clears via the develop-baseline restore
(lockfile now at patched 3.3.18).
---
frontend/pnpm-lock.yaml | 10 +-
frontend/pnpm-workspace.yaml | 1 +
requirements-strix-ci-hashes.txt | 240 +++++++++++++++----------------
3 files changed, 127 insertions(+), 124 deletions(-)
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 610a0e7ca..eb21ead24 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -6,6 +6,7 @@ settings:
overrides:
brace-expansion: 5.0.9
+ js-yaml: 4.3.1
postcss: 8.5.24
sharp: 0.35.0
undici: 8.9.0
@@ -1622,6 +1623,7 @@ packages:
eslint@9.39.5:
resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
hasBin: true
peerDependencies:
jiti: '*'
@@ -1980,8 +1982,8 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
- js-yaml@4.3.0:
- resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
+ js-yaml@4.3.1:
+ resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
hasBin: true
jsdom@30.0.1:
@@ -3156,7 +3158,7 @@ snapshots:
globals: 14.0.0
ignore: 5.3.2
import-fresh: 3.3.1
- js-yaml: 4.3.0
+ js-yaml: 4.3.1
minimatch: 3.1.5(patch_hash=5f38b9c5382c1163b0389810f5e4e867519096f3c11a6df0a51d7cafbdfa93e2)
strip-json-comments: 3.1.1
transitivePeerDependencies:
@@ -4826,7 +4828,7 @@ snapshots:
js-tokens@4.0.0: {}
- js-yaml@4.3.0:
+ js-yaml@4.3.1:
dependencies:
argparse: 2.0.1
diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml
index d028031d2..f9387e42a 100644
--- a/frontend/pnpm-workspace.yaml
+++ b/frontend/pnpm-workspace.yaml
@@ -15,6 +15,7 @@ supportedArchitectures:
overrides:
brace-expansion: "5.0.9"
+ js-yaml: "4.3.1"
postcss: "8.5.24"
sharp: "0.35.0"
undici: 8.9.0
diff --git a/requirements-strix-ci-hashes.txt b/requirements-strix-ci-hashes.txt
index eaa2ad04f..d4f306614 100644
--- a/requirements-strix-ci-hashes.txt
+++ b/requirements-strix-ci-hashes.txt
@@ -4,126 +4,126 @@ aiohappyeyeballs==2.7.1 \
--hash=sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d \
--hash=sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472
# via aiohttp
-aiohttp==3.14.1 \
- --hash=sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5 \
- --hash=sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983 \
- --hash=sha256:092e4ce3619a7c6dee52a6bdabda973d9b34b66781f840ce93c7e0cec30cf521 \
- --hash=sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340 \
- --hash=sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d \
- --hash=sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a \
- --hash=sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4 \
- --hash=sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a \
- --hash=sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f \
- --hash=sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee \
- --hash=sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8 \
- --hash=sha256:23119f8fd4f5d16902ed459b63b100bcd269628075162bddac56cc7b5273b3fb \
- --hash=sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397 \
- --hash=sha256:24ba13339fed9251d9b1a1bec8c7ab84c0d1675d79d33501e11f94f8b9a84e05 \
- --hash=sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8 \
- --hash=sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09 \
- --hash=sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2 \
- --hash=sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba \
- --hash=sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf \
- --hash=sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271 \
- --hash=sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5 \
- --hash=sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847 \
- --hash=sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264 \
- --hash=sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf \
- --hash=sha256:2fe3607e71acc6ebb0ec8e492a247bf7a291226192dc0084236dfc12478916f6 \
- --hash=sha256:30099eda75a53c32efb0920e9c33c195314d2cc1c680fbfd30894932ac5f27df \
- --hash=sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035 \
- --hash=sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126 \
- --hash=sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6 \
- --hash=sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35 \
- --hash=sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4 \
- --hash=sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333 \
- --hash=sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203 \
- --hash=sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c \
- --hash=sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1 \
- --hash=sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251 \
- --hash=sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365 \
- --hash=sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b \
- --hash=sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621 \
- --hash=sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94 \
- --hash=sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da \
- --hash=sha256:4f7215cb3933784f79ed20e5f050e15984f390424339b22375d5a53c933a0491 \
- --hash=sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe \
- --hash=sha256:52cdac9432d8b4a719f35094a818d95adcae0f0b4fe9b9b921909e0c87de9e7d \
- --hash=sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080 \
- --hash=sha256:57fc6745a4b7d0f5a9eb4f40a69718be6c0bc1b8368cc9fe89e90118719f4f42 \
- --hash=sha256:5a837f49d901f9e368651b676912bff1104ed8c1a83b280bcd7b29adccef5c9c \
- --hash=sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397 \
- --hash=sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9 \
- --hash=sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8 \
- --hash=sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345 \
- --hash=sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3 \
- --hash=sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602 \
- --hash=sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2 \
- --hash=sha256:672ac254412a24d0d0cf00a9e6c238877e4be5e5fa2d188832c1244f45f31966 \
- --hash=sha256:672b9d65f42eb877f5c3f234a4547e4e1a226ca8c2eed879bb34670a0ce51192 \
- --hash=sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95 \
- --hash=sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3 \
- --hash=sha256:6fd35beba67c4183b09375c5fff9accb47524191a244a99f95fd4472f5402c2b \
- --hash=sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444 \
- --hash=sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6 \
- --hash=sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573 \
- --hash=sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af \
- --hash=sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15 \
- --hash=sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe \
- --hash=sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2 \
- --hash=sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496 \
- --hash=sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876 \
- --hash=sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817 \
- --hash=sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448 \
- --hash=sha256:8f6bb621e5863cfe8fe5ff5468002d200ec31f30f1280b259dc505b02595099e \
- --hash=sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6 \
- --hash=sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd \
- --hash=sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f \
- --hash=sha256:94da27378da0610e341c4d30de29a191672683cc82b8f9556e8f7c7212a020fe \
- --hash=sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c \
- --hash=sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca \
- --hash=sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c \
- --hash=sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa \
- --hash=sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc \
- --hash=sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0 \
- --hash=sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0 \
- --hash=sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2 \
- --hash=sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844 \
- --hash=sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719 \
- --hash=sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1 \
- --hash=sha256:b238af795833d5731d049d82bc84b768ae6f8f97f0495963b3ed9935c5901cc3 \
- --hash=sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178 \
- --hash=sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3 \
- --hash=sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95 \
- --hash=sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730 \
- --hash=sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842 \
- --hash=sha256:bb33777ea21e8b7ecde0e6fc84f598be0a1192eab1a63bc746d75aa75d38e7bd \
- --hash=sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d \
- --hash=sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96 \
- --hash=sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85 \
- --hash=sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1 \
- --hash=sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199 \
- --hash=sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a \
- --hash=sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588 \
- --hash=sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec \
- --hash=sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004 \
- --hash=sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480 \
- --hash=sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04 \
- --hash=sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8 \
- --hash=sha256:d9d4e294455b23a68c9b8f042d0e8e377a265bcb15332753695f6e5b6819e0ce \
- --hash=sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087 \
- --hash=sha256:e4e5e0ae56914ecdbf446493addefc0159053dd53962cef37d7839f37f73d505 \
- --hash=sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780 \
- --hash=sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4 \
- --hash=sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d \
- --hash=sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca \
- --hash=sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665 \
- --hash=sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296 \
- --hash=sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c \
- --hash=sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a \
- --hash=sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7 \
- --hash=sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451 \
- --hash=sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3
+aiohttp==3.14.3 \
+ --hash=sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39 \
+ --hash=sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043 \
+ --hash=sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b \
+ --hash=sha256:0fdea2281997af69da84c77ffa6f5938a0285f21fb3887c249d67419ca865b3d \
+ --hash=sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf \
+ --hash=sha256:134ac5ddcf61c6fad984b9a5727d83492ada43d63471db20fb73042c13fca62f \
+ --hash=sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7 \
+ --hash=sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc \
+ --hash=sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559 \
+ --hash=sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f \
+ --hash=sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929 \
+ --hash=sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147 \
+ --hash=sha256:1b59533861b70a2185c8f4f350f791f39d64358ef6944ce71c5240c9ec0982c9 \
+ --hash=sha256:1c5281acc88b92396f88c7e1e2748f8466689df22b80170e4f51efa712fb47a8 \
+ --hash=sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf \
+ --hash=sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7 \
+ --hash=sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8 \
+ --hash=sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85 \
+ --hash=sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30 \
+ --hash=sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553 \
+ --hash=sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7 \
+ --hash=sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86 \
+ --hash=sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e \
+ --hash=sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a \
+ --hash=sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c \
+ --hash=sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da \
+ --hash=sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5 \
+ --hash=sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d \
+ --hash=sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100 \
+ --hash=sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71 \
+ --hash=sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22 \
+ --hash=sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1 \
+ --hash=sha256:48d67b87db6279c044760787eb01f6413032c2e6f3ba1cafaa492b1c8e578479 \
+ --hash=sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb \
+ --hash=sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062 \
+ --hash=sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661 \
+ --hash=sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427 \
+ --hash=sha256:5373dc80ad1aa2fb9ad95c83f24eef418bbda3a61375f128e5b0192e4f3f9b32 \
+ --hash=sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a \
+ --hash=sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db \
+ --hash=sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42 \
+ --hash=sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a \
+ --hash=sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd \
+ --hash=sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06 \
+ --hash=sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8 \
+ --hash=sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228 \
+ --hash=sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0 \
+ --hash=sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919 \
+ --hash=sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee \
+ --hash=sha256:6debfa7312ff9d4c124dc71d72e9a0a4b9e0879e48ba6fcb42bef5c3300289e2 \
+ --hash=sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f \
+ --hash=sha256:70c987b27534f9ae1a723f47ae921571d616da21d3208282bf4c52af5164ac43 \
+ --hash=sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098 \
+ --hash=sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c \
+ --hash=sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371 \
+ --hash=sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b \
+ --hash=sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0 \
+ --hash=sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f \
+ --hash=sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100 \
+ --hash=sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529 \
+ --hash=sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc \
+ --hash=sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c \
+ --hash=sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41 \
+ --hash=sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716 \
+ --hash=sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33 \
+ --hash=sha256:a3a8296e7ab5c295f53f1041487cb088e1480775aafbf7fe545d93b770a0f96f \
+ --hash=sha256:a3e22975f905b89a55a488c2a08f2fdb2186175349e917d48985cc468a3d4c6e \
+ --hash=sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa \
+ --hash=sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b \
+ --hash=sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80 \
+ --hash=sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646 \
+ --hash=sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e \
+ --hash=sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b \
+ --hash=sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c \
+ --hash=sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963 \
+ --hash=sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae \
+ --hash=sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25 \
+ --hash=sha256:bdd0e2834dce1a26c1bbe26464861e16bbe217042cbff619247c11594472518c \
+ --hash=sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f \
+ --hash=sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807 \
+ --hash=sha256:c3c200cf9757edd785051dc699c7ecbec22110dbfcb3fefc7a9f9695eda8ea7a \
+ --hash=sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f \
+ --hash=sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d \
+ --hash=sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82 \
+ --hash=sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15 \
+ --hash=sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0 \
+ --hash=sha256:cda5fd5c95ad7a125a2e8464acc78b98b94c475a3780d6aa0aa157c93f470f4d \
+ --hash=sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9 \
+ --hash=sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19 \
+ --hash=sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239 \
+ --hash=sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0 \
+ --hash=sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c \
+ --hash=sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5 \
+ --hash=sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b \
+ --hash=sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4 \
+ --hash=sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2 \
+ --hash=sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9 \
+ --hash=sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0 \
+ --hash=sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883 \
+ --hash=sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d \
+ --hash=sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d \
+ --hash=sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6 \
+ --hash=sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3 \
+ --hash=sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924 \
+ --hash=sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde \
+ --hash=sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787 \
+ --hash=sha256:eac645b09bcfdf73df7536331f0678c1086ea250981118ddb5199e17ccef72bb \
+ --hash=sha256:eb0495d778817619273c108784292be161a924b9f5ae5cbbc70a2caa6838250b \
+ --hash=sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0 \
+ --hash=sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910 \
+ --hash=sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9 \
+ --hash=sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627 \
+ --hash=sha256:f4e05329faa0ea1a404b37de4f034fd2c2defcca06a68dc6745e4e56c88e8a48 \
+ --hash=sha256:f53bcd52f585e1ac3e590d61434eb61f9a88c38df041b4ea126d97144344a77b \
+ --hash=sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce \
+ --hash=sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a \
+ --hash=sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0 \
+ --hash=sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24 \
+ --hash=sha256:fd51ebf9d3a00c074df4ede271023f4d2dba289bcc740b88191872716014e3c5
# via
# gql
# litellm