@elyun/bylane 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +73 -0
- package/README.md +186 -0
- package/commands/bylane-monitor.md +43 -0
- package/commands/bylane.md +61 -0
- package/docs/superpowers/plans/2026-04-04-bylane-implementation.md +1821 -0
- package/docs/superpowers/specs/2026-04-04-bylane-design.md +324 -0
- package/hooks/post-tool-use.md +40 -0
- package/package.json +22 -0
- package/skills/code-agent.md +69 -0
- package/skills/commit-agent.md +85 -0
- package/skills/issue-agent.md +91 -0
- package/skills/notify-agent.md +75 -0
- package/skills/orchestrator.md +70 -0
- package/skills/pr-agent.md +73 -0
- package/skills/respond-agent.md +55 -0
- package/skills/review-agent.md +65 -0
- package/skills/setup.md +101 -0
- package/skills/test-agent.md +63 -0
- package/src/branch.js +31 -0
- package/src/cli.js +67 -0
- package/src/config.js +86 -0
- package/src/monitor/index.js +26 -0
- package/src/monitor/layout.js +37 -0
- package/src/monitor/panels/header.js +24 -0
- package/src/monitor/panels/log.js +39 -0
- package/src/monitor/panels/pipeline.js +50 -0
- package/src/monitor/panels/queue.js +36 -0
- package/src/monitor/panels/status.js +35 -0
- package/src/monitor/poller.js +28 -0
- package/src/state.js +44 -0
- package/tests/branch.test.js +55 -0
- package/tests/config.test.js +55 -0
- package/tests/state.test.js +59 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createLayout } from './layout.js'
|
|
3
|
+
import { createPoller } from './poller.js'
|
|
4
|
+
|
|
5
|
+
const { screen, header, pipeline, log, queue, status, onCleanup } = createLayout()
|
|
6
|
+
const poller = createPoller()
|
|
7
|
+
|
|
8
|
+
let startTime = Date.now()
|
|
9
|
+
|
|
10
|
+
const clockInterval = setInterval(() => {
|
|
11
|
+
header.update({
|
|
12
|
+
time: new Date().toLocaleTimeString('ko-KR', { hour12: false }),
|
|
13
|
+
elapsed: `${Math.floor((Date.now() - startTime) / 1000)}s`
|
|
14
|
+
})
|
|
15
|
+
}, 1000)
|
|
16
|
+
|
|
17
|
+
onCleanup(() => poller.stop())
|
|
18
|
+
|
|
19
|
+
poller.onChange((states) => {
|
|
20
|
+
pipeline.update(states)
|
|
21
|
+
log.update(states)
|
|
22
|
+
queue.update()
|
|
23
|
+
status.update()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
screen.render()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import blessed from 'blessed'
|
|
2
|
+
import { createHeader } from './panels/header.js'
|
|
3
|
+
import { createPipelinePanel } from './panels/pipeline.js'
|
|
4
|
+
import { createLogPanel } from './panels/log.js'
|
|
5
|
+
import { createQueuePanel } from './panels/queue.js'
|
|
6
|
+
import { createStatusPanel } from './panels/status.js'
|
|
7
|
+
|
|
8
|
+
export function createLayout() {
|
|
9
|
+
const screen = blessed.screen({
|
|
10
|
+
smartCSR: true,
|
|
11
|
+
title: 'byLane Monitor'
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const header = createHeader(screen)
|
|
15
|
+
const pipeline = createPipelinePanel(screen)
|
|
16
|
+
const log = createLogPanel(screen)
|
|
17
|
+
const queue = createQueuePanel(screen)
|
|
18
|
+
const status = createStatusPanel(screen)
|
|
19
|
+
|
|
20
|
+
const footer = blessed.box({
|
|
21
|
+
bottom: 0,
|
|
22
|
+
left: 0,
|
|
23
|
+
width: '100%',
|
|
24
|
+
height: 1,
|
|
25
|
+
content: ' [q]종료 [p]일시정지 [c]작업취소 [Tab]포커스 [↑↓]로그스크롤 [?]도움말',
|
|
26
|
+
style: { fg: 'black', bg: 'cyan' }
|
|
27
|
+
})
|
|
28
|
+
screen.append(footer)
|
|
29
|
+
|
|
30
|
+
let cleanupFn = null
|
|
31
|
+
screen.key(['q', 'C-c'], () => {
|
|
32
|
+
if (cleanupFn) cleanupFn()
|
|
33
|
+
process.exit(0)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
return { screen, header, pipeline, log, queue, status, onCleanup(fn) { cleanupFn = fn } }
|
|
37
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import blessed from 'blessed'
|
|
2
|
+
|
|
3
|
+
export function createHeader(screen) {
|
|
4
|
+
const box = blessed.box({
|
|
5
|
+
top: 0,
|
|
6
|
+
left: 0,
|
|
7
|
+
width: '100%',
|
|
8
|
+
height: 3,
|
|
9
|
+
content: ' byLane Monitor Idle --:--:--',
|
|
10
|
+
tags: true,
|
|
11
|
+
border: { type: 'line' },
|
|
12
|
+
style: { fg: 'white', bg: 'blue', border: { fg: 'cyan' } }
|
|
13
|
+
})
|
|
14
|
+
screen.append(box)
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
update({ workflowTitle, elapsed, time }) {
|
|
18
|
+
const title = workflowTitle ?? 'Idle'
|
|
19
|
+
const elapsedStr = elapsed ?? ''
|
|
20
|
+
box.setContent(` {bold}byLane Monitor{/bold} ${title} ${elapsedStr} ${time}`)
|
|
21
|
+
screen.render()
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import blessed from 'blessed'
|
|
2
|
+
|
|
3
|
+
export function createLogPanel(screen) {
|
|
4
|
+
const box = blessed.box({
|
|
5
|
+
top: 3,
|
|
6
|
+
left: '50%',
|
|
7
|
+
width: '50%',
|
|
8
|
+
height: '60%-3',
|
|
9
|
+
label: ' AGENT LOG [LIVE] ',
|
|
10
|
+
tags: true,
|
|
11
|
+
scrollable: true,
|
|
12
|
+
alwaysScroll: true,
|
|
13
|
+
border: { type: 'line' },
|
|
14
|
+
style: { border: { fg: 'cyan' } },
|
|
15
|
+
keys: true,
|
|
16
|
+
vi: true
|
|
17
|
+
})
|
|
18
|
+
screen.append(box)
|
|
19
|
+
const lines = []
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
update(states) {
|
|
23
|
+
const newLines = []
|
|
24
|
+
for (const state of Object.values(states)) {
|
|
25
|
+
for (const entry of (state.log ?? []).slice(-5)) {
|
|
26
|
+
const ts = new Date(entry.ts).toLocaleTimeString('ko-KR', { hour12: false })
|
|
27
|
+
newLines.push(` ${ts} {cyan-fg}${state.agent}{/cyan-fg}`)
|
|
28
|
+
newLines.push(` → ${entry.msg}`)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const all = [...lines, ...newLines].slice(-50)
|
|
32
|
+
lines.length = 0
|
|
33
|
+
lines.push(...all)
|
|
34
|
+
box.setContent(lines.join('\n'))
|
|
35
|
+
box.scrollTo(lines.length)
|
|
36
|
+
screen.render()
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import blessed from 'blessed'
|
|
2
|
+
|
|
3
|
+
const AGENTS = [
|
|
4
|
+
'orchestrator', 'issue-agent', 'code-agent', 'test-agent',
|
|
5
|
+
'commit-agent', 'pr-agent', 'review-agent', 'respond-agent', 'notify-agent'
|
|
6
|
+
]
|
|
7
|
+
|
|
8
|
+
const STATUS_ICON = {
|
|
9
|
+
idle: '[○]',
|
|
10
|
+
in_progress: '[▶]',
|
|
11
|
+
completed: '[✓]',
|
|
12
|
+
failed: '[✗]',
|
|
13
|
+
escalated: '[!]'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createPipelinePanel(screen) {
|
|
17
|
+
const box = blessed.box({
|
|
18
|
+
top: 3,
|
|
19
|
+
left: 0,
|
|
20
|
+
width: '50%',
|
|
21
|
+
height: '60%-3',
|
|
22
|
+
label: ' AGENT PIPELINE ',
|
|
23
|
+
tags: true,
|
|
24
|
+
border: { type: 'line' },
|
|
25
|
+
style: { border: { fg: 'cyan' } }
|
|
26
|
+
})
|
|
27
|
+
screen.append(box)
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
update(states) {
|
|
31
|
+
const lines = AGENTS.map(name => {
|
|
32
|
+
const s = states[name]
|
|
33
|
+
if (!s) return ` ${STATUS_ICON.idle} ${name.padEnd(16)} 대기`
|
|
34
|
+
const icon = STATUS_ICON[s.status] ?? STATUS_ICON.idle
|
|
35
|
+
const elapsed = s.startedAt
|
|
36
|
+
? `${Math.floor((Date.now() - new Date(s.startedAt)) / 1000)}s`
|
|
37
|
+
: ''
|
|
38
|
+
const bar = s.progress > 0
|
|
39
|
+
? `${'█'.repeat(Math.floor(s.progress / 10))}${'░'.repeat(10 - Math.floor(s.progress / 10))} ${s.progress}%`
|
|
40
|
+
: ''
|
|
41
|
+
return ` ${icon} ${name.padEnd(16)} ${elapsed.padEnd(6)} ${bar}`
|
|
42
|
+
})
|
|
43
|
+
const retries = states['orchestrator']?.retries ?? 0
|
|
44
|
+
const maxRetries = states['orchestrator']?.maxRetries ?? 3
|
|
45
|
+
lines.push('', ` Retries: ${retries}/${maxRetries}`)
|
|
46
|
+
box.setContent(lines.join('\n'))
|
|
47
|
+
screen.render()
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import blessed from 'blessed'
|
|
2
|
+
import { existsSync, readFileSync } from 'fs'
|
|
3
|
+
|
|
4
|
+
export function createQueuePanel(screen) {
|
|
5
|
+
const box = blessed.box({
|
|
6
|
+
top: '60%',
|
|
7
|
+
left: 0,
|
|
8
|
+
width: '50%',
|
|
9
|
+
height: '40%',
|
|
10
|
+
label: ' QUEUE ',
|
|
11
|
+
tags: true,
|
|
12
|
+
border: { type: 'line' },
|
|
13
|
+
style: { border: { fg: 'cyan' } }
|
|
14
|
+
})
|
|
15
|
+
screen.append(box)
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
update() {
|
|
19
|
+
const queuePath = '.bylane/queue.json'
|
|
20
|
+
let queue = []
|
|
21
|
+
if (existsSync(queuePath)) {
|
|
22
|
+
try {
|
|
23
|
+
queue = JSON.parse(readFileSync(queuePath, 'utf8'))
|
|
24
|
+
} catch {
|
|
25
|
+
queue = []
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const header = ` ${'#'.padEnd(3)} ${'TYPE'.padEnd(12)} ${'TARGET'.padEnd(10)} STATUS`
|
|
29
|
+
const rows = queue.slice(0, 8).map((item, i) =>
|
|
30
|
+
` ${String(i + 1).padEnd(3)} ${(item.type ?? '').padEnd(12)} ${(item.target ?? '').padEnd(10)} ${item.status ?? ''}`
|
|
31
|
+
)
|
|
32
|
+
box.setContent([header, ...rows].join('\n'))
|
|
33
|
+
screen.render()
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import blessed from 'blessed'
|
|
2
|
+
import { loadConfig } from '../../config.js'
|
|
3
|
+
|
|
4
|
+
export function createStatusPanel(screen) {
|
|
5
|
+
const box = blessed.box({
|
|
6
|
+
top: '60%',
|
|
7
|
+
left: '50%',
|
|
8
|
+
width: '50%',
|
|
9
|
+
height: '40%',
|
|
10
|
+
label: ' SYSTEM STATUS ',
|
|
11
|
+
tags: true,
|
|
12
|
+
border: { type: 'line' },
|
|
13
|
+
style: { border: { fg: 'cyan' } }
|
|
14
|
+
})
|
|
15
|
+
screen.append(box)
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
update() {
|
|
19
|
+
const config = loadConfig()
|
|
20
|
+
const check = (v) => v ? '{green-fg}✓{/green-fg}' : '{red-fg}✗{/red-fg}'
|
|
21
|
+
const lines = [
|
|
22
|
+
` GitHub ${check(true)} 연결됨`,
|
|
23
|
+
` Linear ${check(config.trackers.linear.enabled)} ${config.trackers.linear.enabled ? '활성' : '비활성'}`,
|
|
24
|
+
` Figma MCP ${check(config.extensions.figma.enabled)} ${config.extensions.figma.enabled ? '활성' : '비활성'}`,
|
|
25
|
+
` Slack ${check(config.notifications.slack.enabled)} ${config.notifications.slack.channel || '미설정'}`,
|
|
26
|
+
` Telegram ${check(config.notifications.telegram.enabled)} ${config.notifications.telegram.chatId || '미설정'}`,
|
|
27
|
+
``,
|
|
28
|
+
` 팀 모드 ${check(config.team.enabled)} ${config.team.enabled ? `활성 (${config.team.members.length}명)` : '비활성'}`,
|
|
29
|
+
` 권한 범위 ${config.permissions.scope}`
|
|
30
|
+
]
|
|
31
|
+
box.setContent(lines.join('\n'))
|
|
32
|
+
screen.render()
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { watch } from 'chokidar'
|
|
2
|
+
import { listStates } from '../state.js'
|
|
3
|
+
|
|
4
|
+
export function createPoller(stateDir = '.bylane/state', intervalMs = 1000) {
|
|
5
|
+
const callbacks = new Set()
|
|
6
|
+
|
|
7
|
+
const emit = () => {
|
|
8
|
+
const states = {}
|
|
9
|
+
for (const s of listStates(stateDir)) {
|
|
10
|
+
states[s.agent] = s
|
|
11
|
+
}
|
|
12
|
+
for (const cb of callbacks) cb(states)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const watcher = watch(`${stateDir}`, { persistent: true, ignoreInitial: false })
|
|
16
|
+
watcher.on('change', emit)
|
|
17
|
+
watcher.on('add', emit)
|
|
18
|
+
|
|
19
|
+
const interval = setInterval(emit, intervalMs)
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
onChange(cb) { callbacks.add(cb) },
|
|
23
|
+
stop() {
|
|
24
|
+
clearInterval(interval)
|
|
25
|
+
watcher.close()
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/state.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, unlinkSync, readdirSync, existsSync, mkdirSync } from 'fs'
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_DIR = '.bylane/state'
|
|
5
|
+
|
|
6
|
+
export function writeState(agentName, data, dir = DEFAULT_DIR) {
|
|
7
|
+
mkdirSync(dir, { recursive: true })
|
|
8
|
+
const payload = {
|
|
9
|
+
...data,
|
|
10
|
+
agent: agentName,
|
|
11
|
+
updatedAt: new Date().toISOString(),
|
|
12
|
+
log: data.log ?? []
|
|
13
|
+
}
|
|
14
|
+
writeFileSync(join(dir, `${agentName}.json`), JSON.stringify(payload, null, 2))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function readState(agentName, dir = DEFAULT_DIR) {
|
|
18
|
+
const path = join(dir, `${agentName}.json`)
|
|
19
|
+
if (!existsSync(path)) return null
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
22
|
+
} catch {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function clearState(agentName, dir = DEFAULT_DIR) {
|
|
28
|
+
const path = join(dir, `${agentName}.json`)
|
|
29
|
+
if (existsSync(path)) unlinkSync(path)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function listStates(dir = DEFAULT_DIR) {
|
|
33
|
+
if (!existsSync(dir)) return []
|
|
34
|
+
return readdirSync(dir)
|
|
35
|
+
.filter(f => f.endsWith('.json'))
|
|
36
|
+
.map(f => readState(f.replace('.json', ''), dir))
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function appendLog(agentName, message, dir = DEFAULT_DIR) {
|
|
41
|
+
const state = readState(agentName, dir) ?? { agent: agentName, status: 'idle', log: [] }
|
|
42
|
+
const entry = { ts: new Date().toISOString(), msg: message }
|
|
43
|
+
writeState(agentName, { ...state, log: [...(state.log ?? []), entry] }, dir)
|
|
44
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildBranchName } from '../src/branch.js'
|
|
3
|
+
|
|
4
|
+
describe('buildBranchName', () => {
|
|
5
|
+
it('{tracker}-{issue-number} 패턴 기본 케이스', () => {
|
|
6
|
+
const result = buildBranchName(
|
|
7
|
+
'{tracker}-{issue-number}',
|
|
8
|
+
{ tracker: 'issues', 'issue-number': '32' }
|
|
9
|
+
)
|
|
10
|
+
expect(result).toBe('issues-32')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('{custom-id}가 비어있으면 해당 토큰과 앞 구분자를 제외한다', () => {
|
|
14
|
+
const result = buildBranchName(
|
|
15
|
+
'{tracker}-{issue-number}-{custom-id}',
|
|
16
|
+
{ tracker: 'issues', 'issue-number': '32', 'custom-id': '' }
|
|
17
|
+
)
|
|
18
|
+
expect(result).toBe('issues-32')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('{custom-id}가 있으면 포함한다', () => {
|
|
22
|
+
const result = buildBranchName(
|
|
23
|
+
'{tracker}-{issue-number}-{custom-id}',
|
|
24
|
+
{ tracker: 'issues', 'issue-number': '32', 'custom-id': 'C-12' }
|
|
25
|
+
)
|
|
26
|
+
expect(result).toBe('issues-32-C-12')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('{type}/{issue-number}-{title-slug} 패턴', () => {
|
|
30
|
+
const result = buildBranchName(
|
|
31
|
+
'{type}/{issue-number}-{title-slug}',
|
|
32
|
+
{ type: 'feature', 'issue-number': '32', 'title-slug': 'add-dark-mode' }
|
|
33
|
+
)
|
|
34
|
+
expect(result).toBe('feature/32-add-dark-mode')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('kebab-case 변환: 공백을 하이픈으로', () => {
|
|
38
|
+
const result = buildBranchName(
|
|
39
|
+
'{type}-{title-slug}',
|
|
40
|
+
{ type: 'feat', 'title-slug': 'Add Dark Mode' },
|
|
41
|
+
'kebab-case'
|
|
42
|
+
)
|
|
43
|
+
expect(result).toBe('feat-add-dark-mode')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('슬래시 패턴에서 type이 비어있으면 선행 슬래시 없이 반환한다', () => {
|
|
47
|
+
const result = buildBranchName(
|
|
48
|
+
'{type}/{issue-number}',
|
|
49
|
+
{ type: '', 'issue-number': '32' },
|
|
50
|
+
'kebab-case'
|
|
51
|
+
)
|
|
52
|
+
expect(result).toBe('32')
|
|
53
|
+
expect(result.startsWith('/')).toBe(false)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { loadConfig, saveConfig, validateConfig, DEFAULT_CONFIG } from '../src/config.js'
|
|
3
|
+
import { mkdirSync, rmSync, writeFileSync } from 'fs'
|
|
4
|
+
|
|
5
|
+
const TEST_DIR = '.bylane-config-test'
|
|
6
|
+
|
|
7
|
+
beforeEach(() => mkdirSync(TEST_DIR, { recursive: true }))
|
|
8
|
+
afterEach(() => rmSync(TEST_DIR, { recursive: true, force: true }))
|
|
9
|
+
|
|
10
|
+
describe('DEFAULT_CONFIG', () => {
|
|
11
|
+
it('기본 maxRetries는 3이다', () => {
|
|
12
|
+
expect(DEFAULT_CONFIG.workflow.maxRetries).toBe(3)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('기본 primary tracker는 github이다', () => {
|
|
16
|
+
expect(DEFAULT_CONFIG.trackers.primary).toBe('github')
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('loadConfig', () => {
|
|
21
|
+
it('파일이 없으면 DEFAULT_CONFIG를 반환한다', () => {
|
|
22
|
+
const config = loadConfig(TEST_DIR)
|
|
23
|
+
expect(config.workflow.maxRetries).toBe(3)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('존재하는 설정 파일을 로드한다', () => {
|
|
27
|
+
writeFileSync(`${TEST_DIR}/bylane.json`, JSON.stringify({
|
|
28
|
+
...DEFAULT_CONFIG,
|
|
29
|
+
workflow: { ...DEFAULT_CONFIG.workflow, maxRetries: 5 }
|
|
30
|
+
}))
|
|
31
|
+
const config = loadConfig(TEST_DIR)
|
|
32
|
+
expect(config.workflow.maxRetries).toBe(5)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('saveConfig', () => {
|
|
37
|
+
it('설정을 bylane.json에 저장한다', () => {
|
|
38
|
+
saveConfig({ ...DEFAULT_CONFIG }, TEST_DIR)
|
|
39
|
+
const loaded = loadConfig(TEST_DIR)
|
|
40
|
+
expect(loaded.version).toBe('1.0')
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('validateConfig', () => {
|
|
45
|
+
it('유효한 설정은 에러가 없다', () => {
|
|
46
|
+
const errors = validateConfig(DEFAULT_CONFIG)
|
|
47
|
+
expect(errors).toHaveLength(0)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('maxRetries가 숫자가 아니면 에러를 반환한다', () => {
|
|
51
|
+
const bad = { ...DEFAULT_CONFIG, workflow: { ...DEFAULT_CONFIG.workflow, maxRetries: 'abc' } }
|
|
52
|
+
const errors = validateConfig(bad)
|
|
53
|
+
expect(errors.length).toBeGreaterThan(0)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { readState, writeState, clearState, listStates, appendLog } from '../src/state.js'
|
|
3
|
+
import { mkdirSync, rmSync } from 'fs'
|
|
4
|
+
|
|
5
|
+
const TEST_DIR = '.bylane-test/state'
|
|
6
|
+
|
|
7
|
+
beforeEach(() => mkdirSync(TEST_DIR, { recursive: true }))
|
|
8
|
+
afterEach(() => rmSync('.bylane-test', { recursive: true, force: true }))
|
|
9
|
+
|
|
10
|
+
describe('writeState', () => {
|
|
11
|
+
it('에이전트 상태를 JSON 파일에 저장한다', () => {
|
|
12
|
+
writeState('code-agent', { status: 'in_progress', progress: 50 }, TEST_DIR)
|
|
13
|
+
const result = readState('code-agent', TEST_DIR)
|
|
14
|
+
expect(result.status).toBe('in_progress')
|
|
15
|
+
expect(result.progress).toBe(50)
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe('readState', () => {
|
|
20
|
+
it('존재하지 않는 에이전트는 null을 반환한다', () => {
|
|
21
|
+
const result = readState('nonexistent', TEST_DIR)
|
|
22
|
+
expect(result).toBeNull()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('저장된 상태에 agent 이름과 updatedAt이 포함된다', () => {
|
|
26
|
+
writeState('issue-agent', { status: 'completed' }, TEST_DIR)
|
|
27
|
+
const result = readState('issue-agent', TEST_DIR)
|
|
28
|
+
expect(result.agent).toBe('issue-agent')
|
|
29
|
+
expect(result.updatedAt).toBeDefined()
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('clearState', () => {
|
|
34
|
+
it('특정 에이전트 상태 파일을 삭제한다', () => {
|
|
35
|
+
writeState('pr-agent', { status: 'idle' }, TEST_DIR)
|
|
36
|
+
clearState('pr-agent', TEST_DIR)
|
|
37
|
+
expect(readState('pr-agent', TEST_DIR)).toBeNull()
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('listStates', () => {
|
|
42
|
+
it('모든 에이전트 상태 목록을 반환한다', () => {
|
|
43
|
+
writeState('code-agent', { status: 'completed' }, TEST_DIR)
|
|
44
|
+
writeState('test-agent', { status: 'idle' }, TEST_DIR)
|
|
45
|
+
const list = listStates(TEST_DIR)
|
|
46
|
+
expect(list).toHaveLength(2)
|
|
47
|
+
expect(list.map(s => s.agent)).toContain('code-agent')
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('appendLog', () => {
|
|
52
|
+
it('로그 항목을 state에 추가한다', () => {
|
|
53
|
+
writeState('code-agent', { status: 'in_progress', log: [] }, TEST_DIR)
|
|
54
|
+
appendLog('code-agent', 'ThemeToggle.tsx 생성', TEST_DIR)
|
|
55
|
+
const result = readState('code-agent', TEST_DIR)
|
|
56
|
+
expect(result.log).toHaveLength(1)
|
|
57
|
+
expect(result.log[0].msg).toBe('ThemeToggle.tsx 생성')
|
|
58
|
+
})
|
|
59
|
+
})
|