From 3d08c9cce08593c466a5700b77c1646f94c65ffe Mon Sep 17 00:00:00 2001 From: dev-wooyeon Date: Thu, 12 Mar 2026 15:07:04 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor(agentation):=20same-origin=20sync?= =?UTF-8?q?=20=ED=9D=90=EB=A6=84=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 개발 환경에서 Agentation overlay를 same-origin 프록시로 연결했어요. autorun webhook이 annotation 메타데이터를 프롬프트에 반영하고 stale 실행을 정리하도록 개선했어요. 로컬 개발 가이드도 현재 동작에 맞게 업데이트했어요. --- docs/guides/agentation-workflow.md | 12 ++-- next.config.mjs | 12 ++++ src/app/api/agentation/webhook/route.ts | 83 ++++++++++++++++++++--- src/shared/devtools/AgentationOverlay.tsx | 2 +- 4 files changed, 96 insertions(+), 13 deletions(-) diff --git a/docs/guides/agentation-workflow.md b/docs/guides/agentation-workflow.md index 0b7bdc1b..1f077fee 100644 --- a/docs/guides/agentation-workflow.md +++ b/docs/guides/agentation-workflow.md @@ -11,11 +11,12 @@ npm run dev ``` -기본 엔드포인트는 `http://localhost:4747`이고, +기본 엔드포인트는 same-origin 프록시인 `/api/agentation-sync`예요. +Next 개발 서버가 내부에서 `http://localhost:4747`로 전달해서 CORS 없이 써요. 필요하면 환경변수로 바꿔서 쓸 수 있어요. ```bash -NEXT_PUBLIC_AGENTATION_ENDPOINT=http://localhost:4747 +NEXT_PUBLIC_AGENTATION_ENDPOINT=/api/agentation-sync ``` ## 2. Codex MCP 서버 등록 @@ -57,8 +58,9 @@ npx agentation-mcp server 개발 환경에서는 코멘트를 등록하면 webhook으로 자동 실행을 바로 트리거해요. - 기본 webhook URL: `/api/agentation/webhook` -- 기본 동작: `annotation.add` 또는 `submit` 이벤트가 들어오면 `codex exec`를 자동 실행해요. +- 기본 동작: `annotation.add` 또는 `submit` 이벤트가 들어오면 `codex exec`를 한 번 실행하고 종료해요. webhook에 담긴 코멘트, 세션, URL 같은 정보도 함께 프롬프트에 넣어서 바로 처리해요. - 중복 실행 방지: `.agentation/autorun.lock.json` 락 파일로 한 번에 한 프로세스만 실행해요. +- stale 실행 정리: 기본 120초를 넘기거나 45초 동안 로그가 멈춘 autorun은 다음 webhook 요청이 들어오면 정리하고 새로 실행해요. - 로그 경로: `.agentation/autorun.log` 환경변수로 동작을 조정할 수 있어요. @@ -66,7 +68,9 @@ npx agentation-mcp server ```bash NEXT_PUBLIC_AGENTATION_WEBHOOK_URL=/api/agentation/webhook AGENTATION_AUTORUN_ENABLED=true -AGENTATION_AUTORUN_COMMAND="codex exec --full-auto -C /Users/noah/workspace/personal/eunu.log 'watch mode로 계속 처리해줘'" +AGENTATION_AUTORUN_COMMAND="codex exec --full-auto -C /Users/noah/workspace/personal/eunu.log '방금 등록된 annotation을 처리해줘'" +AGENTATION_AUTORUN_MAX_AGE_MS=120000 +AGENTATION_AUTORUN_IDLE_TIMEOUT_MS=45000 ``` `AGENTATION_AUTORUN_COMMAND`를 지정하지 않으면 기본 `codex exec --full-auto` 명령을 사용해요. diff --git a/next.config.mjs b/next.config.mjs index 598dc299..7f57fbd1 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -33,6 +33,18 @@ const nextConfig = { }, ]; }, + async rewrites() { + if (process.env.NODE_ENV === 'production') { + return []; + } + + return [ + { + source: '/api/agentation-sync/:path*', + destination: 'http://localhost:4747/:path*', + }, + ]; + }, async headers() { return [ { diff --git a/src/app/api/agentation/webhook/route.ts b/src/app/api/agentation/webhook/route.ts index 4691bd11..8ffffc1b 100644 --- a/src/app/api/agentation/webhook/route.ts +++ b/src/app/api/agentation/webhook/route.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process'; -import { closeSync, existsSync, openSync } from 'node:fs'; +import { closeSync, existsSync, openSync, statSync } from 'node:fs'; import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { NextResponse } from 'next/server'; @@ -12,6 +12,12 @@ type AgentationWebhookPayload = { id?: string; comment?: string; sessionId?: string; + timestamp?: number; + url?: string; + element?: string; + elementPath?: string; + nearbyText?: string; + selectedText?: string; }; timestamp?: number; url?: string; @@ -28,8 +34,42 @@ const AUTO_EVENTS = new Set(['annotation.add', 'submit']); const AUTORUN_DIR = path.join(process.cwd(), '.agentation'); const LOCK_PATH = path.join(AUTORUN_DIR, 'autorun.lock.json'); const LOG_PATH = path.join(AUTORUN_DIR, 'autorun.log'); +const AUTORUN_MAX_AGE_MS = Number( + process.env.AGENTATION_AUTORUN_MAX_AGE_MS ?? '120000' +); +const AUTORUN_IDLE_TIMEOUT_MS = Number( + process.env.AGENTATION_AUTORUN_IDLE_TIMEOUT_MS ?? '45000' +); const DEFAULT_PROMPT = - 'watch mode로 계속 처리해줘. Agentation pending annotation을 확인해서 코드 반영, 테스트 검증, resolve 처리까지 완료해줘.'; + '방금 등록된 Agentation annotation 한 건을 처리해줘. webhook에 담긴 코멘트와 위치 정보를 기준으로 관련 코드만 최소 수정하고, 필요한 테스트만 검증한 뒤 resolve 처리하고 바로 종료해줘. 브라우저를 새로 열거나 추가 입력을 기다리거나 watch mode로 머물지 말아줘. pending 조회에서 바로 안 보여도 아래 정보를 기준으로 해당 요청을 찾아 처리해줘.'; + +function buildAutorunPrompt(payload: AgentationWebhookPayload): string { + const annotation = payload.annotation; + const details = [ + annotation?.id ? `- webhook annotation id: ${annotation.id}` : null, + annotation?.comment ? `- comment: ${annotation.comment}` : null, + annotation?.sessionId ? `- sessionId: ${annotation.sessionId}` : null, + annotation?.url || payload.url + ? `- url: ${annotation?.url ?? payload.url}` + : null, + annotation?.element ? `- element: ${annotation.element}` : null, + annotation?.elementPath ? `- elementPath: ${annotation.elementPath}` : null, + annotation?.nearbyText ? `- nearbyText: ${annotation.nearbyText}` : null, + annotation?.selectedText + ? `- selectedText: ${annotation.selectedText}` + : null, + annotation?.timestamp + ? `- annotation timestamp: ${annotation.timestamp}` + : null, + payload.timestamp ? `- event timestamp: ${payload.timestamp}` : null, + ].filter((value): value is string => Boolean(value)); + + if (details.length === 0) { + return DEFAULT_PROMPT; + } + + return `${DEFAULT_PROMPT}\n\n다음 정보를 참고해줘:\n${details.join('\n')}`; +} function isPidRunning(pid: number): boolean { try { @@ -40,6 +80,24 @@ function isPidRunning(pid: number): boolean { } } +function getLockAgeMs(lock: AutorunLock): number { + const startedAt = new Date(lock.startedAt).getTime(); + + if (Number.isNaN(startedAt)) { + return Number.POSITIVE_INFINITY; + } + + return Date.now() - startedAt; +} + +function getAutorunIdleMs(): number { + try { + return Date.now() - statSync(LOG_PATH).mtimeMs; + } catch { + return Number.POSITIVE_INFINITY; + } +} + async function readLockFile(): Promise { if (!existsSync(LOCK_PATH)) { return null; @@ -61,10 +119,21 @@ async function clearStaleLock(lock: AutorunLock | null): Promise { if (!lock) { return; } - if (isPidRunning(lock.pid)) { + + const isRunning = isPidRunning(lock.pid); + const isExpired = getLockAgeMs(lock) > AUTORUN_MAX_AGE_MS; + const isIdle = getAutorunIdleMs() > AUTORUN_IDLE_TIMEOUT_MS; + + if (isRunning && !isExpired && !isIdle) { return; } + if (isRunning && (isExpired || isIdle)) { + try { + process.kill(lock.pid, 'SIGTERM'); + } catch {} + } + if (existsSync(LOCK_PATH)) { await rm(LOCK_PATH, { force: true }); } @@ -74,7 +143,7 @@ async function writeLockFile(lock: AutorunLock): Promise { await writeFile(LOCK_PATH, JSON.stringify(lock, null, 2), 'utf8'); } -function runAutorunCommand(annotationId?: string) { +function runAutorunCommand(payload: AgentationWebhookPayload) { const customCommand = process.env.AGENTATION_AUTORUN_COMMAND?.trim(); const logFd = openSync(LOG_PATH, 'a'); try { @@ -88,9 +157,7 @@ function runAutorunCommand(annotationId?: string) { }); } - const autoPrompt = annotationId - ? `${DEFAULT_PROMPT} 방금 등록된 annotation id는 ${annotationId}예요.` - : DEFAULT_PROMPT; + const autoPrompt = buildAutorunPrompt(payload); return spawn( 'codex', @@ -171,7 +238,7 @@ export async function POST(request: Request) { }); } - const child = runAutorunCommand(payload.annotation?.id); + const child = runAutorunCommand(payload); if (!child.pid) { return NextResponse.json( { ok: false, reason: 'failed to spawn autorun process' }, diff --git a/src/shared/devtools/AgentationOverlay.tsx b/src/shared/devtools/AgentationOverlay.tsx index 955420e0..03bc8b46 100644 --- a/src/shared/devtools/AgentationOverlay.tsx +++ b/src/shared/devtools/AgentationOverlay.tsx @@ -2,7 +2,7 @@ import { Agentation } from 'agentation'; -const DEFAULT_AGENTATION_ENDPOINT = 'http://localhost:4747'; +const DEFAULT_AGENTATION_ENDPOINT = '/api/agentation-sync'; const DEFAULT_AGENTATION_WEBHOOK_URL = '/api/agentation/webhook'; export default function AgentationOverlay() { From 385e5862de9f1e4773adfafa4a0fbef5fa88b954 Mon Sep 17 00:00:00 2001 From: dev-wooyeon Date: Thu, 12 Mar 2026 15:07:15 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor(blog):=20=EC=8B=9C=EB=A6=AC?= =?UTF-8?q?=EC=A6=88=20=ED=97=88=EB=B8=8C=20=EC=B9=B4=EB=93=9C=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 시리즈 제목 앞의 중복 배지를 제거하고 에피소드 링크 여백을 정리했어요. 변경된 카드 구조를 검증하는 컴포넌트 테스트를 추가했어요. --- .../blog/ui/components/SeriesHubCard.test.tsx | 79 +++++++++++++++++++ .../blog/ui/components/SeriesHubCard.tsx | 20 +++-- 2 files changed, 91 insertions(+), 8 deletions(-) create mode 100644 src/features/blog/ui/components/SeriesHubCard.test.tsx diff --git a/src/features/blog/ui/components/SeriesHubCard.test.tsx b/src/features/blog/ui/components/SeriesHubCard.test.tsx new file mode 100644 index 00000000..5a02ee5b --- /dev/null +++ b/src/features/blog/ui/components/SeriesHubCard.test.tsx @@ -0,0 +1,79 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { SeriesSummary } from '@/features/blog/model/series-group'; +import SeriesHubCard from './SeriesHubCard'; + +vi.mock('./SeriesTrackedLink', () => ({ + default: ({ + href, + children, + className, + }: { + href: string; + children: React.ReactNode; + className?: string; + }) => ( + + {children} + + ), +})); + +const summary: SeriesSummary = { + id: 'redis-deep-dive', + title: 'Redis 완전정복', + latestDate: '2026-03-10', + firstPostSlug: 'redis-1', + postCount: 2, + totalReadingMinutes: 18, + posts: [ + { + slug: 'redis-1', + title: 'Redis 1편', + description: '기초를 다뤄요', + date: '2026-03-01', + category: 'Tech', + tags: ['Redis'], + readingTime: 8, + series: { + id: 'redis-deep-dive', + title: 'Redis 완전정복', + order: 1, + }, + }, + { + slug: 'redis-2', + title: 'Redis 2편', + description: '심화를 다뤄요', + date: '2026-03-10', + category: 'Tech', + tags: ['Redis'], + readingTime: 10, + series: { + id: 'redis-deep-dive', + title: 'Redis 완전정복', + order: 2, + }, + }, + ], +}; + +describe('SeriesHubCard', () => { + it('renders series title and links without the redundant series badge', () => { + render(); + + expect( + screen.getByRole('heading', { level: 2, name: 'Redis 완전정복' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: /첫 글부터 읽기/i }) + ).toHaveAttribute('href', '/blog/redis-1'); + const firstEpisodeLink = screen.getByRole('link', { + name: /1\.?\s*Redis 1편/i, + }); + + expect(firstEpisodeLink).toHaveAttribute('href', '/blog/redis-1'); + expect(firstEpisodeLink).toHaveClass('min-h-10', 'py-1.5'); + expect(screen.queryByText('Series')).not.toBeInTheDocument(); + }); +}); diff --git a/src/features/blog/ui/components/SeriesHubCard.tsx b/src/features/blog/ui/components/SeriesHubCard.tsx index 9ec6f085..d46ef434 100644 --- a/src/features/blog/ui/components/SeriesHubCard.tsx +++ b/src/features/blog/ui/components/SeriesHubCard.tsx @@ -7,7 +7,10 @@ interface SeriesHubCardProps { seriesIndex: number; } -export default function SeriesHubCard({ summary, seriesIndex }: SeriesHubCardProps) { +export default function SeriesHubCard({ + summary, + seriesIndex, +}: SeriesHubCardProps) { const metaText = `총 ${summary.postCount}편 · 총 ${summary.totalReadingMinutes}분 · 최근 업데이트 ${formatSeriesDate(summary.latestDate)}`; const firstPostOrder = summary.posts[0]?.series?.order; @@ -15,13 +18,12 @@ export default function SeriesHubCard({ summary, seriesIndex }: SeriesHubCardPro
- - Series - -

+

{summary.title}

-

{metaText}

+

+ {metaText} +

{summary.firstPostSlug && ( @@ -55,9 +57,11 @@ export default function SeriesHubCard({ summary, seriesIndex }: SeriesHubCardPro postSlug={post.slug} episodeOrder={order} seriesIndex={seriesIndex} - className="flex min-h-11 items-center gap-3 rounded-[var(--radius-sm)] px-3 py-2 text-sm text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-grey-50)] hover:text-[var(--color-text-primary)]" + className="flex min-h-10 items-center gap-3 rounded-[var(--radius-sm)] px-3 py-1.5 text-sm text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-grey-50)] hover:text-[var(--color-text-primary)]" > - {order}. + + {order}. + {post.title}