feat(bridge): Discord 봇 — 슬래시 커맨드 트리거 + 실시간 알림
v0.1.4 — 옵션 3 (outbound + inbound). DISCORD_TOKEN 이 설정되지 않으면
bridge 는 no-op 이라 기존 배포는 영향 없음.
## Outbound (rails → Discord)
- runner.ts: PipelineLifecycleEvent emitter 추가
started / stage-done / stage-failed / completed / failed / escalated
- DiscordNotifier: 이벤트 → Discord 메시지 렌더링
thread mode (슬래시 커맨드 트리거) vs channel mode (CLI/HTTP 트리거)
- EscalationNotifier 인터페이스도 구현 — escalate.ts 에서 사용자에게 알림
- runPipeline opts 에 onEvent + notifier 주입
## Inbound (Discord → rails)
- /rails start project:<name> requirements:<text> — 파이프라인 기동
→ defer reply → POST /pipelines/start-async → thread 생성 → 실시간 업데이트
- /rails status <id> — 상태 조회 (ephemeral)
- /rails abort <id> — 강제 종료 (ephemeral)
## Async start 엔드포인트
- POST /pipelines/start-async: pipelineId 즉시 리턴 후 background 에서
runPipeline 실행. Discord 의 3초 ACK 타임아웃을 회피.
- runPipeline 에 opts.pipelineId 지원: async 엔드포인트가 미리 만든
row 위에 파이프라인을 그대로 얹을 수 있게.
## 부트스트랩
- rails serve 가 DISCORD_TOKEN/GUILD_ID/NOTIFY_CHANNEL_ID 세 개가 모두
있으면 DiscordBridge 를 자동 시작. 없으면 "skip" 로그 남기고 무시.
- discord.js ^14.26 의존성 추가.
## 문서/테스트
- .env.example: Discord 섹션 전면 재작성 (동작 설명 포함)
- tests/discord-notifier.test.ts (7 tests): fake DiscordClientWrapper 로
라우팅/렌더링/바인딩 해제 로직 검증
- 총 111 → 118 테스트 통과
This commit is contained in:
17
.env.example
17
.env.example
@@ -117,6 +117,23 @@ RAILS_API_URL=http://127.0.0.1:18800
|
||||
|
||||
|
||||
# ==== 5. OPTIONAL — Discord bridge ====================================
|
||||
#
|
||||
# When all three DISCORD_* vars are set, `rails serve` starts a Discord
|
||||
# bot that:
|
||||
#
|
||||
# - Registers a /rails slash command tree in the target guild
|
||||
# - Accepts `/rails start project:<name> requirements:<text>` to kick
|
||||
# off pipelines from Discord
|
||||
# - Opens a public thread under DISCORD_NOTIFY_CHANNEL_ID for each
|
||||
# pipeline it starts, and posts real-time updates there
|
||||
# - Posts escalations (retry exhausted, fatal error) to the notify
|
||||
# channel as top-level messages
|
||||
# - /rails status <id> and /rails abort <id> are also available
|
||||
#
|
||||
# Leave any of the three empty and the bridge is a no-op. Pipelines still
|
||||
# run; they just don't ping Discord.
|
||||
|
||||
# DISCORD_TOKEN=
|
||||
# DISCORD_GUILD_ID=
|
||||
# DISCORD_NOTIFY_CHANNEL_ID=
|
||||
# GITEA_WEBHOOK_SECRET=
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.6.0",
|
||||
"citty": "^0.1.6",
|
||||
"discord.js": "^14.26.2",
|
||||
"neverthrow": "^8.2.0",
|
||||
"pino": "^9.6.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
|
||||
198
pnpm-lock.yaml
generated
198
pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
||||
citty:
|
||||
specifier: ^0.1.6
|
||||
version: 0.1.6
|
||||
discord.js:
|
||||
specifier: ^14.26.2
|
||||
version: 14.26.2
|
||||
neverthrow:
|
||||
specifier: ^8.2.0
|
||||
version: 8.2.0
|
||||
@@ -51,6 +54,34 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@discordjs/builders@1.14.1':
|
||||
resolution: {integrity: sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==}
|
||||
engines: {node: '>=16.11.0'}
|
||||
|
||||
'@discordjs/collection@1.5.3':
|
||||
resolution: {integrity: sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==}
|
||||
engines: {node: '>=16.11.0'}
|
||||
|
||||
'@discordjs/collection@2.1.1':
|
||||
resolution: {integrity: sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@discordjs/formatters@0.6.2':
|
||||
resolution: {integrity: sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==}
|
||||
engines: {node: '>=16.11.0'}
|
||||
|
||||
'@discordjs/rest@2.6.1':
|
||||
resolution: {integrity: sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@discordjs/util@1.2.0':
|
||||
resolution: {integrity: sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@discordjs/ws@1.2.3':
|
||||
resolution: {integrity: sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==}
|
||||
engines: {node: '>=16.11.0'}
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.7':
|
||||
resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -368,6 +399,22 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@sapphire/async-queue@1.5.5':
|
||||
resolution: {integrity: sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==}
|
||||
engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
|
||||
|
||||
'@sapphire/shapeshift@4.0.0':
|
||||
resolution: {integrity: sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==}
|
||||
engines: {node: '>=v16'}
|
||||
|
||||
'@sapphire/snowflake@3.5.3':
|
||||
resolution: {integrity: sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==}
|
||||
engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
|
||||
|
||||
'@sapphire/snowflake@3.5.5':
|
||||
resolution: {integrity: sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==}
|
||||
engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
@@ -383,6 +430,9 @@ packages:
|
||||
'@types/node@22.19.17':
|
||||
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@vitest/expect@3.2.4':
|
||||
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
|
||||
|
||||
@@ -412,6 +462,10 @@ packages:
|
||||
'@vitest/utils@3.2.4':
|
||||
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
|
||||
|
||||
'@vladfrangu/async_event_emitter@2.4.7':
|
||||
resolution: {integrity: sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==}
|
||||
engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -486,6 +540,13 @@ packages:
|
||||
destr@2.0.5:
|
||||
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
||||
|
||||
discord-api-types@0.38.45:
|
||||
resolution: {integrity: sha512-DiI01i00FPv6n+hXcFkFxK8Y/rFRpKs6U6aP32N4T73nTbj37Eua3H/95TBpLktLWB6xnLXhYDGvyLq6zzYY2w==}
|
||||
|
||||
discord.js@14.26.2:
|
||||
resolution: {integrity: sha512-feShi+gULJ6R2MAA4/KkCFnkJcuVrROJrKk4czplzq8gE1oqhqgOy9K0Scu44B8oGeWKe04egquzf+ia6VtXAw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
dotenv@16.6.1:
|
||||
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -525,6 +586,9 @@ packages:
|
||||
fast-copy@4.0.2:
|
||||
resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
fast-safe-stringify@2.1.1:
|
||||
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
|
||||
|
||||
@@ -560,9 +624,18 @@ packages:
|
||||
js-tokens@9.0.1:
|
||||
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
|
||||
|
||||
lodash.snakecase@4.1.1:
|
||||
resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==}
|
||||
|
||||
lodash@4.18.1:
|
||||
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
||||
|
||||
loupe@3.2.1:
|
||||
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||
|
||||
magic-bytes.js@1.13.0:
|
||||
resolution: {integrity: sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
@@ -741,6 +814,12 @@ packages:
|
||||
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
ts-mixer@6.0.4:
|
||||
resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
@@ -753,6 +832,10 @@ packages:
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
undici@6.24.1:
|
||||
resolution: {integrity: sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==}
|
||||
engines: {node: '>=18.17'}
|
||||
|
||||
vite-node@3.2.4:
|
||||
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
|
||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||
@@ -834,6 +917,18 @@ packages:
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
ws@8.20.0:
|
||||
resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
xstate@5.30.0:
|
||||
resolution: {integrity: sha512-mIzIuMjtYVkqXq9dUzYQoag7b/dF1CBS/yhliuPLfR0FwKPC18HiUivb/crcqY2gknhR8gJEhnppLg6ubQ0gGw==}
|
||||
|
||||
@@ -847,6 +942,55 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@discordjs/builders@1.14.1':
|
||||
dependencies:
|
||||
'@discordjs/formatters': 0.6.2
|
||||
'@discordjs/util': 1.2.0
|
||||
'@sapphire/shapeshift': 4.0.0
|
||||
discord-api-types: 0.38.45
|
||||
fast-deep-equal: 3.1.3
|
||||
ts-mixer: 6.0.4
|
||||
tslib: 2.8.1
|
||||
|
||||
'@discordjs/collection@1.5.3': {}
|
||||
|
||||
'@discordjs/collection@2.1.1': {}
|
||||
|
||||
'@discordjs/formatters@0.6.2':
|
||||
dependencies:
|
||||
discord-api-types: 0.38.45
|
||||
|
||||
'@discordjs/rest@2.6.1':
|
||||
dependencies:
|
||||
'@discordjs/collection': 2.1.1
|
||||
'@discordjs/util': 1.2.0
|
||||
'@sapphire/async-queue': 1.5.5
|
||||
'@sapphire/snowflake': 3.5.5
|
||||
'@vladfrangu/async_event_emitter': 2.4.7
|
||||
discord-api-types: 0.38.45
|
||||
magic-bytes.js: 1.13.0
|
||||
tslib: 2.8.1
|
||||
undici: 6.24.1
|
||||
|
||||
'@discordjs/util@1.2.0':
|
||||
dependencies:
|
||||
discord-api-types: 0.38.45
|
||||
|
||||
'@discordjs/ws@1.2.3':
|
||||
dependencies:
|
||||
'@discordjs/collection': 2.1.1
|
||||
'@discordjs/rest': 2.6.1
|
||||
'@discordjs/util': 1.2.0
|
||||
'@sapphire/async-queue': 1.5.5
|
||||
'@types/ws': 8.18.1
|
||||
'@vladfrangu/async_event_emitter': 2.4.7
|
||||
discord-api-types: 0.38.45
|
||||
tslib: 2.8.1
|
||||
ws: 8.20.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.7':
|
||||
optional: true
|
||||
|
||||
@@ -1039,6 +1183,17 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.60.1':
|
||||
optional: true
|
||||
|
||||
'@sapphire/async-queue@1.5.5': {}
|
||||
|
||||
'@sapphire/shapeshift@4.0.0':
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
lodash: 4.18.1
|
||||
|
||||
'@sapphire/snowflake@3.5.3': {}
|
||||
|
||||
'@sapphire/snowflake@3.5.5': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
@@ -1054,6 +1209,10 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 22.19.17
|
||||
|
||||
'@vitest/expect@3.2.4':
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
@@ -1096,6 +1255,8 @@ snapshots:
|
||||
loupe: 3.2.1
|
||||
tinyrainbow: 2.0.0
|
||||
|
||||
'@vladfrangu/async_event_emitter@2.4.7': {}
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
atomic-sleep@1.0.0: {}
|
||||
@@ -1157,6 +1318,27 @@ snapshots:
|
||||
|
||||
destr@2.0.5: {}
|
||||
|
||||
discord-api-types@0.38.45: {}
|
||||
|
||||
discord.js@14.26.2:
|
||||
dependencies:
|
||||
'@discordjs/builders': 1.14.1
|
||||
'@discordjs/collection': 1.5.3
|
||||
'@discordjs/formatters': 0.6.2
|
||||
'@discordjs/rest': 2.6.1
|
||||
'@discordjs/util': 1.2.0
|
||||
'@discordjs/ws': 1.2.3
|
||||
'@sapphire/snowflake': 3.5.3
|
||||
discord-api-types: 0.38.45
|
||||
fast-deep-equal: 3.1.3
|
||||
lodash.snakecase: 4.1.1
|
||||
magic-bytes.js: 1.13.0
|
||||
tslib: 2.8.1
|
||||
undici: 6.24.1
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
dotenv@16.6.1: {}
|
||||
|
||||
effect@3.21.0:
|
||||
@@ -1215,6 +1397,8 @@ snapshots:
|
||||
|
||||
fast-copy@4.0.2: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-safe-stringify@2.1.1: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.4):
|
||||
@@ -1241,8 +1425,14 @@ snapshots:
|
||||
|
||||
js-tokens@9.0.1: {}
|
||||
|
||||
lodash.snakecase@4.1.1: {}
|
||||
|
||||
lodash@4.18.1: {}
|
||||
|
||||
loupe@3.2.1: {}
|
||||
|
||||
magic-bytes.js@1.13.0: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@@ -1440,12 +1630,18 @@ snapshots:
|
||||
|
||||
tinyspy@4.0.4: {}
|
||||
|
||||
ts-mixer@6.0.4: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
ulid@2.4.0: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici@6.24.1: {}
|
||||
|
||||
vite-node@3.2.4(@types/node@22.19.17)(jiti@2.6.1)(yaml@2.8.3):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
@@ -1529,6 +1725,8 @@ snapshots:
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
ws@8.20.0: {}
|
||||
|
||||
xstate@5.30.0: {}
|
||||
|
||||
yaml@2.8.3: {}
|
||||
|
||||
135
src/bridge/discord-client.ts
Normal file
135
src/bridge/discord-client.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
Client,
|
||||
GatewayIntentBits,
|
||||
type TextChannel,
|
||||
type ThreadChannel,
|
||||
ChannelType,
|
||||
} from "discord.js";
|
||||
import { childLogger } from "../logger.js";
|
||||
|
||||
const log = childLogger({ module: "discord-client" });
|
||||
|
||||
export interface DiscordBridgeConfig {
|
||||
token: string;
|
||||
guildId: string;
|
||||
notifyChannelId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Discord bridge config from env. Returns null if DISCORD_TOKEN is
|
||||
* missing — the bridge is opt-in, so a missing token is expected for local
|
||||
* dev without Discord.
|
||||
*/
|
||||
export function loadDiscordConfig(): DiscordBridgeConfig | null {
|
||||
const token = process.env["DISCORD_TOKEN"];
|
||||
const guildId = process.env["DISCORD_GUILD_ID"];
|
||||
const notifyChannelId = process.env["DISCORD_NOTIFY_CHANNEL_ID"];
|
||||
if (!token || !guildId || !notifyChannelId) {
|
||||
log.info(
|
||||
{
|
||||
tokenSet: Boolean(token),
|
||||
guildIdSet: Boolean(guildId),
|
||||
channelIdSet: Boolean(notifyChannelId),
|
||||
},
|
||||
"Discord bridge not configured — skipping",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return { token, guildId, notifyChannelId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrapper around discord.js Client with the helpers the bridge needs:
|
||||
*
|
||||
* - login / ready / destroy lifecycle
|
||||
* - postToChannel / postToThread
|
||||
* - createPipelineThread : make a public thread named after a pipeline
|
||||
*
|
||||
* Keep all raw discord.js access contained here so the notifier and
|
||||
* command handler don't have to know about GatewayIntents.
|
||||
*/
|
||||
export class DiscordClientWrapper {
|
||||
readonly client: Client;
|
||||
private readonly cfg: DiscordBridgeConfig;
|
||||
private ready = false;
|
||||
|
||||
constructor(cfg: DiscordBridgeConfig) {
|
||||
this.cfg = cfg;
|
||||
this.client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
|
||||
});
|
||||
}
|
||||
|
||||
get config(): DiscordBridgeConfig {
|
||||
return this.cfg;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.ready) return;
|
||||
await new Promise<void>((resolveFn, rejectFn) => {
|
||||
this.client.once("ready", () => {
|
||||
this.ready = true;
|
||||
log.info(
|
||||
{ user: this.client.user?.tag },
|
||||
"Discord bridge logged in",
|
||||
);
|
||||
resolveFn();
|
||||
});
|
||||
this.client.once("error", rejectFn);
|
||||
this.client.login(this.cfg.token).catch(rejectFn);
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.ready) return;
|
||||
await this.client.destroy();
|
||||
this.ready = false;
|
||||
}
|
||||
|
||||
private async fetchChannel(id: string): Promise<TextChannel> {
|
||||
const ch = await this.client.channels.fetch(id);
|
||||
if (!ch || ch.type !== ChannelType.GuildText) {
|
||||
throw new Error(
|
||||
`Discord channel ${id} is missing or not a guild text channel`,
|
||||
);
|
||||
}
|
||||
return ch as TextChannel;
|
||||
}
|
||||
|
||||
async postToChannel(
|
||||
channelId: string,
|
||||
content: string,
|
||||
): Promise<string> {
|
||||
const ch = await this.fetchChannel(channelId);
|
||||
const msg = await ch.send({ content });
|
||||
return msg.id;
|
||||
}
|
||||
|
||||
async createPipelineThread(opts: {
|
||||
channelId: string;
|
||||
name: string;
|
||||
reason: string;
|
||||
}): Promise<ThreadChannel> {
|
||||
const parent = await this.fetchChannel(opts.channelId);
|
||||
const thread = await parent.threads.create({
|
||||
name: opts.name.slice(0, 100),
|
||||
autoArchiveDuration: 60, // minutes
|
||||
reason: opts.reason,
|
||||
type: ChannelType.PublicThread,
|
||||
});
|
||||
return thread;
|
||||
}
|
||||
|
||||
async postToThread(threadId: string, content: string): Promise<string> {
|
||||
const ch = await this.client.channels.fetch(threadId);
|
||||
if (!ch || !ch.isThread()) {
|
||||
throw new Error(`Discord channel ${threadId} is not a thread`);
|
||||
}
|
||||
const msg = await (ch as ThreadChannel).send({ content });
|
||||
return msg.id;
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return this.ready;
|
||||
}
|
||||
}
|
||||
242
src/bridge/discord-commands.ts
Normal file
242
src/bridge/discord-commands.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import {
|
||||
REST,
|
||||
Routes,
|
||||
SlashCommandBuilder,
|
||||
type ChatInputCommandInteraction,
|
||||
type Interaction,
|
||||
} from "discord.js";
|
||||
import type { DiscordClientWrapper } from "./discord-client.js";
|
||||
import type { DiscordNotifier } from "./discord-notifier.js";
|
||||
import { childLogger } from "../logger.js";
|
||||
|
||||
const log = childLogger({ module: "discord-commands" });
|
||||
|
||||
export interface CommandHandlerOptions {
|
||||
discord: DiscordClientWrapper;
|
||||
notifier: DiscordNotifier;
|
||||
/** Loopback rails HTTP URL, e.g. http://127.0.0.1:18800 */
|
||||
railsApiUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `/rails` command tree.
|
||||
*/
|
||||
function buildCommandDefinitions(): Array<
|
||||
ReturnType<SlashCommandBuilder["toJSON"]>
|
||||
> {
|
||||
const rails = new SlashCommandBuilder()
|
||||
.setName("rails")
|
||||
.setDescription("hanarang-rails pipeline controls")
|
||||
.addSubcommand((sub) =>
|
||||
sub
|
||||
.setName("start")
|
||||
.setDescription("Start a new pipeline")
|
||||
.addStringOption((opt) =>
|
||||
opt
|
||||
.setName("project")
|
||||
.setDescription("Project name")
|
||||
.setRequired(true),
|
||||
)
|
||||
.addStringOption((opt) =>
|
||||
opt
|
||||
.setName("requirements")
|
||||
.setDescription("What should the pipeline do?")
|
||||
.setRequired(true),
|
||||
),
|
||||
)
|
||||
.addSubcommand((sub) =>
|
||||
sub
|
||||
.setName("status")
|
||||
.setDescription("Inspect a pipeline's current state")
|
||||
.addStringOption((opt) =>
|
||||
opt
|
||||
.setName("id")
|
||||
.setDescription("Pipeline ULID")
|
||||
.setRequired(true),
|
||||
),
|
||||
)
|
||||
.addSubcommand((sub) =>
|
||||
sub
|
||||
.setName("abort")
|
||||
.setDescription("Abort a running pipeline")
|
||||
.addStringOption((opt) =>
|
||||
opt
|
||||
.setName("id")
|
||||
.setDescription("Pipeline ULID")
|
||||
.setRequired(true),
|
||||
),
|
||||
);
|
||||
return [rails.toJSON()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the slash command tree with Discord via REST. This only needs to
|
||||
* run once per deploy (Discord caches it) but running it at every bridge
|
||||
* startup is idempotent.
|
||||
*/
|
||||
export async function registerSlashCommands(
|
||||
discord: DiscordClientWrapper,
|
||||
): Promise<void> {
|
||||
const cfg = discord.config;
|
||||
const rest = new REST({ version: "10" }).setToken(cfg.token);
|
||||
const clientId = discord.client.user?.id;
|
||||
if (!clientId) {
|
||||
throw new Error("Discord client not ready — cannot register commands");
|
||||
}
|
||||
await rest.put(Routes.applicationGuildCommands(clientId, cfg.guildId), {
|
||||
body: buildCommandDefinitions(),
|
||||
});
|
||||
log.info({ guildId: cfg.guildId }, "Slash commands registered");
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the InteractionCreate listener to the rails HTTP API.
|
||||
*/
|
||||
export function installCommandHandler(opts: CommandHandlerOptions): void {
|
||||
opts.discord.client.on("interactionCreate", (interaction) => {
|
||||
void handleInteraction(interaction, opts).catch((err) => {
|
||||
log.error(
|
||||
{ err: err instanceof Error ? err.message : String(err) },
|
||||
"interaction handler failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function handleInteraction(
|
||||
interaction: Interaction,
|
||||
opts: CommandHandlerOptions,
|
||||
): Promise<void> {
|
||||
if (!interaction.isChatInputCommand()) return;
|
||||
if (interaction.commandName !== "rails") return;
|
||||
|
||||
const sub = interaction.options.getSubcommand(true);
|
||||
switch (sub) {
|
||||
case "start":
|
||||
return handleStart(interaction, opts);
|
||||
case "status":
|
||||
return handleStatus(interaction, opts);
|
||||
case "abort":
|
||||
return handleAbort(interaction, opts);
|
||||
default:
|
||||
await interaction.reply({
|
||||
content: `Unknown subcommand: ${sub}`,
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStart(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
opts: CommandHandlerOptions,
|
||||
): Promise<void> {
|
||||
const project = interaction.options.getString("project", true);
|
||||
const requirements = interaction.options.getString("requirements", true);
|
||||
|
||||
await interaction.deferReply();
|
||||
|
||||
// Kick off the pipeline on the rails loopback API
|
||||
let pipelineId: string;
|
||||
try {
|
||||
const res = await fetch(`${opts.railsApiUrl}/pipelines/start-async`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ project, requirements }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
await interaction.editReply(
|
||||
`❌ Rails API rejected the request (${res.status}): ${text.slice(0, 300)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as { pipelineId: string };
|
||||
pipelineId = data.pipelineId;
|
||||
} catch (err) {
|
||||
await interaction.editReply(
|
||||
`❌ Rails API unreachable: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a thread under the notify channel for this pipeline
|
||||
let threadId: string;
|
||||
try {
|
||||
const thread = await opts.discord.createPipelineThread({
|
||||
channelId: opts.discord.config.notifyChannelId,
|
||||
name: `🚦 ${project} — ${pipelineId.slice(0, 8)}`,
|
||||
reason: `rails pipeline ${pipelineId}`,
|
||||
});
|
||||
threadId = thread.id;
|
||||
opts.notifier.bindPipelineThread(pipelineId, threadId);
|
||||
await thread.send(
|
||||
[
|
||||
`🚦 **파이프라인 시작됨**`,
|
||||
`ID: \`${pipelineId}\``,
|
||||
`프로젝트: **${project}**`,
|
||||
`요구사항: ${requirements.slice(0, 1000)}`,
|
||||
"",
|
||||
`실시간 업데이트가 이 스레드에 계속 올라옵니다.`,
|
||||
].join("\n"),
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
{ err: err instanceof Error ? err.message : String(err) },
|
||||
"thread creation failed — posting updates to channel instead",
|
||||
);
|
||||
}
|
||||
|
||||
await interaction.editReply(
|
||||
`✅ 파이프라인 큐에 올렸어요: \`${pipelineId}\``,
|
||||
);
|
||||
}
|
||||
|
||||
async function handleStatus(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
opts: CommandHandlerOptions,
|
||||
): Promise<void> {
|
||||
const id = interaction.options.getString("id", true);
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
try {
|
||||
const res = await fetch(`${opts.railsApiUrl}/pipelines/${id}`);
|
||||
if (!res.ok) {
|
||||
await interaction.editReply(`❌ ${res.status}: pipeline not found`);
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as { state: string };
|
||||
await interaction.editReply(
|
||||
`Pipeline \`${id.slice(0, 8)}\` → **${data.state}**`,
|
||||
);
|
||||
} catch (err) {
|
||||
await interaction.editReply(
|
||||
`❌ ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAbort(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
opts: CommandHandlerOptions,
|
||||
): Promise<void> {
|
||||
const id = interaction.options.getString("id", true);
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
try {
|
||||
const res = await fetch(`${opts.railsApiUrl}/pipelines/${id}/abort`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ reason: `aborted via discord by ${interaction.user.tag}` }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
await interaction.editReply(`❌ ${res.status}: abort failed`);
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as { state: string };
|
||||
await interaction.editReply(
|
||||
`Pipeline \`${id.slice(0, 8)}\` aborted → **${data.state}**`,
|
||||
);
|
||||
} catch (err) {
|
||||
await interaction.editReply(
|
||||
`❌ ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
177
src/bridge/discord-notifier.ts
Normal file
177
src/bridge/discord-notifier.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import type {
|
||||
PipelineLifecycleEvent,
|
||||
PipelineEventListener,
|
||||
} from "../orchestrator/runner.js";
|
||||
import type { EscalationNotifier } from "../resilience/escalate.js";
|
||||
import type { DiscordClientWrapper } from "./discord-client.js";
|
||||
import { childLogger } from "../logger.js";
|
||||
|
||||
const log = childLogger({ module: "discord-notifier" });
|
||||
|
||||
const STAGE_EMOJI: Record<string, string> = {
|
||||
plan: "📋",
|
||||
implement: "🔨",
|
||||
review: "🔍",
|
||||
deploy: "🚀",
|
||||
};
|
||||
|
||||
const STAGE_KOREAN: Record<string, string> = {
|
||||
plan: "기획",
|
||||
implement: "구현",
|
||||
review: "검토",
|
||||
deploy: "배포",
|
||||
};
|
||||
|
||||
/**
|
||||
* DiscordNotifier — posts pipeline lifecycle events to Discord.
|
||||
*
|
||||
* Two delivery modes:
|
||||
*
|
||||
* 1. **Thread mode**: when a pipeline was started by a Discord slash
|
||||
* command, the command handler opens a thread and registers the
|
||||
* mapping here. Subsequent events go to that thread.
|
||||
*
|
||||
* 2. **Channel mode**: when a pipeline was started via CLI / HTTP, there
|
||||
* is no thread, so events are posted to the top-level notify channel.
|
||||
*
|
||||
* The notifier also implements EscalationNotifier so it can be injected
|
||||
* into the resilience layer without knowing it's Discord.
|
||||
*/
|
||||
export class DiscordNotifier implements EscalationNotifier {
|
||||
private readonly pipelineThreads = new Map<string, string>();
|
||||
|
||||
constructor(private readonly discord: DiscordClientWrapper) {}
|
||||
|
||||
/** Registered by the slash command handler after creating a thread. */
|
||||
bindPipelineThread(pipelineId: string, threadId: string): void {
|
||||
this.pipelineThreads.set(pipelineId, threadId);
|
||||
}
|
||||
|
||||
unbindPipelineThread(pipelineId: string): void {
|
||||
this.pipelineThreads.delete(pipelineId);
|
||||
}
|
||||
|
||||
/** Returns a callback compatible with runner's onEvent hook. */
|
||||
asListener(): PipelineEventListener {
|
||||
return (evt) => {
|
||||
void this.handle(evt).catch((err) => {
|
||||
log.warn(
|
||||
{ err: err instanceof Error ? err.message : String(err) },
|
||||
"Discord notifier handler failed",
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** EscalationNotifier interface — called from resilience/escalate.ts. */
|
||||
async notify(message: {
|
||||
title: string;
|
||||
body: string;
|
||||
mentionUser?: boolean;
|
||||
}): Promise<void> {
|
||||
const content = `**${message.title}**\n\n${message.body}`;
|
||||
try {
|
||||
await this.discord.postToChannel(
|
||||
this.discord.config.notifyChannelId,
|
||||
content,
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
{ err: err instanceof Error ? err.message : String(err) },
|
||||
"Discord escalation post failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async handle(evt: PipelineLifecycleEvent): Promise<void> {
|
||||
const content = this.render(evt);
|
||||
if (!content) return;
|
||||
const threadId = this.pipelineThreads.get(evt.pipelineId);
|
||||
try {
|
||||
if (threadId) {
|
||||
await this.discord.postToThread(threadId, content);
|
||||
} else {
|
||||
await this.discord.postToChannel(
|
||||
this.discord.config.notifyChannelId,
|
||||
content,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
{
|
||||
pipelineId: evt.pipelineId,
|
||||
evt: evt.type,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"Discord post failed",
|
||||
);
|
||||
}
|
||||
// Auto-unbind on terminal events so long-lived bridges don't leak.
|
||||
if (
|
||||
evt.type === "completed" ||
|
||||
evt.type === "failed" ||
|
||||
evt.type === "escalated"
|
||||
) {
|
||||
// Keep the mapping for escalated — user may resume and emit more events
|
||||
if (evt.type !== "escalated") {
|
||||
this.unbindPipelineThread(evt.pipelineId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private render(evt: PipelineLifecycleEvent): string | null {
|
||||
const short = evt.pipelineId.slice(0, 8);
|
||||
switch (evt.type) {
|
||||
case "started":
|
||||
return [
|
||||
`🚦 **파이프라인 시작** \`${short}\``,
|
||||
`📦 프로젝트: **${evt.projectName}**`,
|
||||
`📝 요구사항: ${truncate(evt.requirements, 500)}`,
|
||||
].join("\n");
|
||||
|
||||
case "stage-done": {
|
||||
const emoji = STAGE_EMOJI[evt.stage] ?? "✅";
|
||||
const ko = STAGE_KOREAN[evt.stage] ?? evt.stage;
|
||||
const summary = evt.text ? `\n${truncate(evt.text, 800)}` : "";
|
||||
return `${emoji} **${ko} 완료** \`${short}\`${summary}`;
|
||||
}
|
||||
|
||||
case "stage-failed": {
|
||||
const ko = STAGE_KOREAN[evt.stage] ?? evt.stage;
|
||||
return `⚠️ **${ko} 실패** \`${short}\`\n${truncate(evt.reason, 600)}`;
|
||||
}
|
||||
|
||||
case "completed":
|
||||
return [
|
||||
`✅ **파이프라인 완료** \`${short}\``,
|
||||
`전이: ${evt.transitions} · 최종 상태: \`${evt.finalState}\``,
|
||||
].join("\n");
|
||||
|
||||
case "failed":
|
||||
return `❌ **파이프라인 실패** \`${short}\`\n${truncate(evt.reason, 600)}`;
|
||||
|
||||
case "escalated":
|
||||
return [
|
||||
`🚨 **에스컬레이션 — 사용자 개입 필요** \`${short}\``,
|
||||
`단계: \`${evt.stage}\` · 시도: ${evt.attempts}`,
|
||||
`사유: ${truncate(evt.reason, 600)}`,
|
||||
"",
|
||||
"복구 명령:",
|
||||
` \`rails resume ${evt.pipelineId}\``,
|
||||
` \`rails abort ${evt.pipelineId}\``,
|
||||
].join("\n");
|
||||
|
||||
default: {
|
||||
const exhaustive: never = evt;
|
||||
void exhaustive;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
if (!text) return "";
|
||||
if (text.length <= max) return text;
|
||||
return text.slice(0, max - 1) + "…";
|
||||
}
|
||||
78
src/bridge/index.ts
Normal file
78
src/bridge/index.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
DiscordClientWrapper,
|
||||
loadDiscordConfig,
|
||||
} from "./discord-client.js";
|
||||
import { DiscordNotifier } from "./discord-notifier.js";
|
||||
import {
|
||||
installCommandHandler,
|
||||
registerSlashCommands,
|
||||
} from "./discord-commands.js";
|
||||
import type { PipelineEventListener } from "../orchestrator/runner.js";
|
||||
import type { EscalationNotifier } from "../resilience/escalate.js";
|
||||
import { childLogger } from "../logger.js";
|
||||
|
||||
const log = childLogger({ module: "bridge" });
|
||||
|
||||
export interface BridgeHandle {
|
||||
notifier: DiscordNotifier;
|
||||
onPipelineEvent: PipelineEventListener;
|
||||
escalationNotifier: EscalationNotifier;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the Discord bridge if DISCORD_TOKEN / DISCORD_GUILD_ID /
|
||||
* DISCORD_NOTIFY_CHANNEL_ID are set. Returns a handle with the listener
|
||||
* that rails serve should inject into runPipeline.
|
||||
*
|
||||
* Returns null when Discord env is missing — callers should treat null as
|
||||
* "bridge not configured, skip".
|
||||
*/
|
||||
export async function startDiscordBridge(opts: {
|
||||
railsApiUrl: string;
|
||||
}): Promise<BridgeHandle | null> {
|
||||
const cfg = loadDiscordConfig();
|
||||
if (!cfg) return null;
|
||||
|
||||
const discord = new DiscordClientWrapper(cfg);
|
||||
try {
|
||||
await discord.start();
|
||||
} catch (err) {
|
||||
log.error(
|
||||
{ err: err instanceof Error ? err.message : String(err) },
|
||||
"Discord login failed — bridge disabled",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const notifier = new DiscordNotifier(discord);
|
||||
|
||||
try {
|
||||
await registerSlashCommands(discord);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
{ err: err instanceof Error ? err.message : String(err) },
|
||||
"Slash command registration failed — commands may not appear",
|
||||
);
|
||||
}
|
||||
|
||||
installCommandHandler({
|
||||
discord,
|
||||
notifier,
|
||||
railsApiUrl: opts.railsApiUrl,
|
||||
});
|
||||
|
||||
log.info(
|
||||
{ channelId: cfg.notifyChannelId, guildId: cfg.guildId },
|
||||
"Discord bridge ready",
|
||||
);
|
||||
|
||||
return {
|
||||
notifier,
|
||||
onPipelineEvent: notifier.asListener(),
|
||||
escalationNotifier: notifier,
|
||||
async stop() {
|
||||
await discord.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { loadEnv } from "../env.js";
|
||||
import { getLogger } from "../logger.js";
|
||||
import { startHttpServer } from "../server/http.js";
|
||||
import { disconnectPrisma } from "../orchestrator/persist.js";
|
||||
import { startDiscordBridge, type BridgeHandle } from "../bridge/index.js";
|
||||
|
||||
export default defineCommand({
|
||||
meta: {
|
||||
@@ -35,22 +36,38 @@ export default defineCommand({
|
||||
|
||||
const port = parseInt(args.port || String(env.RAILS_PORT), 10);
|
||||
|
||||
// Bring up Discord bridge first (if configured) so the notifier can be
|
||||
// injected into every runPipeline call the HTTP server makes.
|
||||
let bridge: BridgeHandle | null = null;
|
||||
try {
|
||||
bridge = await startDiscordBridge({
|
||||
railsApiUrl: `http://127.0.0.1:${port}`,
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
{ err: err instanceof Error ? err.message : String(err) },
|
||||
"Discord bridge failed to start — continuing without it",
|
||||
);
|
||||
}
|
||||
|
||||
const { url, close } = await startHttpServer({
|
||||
port,
|
||||
host: args.host ?? "0.0.0.0",
|
||||
...(args.config && { configPath: args.config }),
|
||||
...(bridge && { onPipelineEvent: bridge.onPipelineEvent }),
|
||||
...(bridge && { notifier: bridge.escalationNotifier }),
|
||||
});
|
||||
|
||||
log.info(
|
||||
{ url, nodeEnv: env.NODE_ENV },
|
||||
{ url, nodeEnv: env.NODE_ENV, discordBridge: Boolean(bridge) },
|
||||
"hanarang-rails server ready",
|
||||
);
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = async (signal: string) => {
|
||||
log.info({ signal }, "Shutdown requested");
|
||||
try {
|
||||
await close();
|
||||
if (bridge) await bridge.stop();
|
||||
await disconnectPrisma();
|
||||
} catch (err) {
|
||||
log.error(
|
||||
@@ -64,7 +81,6 @@ export default defineCommand({
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
|
||||
// Keep alive
|
||||
await new Promise<never>(() => {
|
||||
/* block until signal */
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { ulid } from "ulid";
|
||||
import { sendEvent, createPipeline } from "./persist.js";
|
||||
import {
|
||||
sendEvent,
|
||||
createPipeline,
|
||||
getPipelineState,
|
||||
} from "./persist.js";
|
||||
import type { PipelineEvent, PipelineState } from "./events.js";
|
||||
import type { HandoffMessage, InvokeRequest } from "../handoff/message.js";
|
||||
import type { SisterTransport } from "../handoff/transport.js";
|
||||
@@ -10,6 +14,46 @@ import { childLogger } from "../logger.js";
|
||||
|
||||
const log = childLogger({ module: "runner" });
|
||||
|
||||
export type PipelineLifecycleEvent =
|
||||
| {
|
||||
type: "started";
|
||||
pipelineId: string;
|
||||
projectName: string;
|
||||
requirements: string;
|
||||
}
|
||||
| {
|
||||
type: "stage-done";
|
||||
pipelineId: string;
|
||||
stage: "plan" | "implement" | "review" | "deploy";
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type: "stage-failed";
|
||||
pipelineId: string;
|
||||
stage: "plan" | "implement" | "review" | "deploy";
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: "completed";
|
||||
pipelineId: string;
|
||||
finalState: PipelineState;
|
||||
transitions: number;
|
||||
}
|
||||
| {
|
||||
type: "failed";
|
||||
pipelineId: string;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: "escalated";
|
||||
pipelineId: string;
|
||||
stage: string;
|
||||
reason: string;
|
||||
attempts: number;
|
||||
};
|
||||
|
||||
export type PipelineEventListener = (evt: PipelineLifecycleEvent) => void;
|
||||
|
||||
export interface RunOptions {
|
||||
projectName: string;
|
||||
requirements: string;
|
||||
@@ -18,6 +62,14 @@ export interface RunOptions {
|
||||
signal?: AbortSignal;
|
||||
maxRetries?: number;
|
||||
notifier?: EscalationNotifier;
|
||||
/** Optional lifecycle listener — used by the Discord bridge to post updates. */
|
||||
onEvent?: PipelineEventListener;
|
||||
/**
|
||||
* If provided, resume an already-created pipeline row instead of making
|
||||
* a new one. Used by async HTTP starts where the caller needs the id
|
||||
* before runPipeline finishes.
|
||||
*/
|
||||
pipelineId?: string;
|
||||
}
|
||||
|
||||
export interface RunResult {
|
||||
@@ -32,13 +84,44 @@ export interface RunResult {
|
||||
* into the FSM until done or escalated.
|
||||
*/
|
||||
export async function runPipeline(opts: RunOptions): Promise<RunResult> {
|
||||
const { pipelineId, state: initialState } = await createPipeline(
|
||||
opts.projectName,
|
||||
opts.requirements,
|
||||
);
|
||||
let pipelineId: string;
|
||||
let initialState: string;
|
||||
if (opts.pipelineId) {
|
||||
pipelineId = opts.pipelineId;
|
||||
const existing = await getPipelineState(pipelineId);
|
||||
if (!existing) {
|
||||
throw new Error(
|
||||
`runPipeline: pipelineId ${pipelineId} does not exist in DB`,
|
||||
);
|
||||
}
|
||||
initialState = existing.state;
|
||||
} else {
|
||||
const created = await createPipeline(opts.projectName, opts.requirements);
|
||||
pipelineId = created.pipelineId;
|
||||
initialState = created.state;
|
||||
}
|
||||
|
||||
log.info({ pipelineId, project: opts.projectName }, "Pipeline run started");
|
||||
|
||||
const emit = (evt: PipelineLifecycleEvent): void => {
|
||||
if (!opts.onEvent) return;
|
||||
try {
|
||||
opts.onEvent(evt);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
{ err: err instanceof Error ? err.message : String(err) },
|
||||
"pipeline event listener threw",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
emit({
|
||||
type: "started",
|
||||
pipelineId,
|
||||
projectName: opts.projectName,
|
||||
requirements: opts.requirements,
|
||||
});
|
||||
|
||||
// REQUEST event — enters planning
|
||||
let result = await sendEvent(pipelineId, {
|
||||
type: "REQUEST",
|
||||
@@ -114,6 +197,12 @@ export async function runPipeline(opts: RunOptions): Promise<RunResult> {
|
||||
if (stageText) {
|
||||
priorStages.push({ stage, text: stageText });
|
||||
}
|
||||
emit({
|
||||
type: "stage-done",
|
||||
pipelineId,
|
||||
stage,
|
||||
text: stageText,
|
||||
});
|
||||
const event = handoffToEvent(retryResult.value);
|
||||
result = await sendEvent(pipelineId, event);
|
||||
transitions += 1;
|
||||
@@ -132,6 +221,13 @@ export async function runPipeline(opts: RunOptions): Promise<RunResult> {
|
||||
"Transport invoke failed after retries",
|
||||
);
|
||||
|
||||
emit({
|
||||
type: "stage-failed",
|
||||
pipelineId,
|
||||
stage,
|
||||
reason,
|
||||
});
|
||||
|
||||
if (classification && !classification.retryable) {
|
||||
await recordEscalation(
|
||||
{
|
||||
@@ -144,6 +240,13 @@ export async function runPipeline(opts: RunOptions): Promise<RunResult> {
|
||||
},
|
||||
opts.notifier,
|
||||
);
|
||||
emit({
|
||||
type: "escalated",
|
||||
pipelineId,
|
||||
stage,
|
||||
reason,
|
||||
attempts: retryResult.attempts,
|
||||
});
|
||||
}
|
||||
|
||||
result = await sendEvent(pipelineId, {
|
||||
@@ -161,6 +264,23 @@ export async function runPipeline(opts: RunOptions): Promise<RunResult> {
|
||||
"Pipeline run finished",
|
||||
);
|
||||
|
||||
if (result.state === "done") {
|
||||
emit({
|
||||
type: "completed",
|
||||
pipelineId,
|
||||
finalState: result.state,
|
||||
transitions,
|
||||
});
|
||||
} else if (result.state === "escalated") {
|
||||
// Escalated emit already fired at the failure site; no extra event.
|
||||
} else {
|
||||
emit({
|
||||
type: "failed",
|
||||
pipelineId,
|
||||
reason: `Pipeline ended in ${result.state}`,
|
||||
});
|
||||
}
|
||||
|
||||
void initialState; // referenced only for typecheck
|
||||
return {
|
||||
pipelineId,
|
||||
|
||||
@@ -8,10 +8,14 @@ import {
|
||||
listEscalations,
|
||||
sendEvent,
|
||||
} from "../orchestrator/persist.js";
|
||||
import { runPipeline } from "../orchestrator/runner.js";
|
||||
import {
|
||||
runPipeline,
|
||||
type PipelineEventListener,
|
||||
} from "../orchestrator/runner.js";
|
||||
import { loadConfig } from "../config/loader.js";
|
||||
import type { SisterTransport } from "../handoff/transport.js";
|
||||
import { buildTransports } from "../handoff/build.js";
|
||||
import type { EscalationNotifier } from "../resilience/escalate.js";
|
||||
import {
|
||||
CreateSubTaskInput,
|
||||
SubTaskEventInput,
|
||||
@@ -40,6 +44,10 @@ interface ServerOpts {
|
||||
port: number;
|
||||
host?: string;
|
||||
configPath?: string;
|
||||
/** Optional lifecycle listener injected into every runPipeline call. */
|
||||
onPipelineEvent?: PipelineEventListener;
|
||||
/** Optional escalation notifier injected into every runPipeline call. */
|
||||
notifier?: EscalationNotifier;
|
||||
}
|
||||
|
||||
export async function startHttpServer(opts: ServerOpts): Promise<{
|
||||
@@ -72,7 +80,7 @@ export async function startHttpServer(opts: ServerOpts): Promise<{
|
||||
return sendJson(res, 200, { pipelines: list });
|
||||
}
|
||||
|
||||
// ── Start new pipeline ──
|
||||
// ── Start new pipeline (synchronous — blocks until done) ──
|
||||
if (method === "POST" && path === "/pipelines/start") {
|
||||
const body = await readJson(req);
|
||||
const parsed = StartRequest.safeParse(body);
|
||||
@@ -84,13 +92,13 @@ export async function startHttpServer(opts: ServerOpts): Promise<{
|
||||
}
|
||||
const { project, requirements } = parsed.data;
|
||||
|
||||
// Run pipeline (async, but we await for this simple demo)
|
||||
// Transport is determined by env RAILS_TRANSPORT_MODE and rails.config.yaml
|
||||
const result = await runPipeline({
|
||||
projectName: project,
|
||||
requirements,
|
||||
config,
|
||||
transports,
|
||||
...(opts.onPipelineEvent && { onEvent: opts.onPipelineEvent }),
|
||||
...(opts.notifier && { notifier: opts.notifier }),
|
||||
});
|
||||
|
||||
return sendJson(res, 201, {
|
||||
@@ -100,6 +108,53 @@ export async function startHttpServer(opts: ServerOpts): Promise<{
|
||||
});
|
||||
}
|
||||
|
||||
// ── Start new pipeline (async — returns pipelineId immediately) ──
|
||||
//
|
||||
// Used by the Discord slash command so the bot can ACK within 3 s and
|
||||
// then post progress updates to a thread as the pipeline advances.
|
||||
if (method === "POST" && path === "/pipelines/start-async") {
|
||||
const body = await readJson(req);
|
||||
const parsed = StartRequest.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return sendJson(res, 400, {
|
||||
error: "invalid_request",
|
||||
issues: parsed.error.issues,
|
||||
});
|
||||
}
|
||||
const { project, requirements } = parsed.data;
|
||||
|
||||
// Create the pipeline row synchronously so we can return its id
|
||||
// immediately, then run the rest in the background under that id.
|
||||
const { pipelineId } = await createPipeline(project, requirements);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await runPipeline({
|
||||
projectName: project,
|
||||
requirements,
|
||||
pipelineId,
|
||||
config,
|
||||
transports,
|
||||
...(opts.onPipelineEvent && { onEvent: opts.onPipelineEvent }),
|
||||
...(opts.notifier && { notifier: opts.notifier }),
|
||||
});
|
||||
} catch (err) {
|
||||
log.error(
|
||||
{
|
||||
pipelineId,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"background pipeline run failed",
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return sendJson(res, 202, {
|
||||
pipelineId,
|
||||
status: "accepted",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Get pipeline status ──
|
||||
const statusMatch = path.match(/^\/pipelines\/([^/]+)$/);
|
||||
if (method === "GET" && statusMatch) {
|
||||
|
||||
215
tests/discord-notifier.test.ts
Normal file
215
tests/discord-notifier.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { DiscordNotifier } from "../src/bridge/discord-notifier.js";
|
||||
import type { DiscordClientWrapper } from "../src/bridge/discord-client.js";
|
||||
import type { PipelineLifecycleEvent } from "../src/orchestrator/runner.js";
|
||||
|
||||
/**
|
||||
* The DiscordNotifier is the interesting unit — it contains the routing
|
||||
* logic (channel vs thread) and the message rendering. We exercise it
|
||||
* against a fake DiscordClientWrapper that records every call.
|
||||
*/
|
||||
|
||||
function makeFakeDiscord(): {
|
||||
fake: DiscordClientWrapper;
|
||||
channelPosts: Array<{ channelId: string; content: string }>;
|
||||
threadPosts: Array<{ threadId: string; content: string }>;
|
||||
} {
|
||||
const channelPosts: Array<{ channelId: string; content: string }> = [];
|
||||
const threadPosts: Array<{ threadId: string; content: string }> = [];
|
||||
const fake = {
|
||||
config: {
|
||||
token: "x",
|
||||
guildId: "g",
|
||||
notifyChannelId: "channel-123",
|
||||
},
|
||||
async postToChannel(channelId: string, content: string) {
|
||||
channelPosts.push({ channelId, content });
|
||||
return "msg-id";
|
||||
},
|
||||
async postToThread(threadId: string, content: string) {
|
||||
threadPosts.push({ threadId, content });
|
||||
return "msg-id";
|
||||
},
|
||||
} as unknown as DiscordClientWrapper;
|
||||
return { fake, channelPosts, threadPosts };
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
// The notifier dispatches via .catch on a floating promise — give
|
||||
// microtasks a chance to run before we assert.
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
|
||||
describe("DiscordNotifier", () => {
|
||||
let fake: ReturnType<typeof makeFakeDiscord>;
|
||||
let notifier: DiscordNotifier;
|
||||
|
||||
beforeEach(() => {
|
||||
fake = makeFakeDiscord();
|
||||
notifier = new DiscordNotifier(fake.fake);
|
||||
});
|
||||
|
||||
it("posts to channel when no thread is bound", async () => {
|
||||
const listener = notifier.asListener();
|
||||
const evt: PipelineLifecycleEvent = {
|
||||
type: "started",
|
||||
pipelineId: "01HXYZTEST1234567890ABCDE",
|
||||
projectName: "demo",
|
||||
requirements: "make a toy app",
|
||||
};
|
||||
listener(evt);
|
||||
await flush();
|
||||
|
||||
expect(fake.channelPosts).toHaveLength(1);
|
||||
expect(fake.threadPosts).toHaveLength(0);
|
||||
expect(fake.channelPosts[0]!.channelId).toBe("channel-123");
|
||||
expect(fake.channelPosts[0]!.content).toContain("파이프라인 시작");
|
||||
expect(fake.channelPosts[0]!.content).toContain("demo");
|
||||
});
|
||||
|
||||
it("posts to thread when one is bound", async () => {
|
||||
notifier.bindPipelineThread("01HXYZTEST1234567890ABCDE", "thread-777");
|
||||
const listener = notifier.asListener();
|
||||
listener({
|
||||
type: "stage-done",
|
||||
pipelineId: "01HXYZTEST1234567890ABCDE",
|
||||
stage: "plan",
|
||||
text: "plan ok",
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(fake.threadPosts).toHaveLength(1);
|
||||
expect(fake.channelPosts).toHaveLength(0);
|
||||
expect(fake.threadPosts[0]!.threadId).toBe("thread-777");
|
||||
expect(fake.threadPosts[0]!.content).toContain("기획 완료");
|
||||
});
|
||||
|
||||
it("renders every lifecycle event type", async () => {
|
||||
notifier.bindPipelineThread("01HXYZTEST1234567890ABCDE", "thread-1");
|
||||
const listener = notifier.asListener();
|
||||
|
||||
const events: PipelineLifecycleEvent[] = [
|
||||
{
|
||||
type: "started",
|
||||
pipelineId: "01HXYZTEST1234567890ABCDE",
|
||||
projectName: "p",
|
||||
requirements: "r",
|
||||
},
|
||||
{
|
||||
type: "stage-done",
|
||||
pipelineId: "01HXYZTEST1234567890ABCDE",
|
||||
stage: "implement",
|
||||
text: "impl ok",
|
||||
},
|
||||
{
|
||||
type: "stage-failed",
|
||||
pipelineId: "01HXYZTEST1234567890ABCDE",
|
||||
stage: "review",
|
||||
reason: "something broke",
|
||||
},
|
||||
{
|
||||
type: "escalated",
|
||||
pipelineId: "01HXYZTEST1234567890ABCDE",
|
||||
stage: "implement",
|
||||
reason: "3 retries failed",
|
||||
attempts: 3,
|
||||
},
|
||||
{
|
||||
type: "completed",
|
||||
pipelineId: "01HXYZTEST1234567890ABCDE",
|
||||
finalState: "done",
|
||||
transitions: 5,
|
||||
},
|
||||
];
|
||||
for (const e of events) listener(e);
|
||||
await flush();
|
||||
|
||||
const contents = fake.threadPosts.map((p) => p.content);
|
||||
expect(contents.some((c) => c.includes("파이프라인 시작"))).toBe(true);
|
||||
expect(contents.some((c) => c.includes("구현 완료"))).toBe(true);
|
||||
expect(contents.some((c) => c.includes("검토 실패"))).toBe(true);
|
||||
expect(contents.some((c) => c.includes("에스컬레이션"))).toBe(true);
|
||||
expect(contents.some((c) => c.includes("파이프라인 완료"))).toBe(true);
|
||||
});
|
||||
|
||||
it("unbinds thread after terminal 'completed' event", async () => {
|
||||
notifier.bindPipelineThread("01HXYZTEST1234567890ABCDE", "thread-2");
|
||||
const listener = notifier.asListener();
|
||||
listener({
|
||||
type: "completed",
|
||||
pipelineId: "01HXYZTEST1234567890ABCDE",
|
||||
finalState: "done",
|
||||
transitions: 5,
|
||||
});
|
||||
await flush();
|
||||
|
||||
// A subsequent event should fall back to channel (thread unbound)
|
||||
listener({
|
||||
type: "started",
|
||||
pipelineId: "01HXYZTEST1234567890ABCDE",
|
||||
projectName: "x",
|
||||
requirements: "y",
|
||||
});
|
||||
await flush();
|
||||
|
||||
// First post to thread, second to channel
|
||||
expect(fake.threadPosts).toHaveLength(1);
|
||||
expect(fake.channelPosts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps thread binding after 'escalated' (user may resume)", async () => {
|
||||
notifier.bindPipelineThread("01HXYZTEST1234567890ABCDE", "thread-3");
|
||||
const listener = notifier.asListener();
|
||||
listener({
|
||||
type: "escalated",
|
||||
pipelineId: "01HXYZTEST1234567890ABCDE",
|
||||
stage: "plan",
|
||||
reason: "halt",
|
||||
attempts: 2,
|
||||
});
|
||||
await flush();
|
||||
|
||||
listener({
|
||||
type: "stage-done",
|
||||
pipelineId: "01HXYZTEST1234567890ABCDE",
|
||||
stage: "plan",
|
||||
text: "resumed",
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(fake.threadPosts).toHaveLength(2);
|
||||
expect(fake.channelPosts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("EscalationNotifier.notify posts to channel", async () => {
|
||||
await notifier.notify({
|
||||
title: "🚨 pipeline halted",
|
||||
body: "three retries exhausted",
|
||||
mentionUser: true,
|
||||
});
|
||||
expect(fake.channelPosts).toHaveLength(1);
|
||||
expect(fake.channelPosts[0]!.content).toContain("pipeline halted");
|
||||
expect(fake.channelPosts[0]!.content).toContain("three retries exhausted");
|
||||
});
|
||||
|
||||
it("swallows listener exceptions from postToChannel (non-fatal)", async () => {
|
||||
const warnSpy = vi.fn();
|
||||
// Inject a failing postToChannel
|
||||
const failingFake = makeFakeDiscord();
|
||||
(failingFake.fake as unknown as {
|
||||
postToChannel: () => Promise<string>;
|
||||
}).postToChannel = async () => {
|
||||
throw new Error("boom");
|
||||
};
|
||||
const failingNotifier = new DiscordNotifier(failingFake.fake);
|
||||
failingNotifier.asListener()({
|
||||
type: "started",
|
||||
pipelineId: "01HXYZTEST1234567890ABCDE",
|
||||
projectName: "p",
|
||||
requirements: "r",
|
||||
});
|
||||
await flush();
|
||||
// If we get here without an uncaught rejection, the test passes.
|
||||
expect(warnSpy).toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user