Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 8 additions & 4 deletions docs/guides/agentation-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 서버 등록
Expand Down Expand Up @@ -57,16 +58,19 @@ 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`

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

```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` 명령을 사용해요.
Expand Down
12 changes: 12 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
{
Expand Down
83 changes: 75 additions & 8 deletions src/app/api/agentation/webhook/route.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand All @@ -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 {
Expand All @@ -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<AutorunLock | null> {
if (!existsSync(LOCK_PATH)) {
return null;
Expand All @@ -61,10 +119,21 @@ async function clearStaleLock(lock: AutorunLock | null): Promise<void> {
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compute idle timeout from this run, not prior log mtime

Idle detection is based on autorun.log mtime, but starting a new child with openSync(..., 'a') does not update mtime until the process writes output. If another webhook arrives before the first log write, isIdle can be true immediately because the file still has an old timestamp from a previous run, causing a healthy fresh autorun to be killed and restarted.

Useful? React with 👍 / 👎.


if (isRunning && !isExpired && !isIdle) {
return;
}

if (isRunning && (isExpired || isIdle)) {
try {
process.kill(lock.pid, 'SIGTERM');
} catch {}
Comment on lines +131 to +134

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Verify PID ownership before sending SIGTERM

When a lock is considered expired/idle, this code now unconditionally calls process.kill(lock.pid, 'SIGTERM') based only on the PID stored in .agentation/autorun.lock.json. If that lock survives long enough for PID reuse (e.g., after a crash/restart), a later webhook can terminate an unrelated local process that happens to have the same PID, which is a regression from the previous behavior that never killed running PIDs.

Useful? React with 👍 / 👎.

}

if (existsSync(LOCK_PATH)) {
await rm(LOCK_PATH, { force: true });
}
Expand All @@ -74,7 +143,7 @@ async function writeLockFile(lock: AutorunLock): Promise<void> {
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 {
Expand All @@ -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',
Expand Down Expand Up @@ -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' },
Expand Down
79 changes: 79 additions & 0 deletions src/features/blog/ui/components/SeriesHubCard.test.tsx
Original file line number Diff line number Diff line change
@@ -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;
}) => (
<a href={href} className={className}>
{children}
</a>
),
}));

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(<SeriesHubCard summary={summary} seriesIndex={0} />);

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();
});
});
20 changes: 12 additions & 8 deletions src/features/blog/ui/components/SeriesHubCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,23 @@ 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;

return (
<section className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-bg-primary)] p-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<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)]">
Series
</span>
<h2 className="mt-2 text-xl font-semibold text-[var(--color-text-primary)]">
<h2 className="text-xl font-semibold text-[var(--color-text-primary)]">
{summary.title}
</h2>
<p className="mt-1 text-sm text-[var(--color-text-tertiary)]">{metaText}</p>
<p className="mt-1 text-sm text-[var(--color-text-tertiary)]">
{metaText}
</p>
</div>

{summary.firstPostSlug && (
Expand Down Expand Up @@ -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)]"
>
<span className="w-6 text-[var(--color-text-tertiary)]">{order}.</span>
<span className="w-6 text-[var(--color-text-tertiary)]">
{order}.
</span>
<span className="flex-1 truncate">{post.title}</span>
</SeriesTrackedLink>
</li>
Expand Down
2 changes: 1 addition & 1 deletion src/shared/devtools/AgentationOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading