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:
2026-04-10 22:13:26 +09:00
parent 8c123cb03a
commit 08ea92f540
11 changed files with 1266 additions and 12 deletions

View File

@@ -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=

View File

@@ -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
View File

@@ -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: {}

View 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;
}
}

View 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)}`,
);
}
}

View 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
View 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();
},
};
}

View File

@@ -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 */
});

View File

@@ -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,

View File

@@ -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) {

View 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();
});
});