Skip to content

Commit ce3ebd8

Browse files
authored
refactor(infra): Agentation 흐름과 시리즈 카드 정리 (#82)
* refactor(agentation): same-origin sync 흐름 정리 개발 환경에서 Agentation overlay를 same-origin 프록시로 연결했어요. autorun webhook이 annotation 메타데이터를 프롬프트에 반영하고 stale 실행을 정리하도록 개선했어요. 로컬 개발 가이드도 현재 동작에 맞게 업데이트했어요. * refactor(blog): 시리즈 허브 카드 구조 정리 시리즈 제목 앞의 중복 배지를 제거하고 에피소드 링크 여백을 정리했어요. 변경된 카드 구조를 검증하는 컴포넌트 테스트를 추가했어요.
1 parent 492e8f8 commit ce3ebd8

6 files changed

Lines changed: 187 additions & 21 deletions

File tree

docs/guides/agentation-workflow.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@
1111
npm run dev
1212
```
1313

14-
기본 엔드포인트는 `http://localhost:4747`이고,
14+
기본 엔드포인트는 same-origin 프록시인 `/api/agentation-sync`예요.
15+
Next 개발 서버가 내부에서 `http://localhost:4747`로 전달해서 CORS 없이 써요.
1516
필요하면 환경변수로 바꿔서 쓸 수 있어요.
1617

1718
```bash
18-
NEXT_PUBLIC_AGENTATION_ENDPOINT=http://localhost:4747
19+
NEXT_PUBLIC_AGENTATION_ENDPOINT=/api/agentation-sync
1920
```
2021

2122
## 2. Codex MCP 서버 등록
@@ -57,16 +58,19 @@ npx agentation-mcp server
5758
개발 환경에서는 코멘트를 등록하면 webhook으로 자동 실행을 바로 트리거해요.
5859

5960
- 기본 webhook URL: `/api/agentation/webhook`
60-
- 기본 동작: `annotation.add` 또는 `submit` 이벤트가 들어오면 `codex exec`자동 실행해요.
61+
- 기본 동작: `annotation.add` 또는 `submit` 이벤트가 들어오면 `codex exec`한 번 실행하고 종료해요. webhook에 담긴 코멘트, 세션, URL 같은 정보도 함께 프롬프트에 넣어서 바로 처리해요.
6162
- 중복 실행 방지: `.agentation/autorun.lock.json` 락 파일로 한 번에 한 프로세스만 실행해요.
63+
- stale 실행 정리: 기본 120초를 넘기거나 45초 동안 로그가 멈춘 autorun은 다음 webhook 요청이 들어오면 정리하고 새로 실행해요.
6264
- 로그 경로: `.agentation/autorun.log`
6365

6466
환경변수로 동작을 조정할 수 있어요.
6567

6668
```bash
6769
NEXT_PUBLIC_AGENTATION_WEBHOOK_URL=/api/agentation/webhook
6870
AGENTATION_AUTORUN_ENABLED=true
69-
AGENTATION_AUTORUN_COMMAND="codex exec --full-auto -C /Users/noah/workspace/personal/eunu.log 'watch mode로 계속 처리해줘'"
71+
AGENTATION_AUTORUN_COMMAND="codex exec --full-auto -C /Users/noah/workspace/personal/eunu.log '방금 등록된 annotation을 처리해줘'"
72+
AGENTATION_AUTORUN_MAX_AGE_MS=120000
73+
AGENTATION_AUTORUN_IDLE_TIMEOUT_MS=45000
7074
```
7175

7276
`AGENTATION_AUTORUN_COMMAND`를 지정하지 않으면 기본 `codex exec --full-auto` 명령을 사용해요.

next.config.mjs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@ const nextConfig = {
3333
},
3434
];
3535
},
36+
async rewrites() {
37+
if (process.env.NODE_ENV === 'production') {
38+
return [];
39+
}
40+
41+
return [
42+
{
43+
source: '/api/agentation-sync/:path*',
44+
destination: 'http://localhost:4747/:path*',
45+
},
46+
];
47+
},
3648
async headers() {
3749
return [
3850
{

src/app/api/agentation/webhook/route.ts

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { spawn } from 'node:child_process';
2-
import { closeSync, existsSync, openSync } from 'node:fs';
2+
import { closeSync, existsSync, openSync, statSync } from 'node:fs';
33
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
44
import path from 'node:path';
55
import { NextResponse } from 'next/server';
@@ -12,6 +12,12 @@ type AgentationWebhookPayload = {
1212
id?: string;
1313
comment?: string;
1414
sessionId?: string;
15+
timestamp?: number;
16+
url?: string;
17+
element?: string;
18+
elementPath?: string;
19+
nearbyText?: string;
20+
selectedText?: string;
1521
};
1622
timestamp?: number;
1723
url?: string;
@@ -28,8 +34,42 @@ const AUTO_EVENTS = new Set(['annotation.add', 'submit']);
2834
const AUTORUN_DIR = path.join(process.cwd(), '.agentation');
2935
const LOCK_PATH = path.join(AUTORUN_DIR, 'autorun.lock.json');
3036
const LOG_PATH = path.join(AUTORUN_DIR, 'autorun.log');
37+
const AUTORUN_MAX_AGE_MS = Number(
38+
process.env.AGENTATION_AUTORUN_MAX_AGE_MS ?? '120000'
39+
);
40+
const AUTORUN_IDLE_TIMEOUT_MS = Number(
41+
process.env.AGENTATION_AUTORUN_IDLE_TIMEOUT_MS ?? '45000'
42+
);
3143
const DEFAULT_PROMPT =
32-
'watch mode로 계속 처리해줘. Agentation pending annotation을 확인해서 코드 반영, 테스트 검증, resolve 처리까지 완료해줘.';
44+
'방금 등록된 Agentation annotation 한 건을 처리해줘. webhook에 담긴 코멘트와 위치 정보를 기준으로 관련 코드만 최소 수정하고, 필요한 테스트만 검증한 뒤 resolve 처리하고 바로 종료해줘. 브라우저를 새로 열거나 추가 입력을 기다리거나 watch mode로 머물지 말아줘. pending 조회에서 바로 안 보여도 아래 정보를 기준으로 해당 요청을 찾아 처리해줘.';
45+
46+
function buildAutorunPrompt(payload: AgentationWebhookPayload): string {
47+
const annotation = payload.annotation;
48+
const details = [
49+
annotation?.id ? `- webhook annotation id: ${annotation.id}` : null,
50+
annotation?.comment ? `- comment: ${annotation.comment}` : null,
51+
annotation?.sessionId ? `- sessionId: ${annotation.sessionId}` : null,
52+
annotation?.url || payload.url
53+
? `- url: ${annotation?.url ?? payload.url}`
54+
: null,
55+
annotation?.element ? `- element: ${annotation.element}` : null,
56+
annotation?.elementPath ? `- elementPath: ${annotation.elementPath}` : null,
57+
annotation?.nearbyText ? `- nearbyText: ${annotation.nearbyText}` : null,
58+
annotation?.selectedText
59+
? `- selectedText: ${annotation.selectedText}`
60+
: null,
61+
annotation?.timestamp
62+
? `- annotation timestamp: ${annotation.timestamp}`
63+
: null,
64+
payload.timestamp ? `- event timestamp: ${payload.timestamp}` : null,
65+
].filter((value): value is string => Boolean(value));
66+
67+
if (details.length === 0) {
68+
return DEFAULT_PROMPT;
69+
}
70+
71+
return `${DEFAULT_PROMPT}\n\n다음 정보를 참고해줘:\n${details.join('\n')}`;
72+
}
3373

3474
function isPidRunning(pid: number): boolean {
3575
try {
@@ -40,6 +80,24 @@ function isPidRunning(pid: number): boolean {
4080
}
4181
}
4282

83+
function getLockAgeMs(lock: AutorunLock): number {
84+
const startedAt = new Date(lock.startedAt).getTime();
85+
86+
if (Number.isNaN(startedAt)) {
87+
return Number.POSITIVE_INFINITY;
88+
}
89+
90+
return Date.now() - startedAt;
91+
}
92+
93+
function getAutorunIdleMs(): number {
94+
try {
95+
return Date.now() - statSync(LOG_PATH).mtimeMs;
96+
} catch {
97+
return Number.POSITIVE_INFINITY;
98+
}
99+
}
100+
43101
async function readLockFile(): Promise<AutorunLock | null> {
44102
if (!existsSync(LOCK_PATH)) {
45103
return null;
@@ -61,10 +119,21 @@ async function clearStaleLock(lock: AutorunLock | null): Promise<void> {
61119
if (!lock) {
62120
return;
63121
}
64-
if (isPidRunning(lock.pid)) {
122+
123+
const isRunning = isPidRunning(lock.pid);
124+
const isExpired = getLockAgeMs(lock) > AUTORUN_MAX_AGE_MS;
125+
const isIdle = getAutorunIdleMs() > AUTORUN_IDLE_TIMEOUT_MS;
126+
127+
if (isRunning && !isExpired && !isIdle) {
65128
return;
66129
}
67130

131+
if (isRunning && (isExpired || isIdle)) {
132+
try {
133+
process.kill(lock.pid, 'SIGTERM');
134+
} catch {}
135+
}
136+
68137
if (existsSync(LOCK_PATH)) {
69138
await rm(LOCK_PATH, { force: true });
70139
}
@@ -74,7 +143,7 @@ async function writeLockFile(lock: AutorunLock): Promise<void> {
74143
await writeFile(LOCK_PATH, JSON.stringify(lock, null, 2), 'utf8');
75144
}
76145

77-
function runAutorunCommand(annotationId?: string) {
146+
function runAutorunCommand(payload: AgentationWebhookPayload) {
78147
const customCommand = process.env.AGENTATION_AUTORUN_COMMAND?.trim();
79148
const logFd = openSync(LOG_PATH, 'a');
80149
try {
@@ -88,9 +157,7 @@ function runAutorunCommand(annotationId?: string) {
88157
});
89158
}
90159

91-
const autoPrompt = annotationId
92-
? `${DEFAULT_PROMPT} 방금 등록된 annotation id는 ${annotationId}예요.`
93-
: DEFAULT_PROMPT;
160+
const autoPrompt = buildAutorunPrompt(payload);
94161

95162
return spawn(
96163
'codex',
@@ -171,7 +238,7 @@ export async function POST(request: Request) {
171238
});
172239
}
173240

174-
const child = runAutorunCommand(payload.annotation?.id);
241+
const child = runAutorunCommand(payload);
175242
if (!child.pid) {
176243
return NextResponse.json(
177244
{ ok: false, reason: 'failed to spawn autorun process' },
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { describe, expect, it, vi } from 'vitest';
3+
import type { SeriesSummary } from '@/features/blog/model/series-group';
4+
import SeriesHubCard from './SeriesHubCard';
5+
6+
vi.mock('./SeriesTrackedLink', () => ({
7+
default: ({
8+
href,
9+
children,
10+
className,
11+
}: {
12+
href: string;
13+
children: React.ReactNode;
14+
className?: string;
15+
}) => (
16+
<a href={href} className={className}>
17+
{children}
18+
</a>
19+
),
20+
}));
21+
22+
const summary: SeriesSummary = {
23+
id: 'redis-deep-dive',
24+
title: 'Redis 완전정복',
25+
latestDate: '2026-03-10',
26+
firstPostSlug: 'redis-1',
27+
postCount: 2,
28+
totalReadingMinutes: 18,
29+
posts: [
30+
{
31+
slug: 'redis-1',
32+
title: 'Redis 1편',
33+
description: '기초를 다뤄요',
34+
date: '2026-03-01',
35+
category: 'Tech',
36+
tags: ['Redis'],
37+
readingTime: 8,
38+
series: {
39+
id: 'redis-deep-dive',
40+
title: 'Redis 완전정복',
41+
order: 1,
42+
},
43+
},
44+
{
45+
slug: 'redis-2',
46+
title: 'Redis 2편',
47+
description: '심화를 다뤄요',
48+
date: '2026-03-10',
49+
category: 'Tech',
50+
tags: ['Redis'],
51+
readingTime: 10,
52+
series: {
53+
id: 'redis-deep-dive',
54+
title: 'Redis 완전정복',
55+
order: 2,
56+
},
57+
},
58+
],
59+
};
60+
61+
describe('SeriesHubCard', () => {
62+
it('renders series title and links without the redundant series badge', () => {
63+
render(<SeriesHubCard summary={summary} seriesIndex={0} />);
64+
65+
expect(
66+
screen.getByRole('heading', { level: 2, name: 'Redis 완전정복' })
67+
).toBeInTheDocument();
68+
expect(
69+
screen.getByRole('link', { name: / /i })
70+
).toHaveAttribute('href', '/blog/redis-1');
71+
const firstEpisodeLink = screen.getByRole('link', {
72+
name: /1\.?\s*Redis 1/i,
73+
});
74+
75+
expect(firstEpisodeLink).toHaveAttribute('href', '/blog/redis-1');
76+
expect(firstEpisodeLink).toHaveClass('min-h-10', 'py-1.5');
77+
expect(screen.queryByText('Series')).not.toBeInTheDocument();
78+
});
79+
});

src/features/blog/ui/components/SeriesHubCard.tsx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,23 @@ interface SeriesHubCardProps {
77
seriesIndex: number;
88
}
99

10-
export default function SeriesHubCard({ summary, seriesIndex }: SeriesHubCardProps) {
10+
export default function SeriesHubCard({
11+
summary,
12+
seriesIndex,
13+
}: SeriesHubCardProps) {
1114
const metaText = `총 ${summary.postCount}편 · 총 ${summary.totalReadingMinutes}분 · 최근 업데이트 ${formatSeriesDate(summary.latestDate)}`;
1215
const firstPostOrder = summary.posts[0]?.series?.order;
1316

1417
return (
1518
<section className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-bg-primary)] p-6">
1619
<div className="flex flex-wrap items-start justify-between gap-3">
1720
<div>
18-
<span className="inline-flex rounded-full border border-[var(--color-category-series-border)] bg-[var(--color-category-series-bg)] px-2 py-1 text-xs font-medium text-[var(--color-category-series-text)]">
19-
Series
20-
</span>
21-
<h2 className="mt-2 text-xl font-semibold text-[var(--color-text-primary)]">
21+
<h2 className="text-xl font-semibold text-[var(--color-text-primary)]">
2222
{summary.title}
2323
</h2>
24-
<p className="mt-1 text-sm text-[var(--color-text-tertiary)]">{metaText}</p>
24+
<p className="mt-1 text-sm text-[var(--color-text-tertiary)]">
25+
{metaText}
26+
</p>
2527
</div>
2628

2729
{summary.firstPostSlug && (
@@ -55,9 +57,11 @@ export default function SeriesHubCard({ summary, seriesIndex }: SeriesHubCardPro
5557
postSlug={post.slug}
5658
episodeOrder={order}
5759
seriesIndex={seriesIndex}
58-
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)]"
60+
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)]"
5961
>
60-
<span className="w-6 text-[var(--color-text-tertiary)]">{order}.</span>
62+
<span className="w-6 text-[var(--color-text-tertiary)]">
63+
{order}.
64+
</span>
6165
<span className="flex-1 truncate">{post.title}</span>
6266
</SeriesTrackedLink>
6367
</li>

src/shared/devtools/AgentationOverlay.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { Agentation } from 'agentation';
44

5-
const DEFAULT_AGENTATION_ENDPOINT = 'http://localhost:4747';
5+
const DEFAULT_AGENTATION_ENDPOINT = '/api/agentation-sync';
66
const DEFAULT_AGENTATION_WEBHOOK_URL = '/api/agentation/webhook';
77

88
export default function AgentationOverlay() {

0 commit comments

Comments
 (0)