-
Notifications
You must be signed in to change notification settings - Fork 0
refactor(infra): Agentation 흐름과 시리즈 카드 정리 #82
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'; | ||
|
|
@@ -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<AutorunLock | null> { | ||
| if (!existsSync(LOCK_PATH)) { | ||
| return null; | ||
|
|
@@ -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; | ||
|
|
||
| if (isRunning && !isExpired && !isIdle) { | ||
| return; | ||
| } | ||
|
|
||
| if (isRunning && (isExpired || isIdle)) { | ||
| try { | ||
| process.kill(lock.pid, 'SIGTERM'); | ||
| } catch {} | ||
|
Comment on lines
+131
to
+134
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a lock is considered expired/idle, this code now unconditionally calls Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| if (existsSync(LOCK_PATH)) { | ||
| await rm(LOCK_PATH, { force: true }); | ||
| } | ||
|
|
@@ -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 { | ||
|
|
@@ -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' }, | ||
|
|
||
| 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(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Idle detection is based on
autorun.logmtime, but starting a new child withopenSync(..., 'a')does not update mtime until the process writes output. If another webhook arrives before the first log write,isIdlecan 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 👍 / 👎.