실시간 이벤트와 알림
Chorus는 열려 있는 모든 브라우저를 사람과 에이전트가 하고 있는 일과 동기화된 상태로 유지합니다. 작업이 이동하거나 아이디어를 누군가 가져가거나 제안이 승인되는 등 변경이 반영되면, 그 변경은 Server-Sent Events(SSE)로 전송되고 영향을 받은 페이지가 스스로 새로고침합니다. 에이전트 데몬도 SSE를 수신하며, 그 알림 스트림이 깨울 수 있는 에이전트가 새 작업을 알게 되는 경로입니다. 이 페이지는 SSE 엔드포인트와 그 이벤트 형태, 데몬이 인증하고 놓친 작업을 복구하는 방법, 그리고 데몬 없이 동작하는 에이전트(와 브라우저 클라이언트)가 호출하는 알림 REST API를 설명합니다.
실시간 업데이트는 어떻게 흐르는가
섹션 제목: “실시간 업데이트는 어떻게 흐르는가”MCP 도구, REST API, 웹 UI 중 어느 쪽으로 이루어진 변경이든, 저장되고 나면 변경 이벤트로 알려집니다. SSE 엔드포인트가 연결된 각 브라우저에 일치하는 이벤트를 스트리밍하면, 브라우저는 잠시 기다린 뒤 현재 페이지의 데이터를 다시 불러옵니다.
변경 저장(MCP 도구 / REST API / 웹 UI) → 변경 이벤트 발행 → GET /api/events가 워크스페이스 + 프로젝트로 필터링 → 브라우저 EventSource가 이벤트를 받음 → 500ms 후 페이지가 데이터를 다시 불러옴이 새로고침은 의도적으로 거친 단위로 이루어집니다. 단일 이벤트는 페이지에 “보고 있는 무언가가 바뀌었다”라고만 알리고, 페이지가 서버에서 최신 데이터를 다시 가져옵니다. 어떤 이벤트도 변경된 레코드 자체를 담지 않습니다.
SSE 엔드포인트
섹션 제목: “SSE 엔드포인트”두 엔드포인트가 이벤트를 스트림합니다. 둘 다 GET이고, 둘 다 브라우저 세션 Cookie 또는
Authorization: Bearer API 키를 받으며(에이전트가 이벤트를 받는 방법 참고), 응답은
절대 캐시되지 않습니다.
| 엔드포인트 | 스트림하는 것 |
|---|---|
GET /api/events?projectUuid=<uuid> | 엔티티 변경 이벤트. 호출자의 워크스페이스 기준으로 필터링되며, projectUuid가 주어지면 그 프로젝트로 한정된다. |
GET /api/events/notifications | 인증된 호출자를 위한 알림 이벤트. 이를 통해 앱 내 알림 표시기가 실시간으로 업데이트된다. 에이전트 데몬은 이 스트림을 주된 깨우기 채널로 구독한다. |
각 응답은 다음 헤더와 동작으로 전송됩니다.
Content-Type: text/event-stream,Cache-Control: no-cache,Connection: keep-alive.- 연결 시, 스트림은 먼저
: connected주석을 쓰고, 그다음data: <json>프레임으로 이벤트를 스트림합니다. - 하트비트:
: heartbeat주석을 30초마다 보내, 유휴 상태에서도 중간 프록시의 타임아웃으로 연결이 끊기지 않게 합니다. - 연결 해제 시 정리: 클라이언트가 요청을 중단하면(탭 닫기, 이동, 네트워크 끊김), 엔드포인트는 스트리밍을 멈추고 하트비트 타이머를 지웁니다.
- 멀티테넌시: 변경 스트림은
companyUuid가 호출자와 일치하지 않는 이벤트를 모두 버리므로, 클라이언트는 다른 워크스페이스의 활동을 받지 않습니다.
변경 이벤트 형태
섹션 제목: “변경 이벤트 형태”GET /api/events의 변경 이벤트는 형태가 고정된 JSON 객체입니다.
interface RealtimeEvent { companyUuid: string; // 멀티테넌트 격리 — 스트림은 일치하지 않는 이벤트를 버린다 projectUuid: string; // 선택적 projectUuid 필터에 사용 entityType: "task" | "idea" | "proposal" | "document"; entityUuid: string; // 변경된 엔티티 action: "created" | "updated" | "deleted";}이벤트는 어떤 엔티티가 어떻게 바뀌었는지를 가리킬 뿐, 그 엔티티의 필드는 담지 않습니다. 클라이언트는 이벤트를 새로고침 신호로만 사용합니다.
클라이언트 동작
섹션 제목: “클라이언트 동작”브라우저는 /api/events?projectUuid=<uuid>에 EventSource를 열고, 메시지를 받으면 500ms 기다린 뒤
페이지를 새로고침합니다. 이 디바운스 덕분에 하나의 동작에서 연달아 나오는 이벤트(예: 제안을 승인하면 제안
업데이트 하나와 생성된 작업마다 이벤트 하나씩)가 한 번의 새로고침으로 합쳐집니다. 탭이 숨겨지면 연결을
닫고, 다시 보이면 새로고침과 함께 다시 열며, 페이지를 떠나면 연결을 완전히 닫습니다.
에이전트가 이벤트를 받는 방법
섹션 제목: “에이전트가 이벤트를 받는 방법”에이전트는 MCP 도구나 REST API로 동작하며, 에이전트가 만든 모든 변경은 변경 이벤트를 발행합니다. 그래서 같은
프로젝트를 보고 있는 브라우저 사용자에게는 에이전트의 작업이 실시간으로 나타납니다. 에이전트도 SSE를
수신합니다. 프로젝트 변경 스트림(GET /api/events)은 브라우저만 읽지만,
에이전트 데몬은 알림 스트림 GET /api/events/notifications를 구독하며, 이
스트림이 데몬의 주된 깨우기 채널입니다.
두 가지 인증 방식
섹션 제목: “두 가지 인증 방식”두 SSE 엔드포인트 모두 Bearer 토큰을 먼저 확인하고, 없으면 Cookie로 넘어갑니다.
| 클라이언트 | 전송 방식 | 인증 |
|---|---|---|
| 브라우저 | EventSource | 세션 Cookie, 자동으로 전송된다. |
| 에이전트 데몬 | fetch로 스트리밍 응답을 읽음 | Authorization: Bearer cho_…와 Accept: text/event-stream. |
EventSource는 사용자 지정 요청 헤더를 설정할 수 없기 때문에 브라우저는 Cookie에 의존합니다. 이 제약은
EventSource에만 해당합니다. 데몬은 fetch로 스트림을 읽는 Node 프로세스이므로, 다른 요청과 마찬가지로
에이전트 API 키를 보냅니다.
대기 중인 턴 복구
섹션 제목: “대기 중인 턴 복구”GET /api/daemon/pending-turns?connectionUuid=<uuid>를 에이전트의 Bearer 키로 호출하면, 해당 데몬 연결에 속한
세션 중 아직 pending(시작 전) 상태인 턴을 돌려줍니다. 이것은 SSE를 대체하는 것이 아니라 복구 경로입니다.
모든 턴은 생성될 때 저장되므로, 어떤 깨우기의 SSE 전달이 유실되더라도 그 턴을 찾아 실행할 수 있습니다.
데몬은 세 시점에 이를 읽습니다.
- 등록 시. SSE 핸드셰이크로 연결이 확인될 때마다(재연결 후 포함) 데몬은 즉시 대기 중인 턴을 훑습니다.
- 연결 중 30초마다. 주기적인 훑기 덕분에, 한 번도 끊기지 않은 연결에서도 유실된 전달을 복구합니다.
deliver_turn알림을 받았을 때. 연결의 대기 중인 턴을 읽은 뒤 알림에 지정된 턴을 골라 실행합니다. 정기 복구 시점도 되었다면 전체 스캔도 수행합니다.
등록 시와 정기 스캔에서는 지원되는 영구 저장 깨우기 컨텍스트나 작업 페이로드가 있는 턴만 자동 재실행합니다. 둘 다 없는 이전 대기 턴은 automatic historical replay blocked를 기록하고 건너뛰며 기다리거나 재연결해도 재실행하지 않습니다. 실시간 deliver_turn은 특정 대기 턴을 지정해 실행할 수 있습니다.
복구와 SSE 연결은 다음과 같이 맞물립니다.
- 조용한 단절. 스트림이 75초 동안 아무것도 받지 못하면(이벤트도, 서버가 30초마다 보내는 하트비트도 없으면) 데몬은 연결을 끊고 다시 연결합니다.
- 재연결 중에는 복구가 멈추고, 새 연결이 등록되자마자 훑기부터 다시 시작합니다.
daemon → GET /api/events/notifications (Bearer cho_…)server → connection registereddaemon → GET /api/daemon/pending-turns (sweep on registration)daemon → GET /api/daemon/pending-turns (every 30s)server → new notification / deliver_turn (primary path) … no bytes for 75s → abort, pause recovery, reconnect …daemon → GET /api/events/notifications (reconnect)server → connection registereddaemon → GET /api/daemon/pending-turns (catch-up sweep)중복 처리
섹션 제목: “중복 처리”데몬 연결마다 이미 받아들인 턴과 알림의 기록이 메모리에 하나씩 있으며, 실시간 스트림과 복구 읽기가 이를
함께 씁니다. 같은 데몬 프로세스 안에서는 두 경로로 모두 도착한 턴도, 먼저 본 경로를 통해 한 번만 처리됩니다.
이 기록은 SSE 재연결을 넘어 유지되지만 데몬 재시작은 넘지 못합니다. 또한 턴 시작이 거부되거나 취소되면
기록이 해제되어 그 턴을 다시 시도할 수 있습니다. 이와 별개로, 서버는 아직 아무도 확보하지 않은 pending 턴만
실행을 시작하게 합니다. 이 중 어느 것도 종단 간 정확히 한 번(exactly-once) 보장은 아닙니다.
데몬이 없는 에이전트
섹션 제목: “데몬이 없는 에이전트”데몬 아래에서 실행되지 않는 MCP 에이전트는 SSE 연결을 갖지 않습니다. 이런 에이전트는 아래의
chorus_get_notifications MCP 도구와 /api/notifications REST 엔드포인트를 통해 할 일을
받습니다.
알림 REST API
섹션 제목: “알림 REST API”알림은 브라우저 클라이언트와 데몬 아래에서 실행되지 않는 에이전트가 사용하는, 영속적인 “조회 후 처리” 경로입니다. 수신자는 인증 컨텍스트에서 도출됩니다. 사용자로 인증된 요청은 그 사용자의 알림을 읽고, 에이전트 API 키로 인증된 요청은 그 에이전트의 알림을 읽습니다. 같은 라우트가 둘을 모두 처리하며, 수신자 매개변수는 없습니다.
| 메서드와 경로 | 용도 | 주요 매개변수 |
|---|---|---|
GET /api/notifications | 호출자(사용자 또는 에이전트)의 알림을 나열한다. { notifications, unreadCount }를 반환한다. | 쿼리: limit(1–100, 기본 50), offset(기본 0), unreadOnly(true면 읽지 않은 것만 반환), projectUuid(하나의 프로젝트로 범위 지정). |
GET /api/notifications/unread-count | 호출자의 읽지 않은 수를 { count }로 반환한다. | — |
POST /api/notifications/read-all | 호출자의 모든 알림을 읽음으로 표시한다. | 선택적 JSON body { projectUuid }로 적용 범위를 하나의 프로젝트로 제한. |
PATCH /api/notifications/[uuid]/read | 알림 하나를 읽음으로 표시한다. | 경로: 알림 uuid. |
PATCH /api/notifications/[uuid]/archive | 알림 하나를 보관한다. | 경로: 알림 uuid. |
GET /api/notifications/preferences | 호출자의 알림 설정을 읽는다. | — |
PUT /api/notifications/preferences | 호출자의 알림 설정을 업데이트한다. | 설정 필드의 JSON body. |
limit은 서버 측에서 1–100 범위로 조정되고, offset은 0보다 작으면 0으로 고정됩니다. 호출자가 소유하지 않은
알림을 읽음으로 표시하거나 보관하려 하면, 그 존재를 드러내는 대신 404를 반환합니다.
응답 형식
섹션 제목: “응답 형식”모든 알림 REST 라우트는 표준 Chorus JSON 응답 형식을 반환합니다. 성공 응답은 페이로드를 data 아래에 담습니다.
{ "success": true, "data": { "notifications": [], "unreadCount": 0 } }오류 응답은 대신 구조화된 error 객체를 담습니다.
{ "success": false, "error": { "code": "NOT_FOUND", "message": "Notification not found" } }흔한 실패의 오류 코드는 NOT_FOUND, BAD_REQUEST, UNAUTHORIZED, FORBIDDEN이며,
각각 HTTP 상태 404, 400, 401, 403에 대응합니다. 유효한 세션도 에이전트 키도 없는 요청은
UNAUTHORIZED로 거부됩니다.
{ "success": false, "error": { "code": "UNAUTHORIZED", "message": "Authentication required" } }HTTP는 401입니다. (SSE 엔드포인트는 이 응답 형식을 쓰지 않으며, 미인증 요청에는 평문 Unauthorized와 상태
401로 응답합니다.)
단일 인스턴스와 다중 인스턴스 전달
섹션 제목: “단일 인스턴스와 다중 인스턴스 전달”기본적으로 변경 이벤트는 메모리 안에서 전달되며, 단일 인스턴스 배포에는 이것으로 충분합니다. 변경을 처리하는 프로세스와 모든 SSE 연결을 유지하는 프로세스가 같기 때문입니다. 로컬에서 이벤트를 발행하면 연결된 모든 브라우저에 도달합니다.
인스턴스를 둘 이상 실행하면 이 전제가 깨집니다. 인스턴스 A의 변경이 인스턴스 B에 연결된 브라우저에도 여전히 도달해야 합니다. 다중 인스턴스 배포에서는 Redis pub/sub으로 이벤트를 전달하세요. 각 인스턴스가 공유 채널을 구독하고, 변경은 로컬에서만 발행하는 대신 Redis로 발행하며, 각 인스턴스의 SSE 엔드포인트가 받은 것을 자신의 연결된 브라우저로 전달합니다. 이때도 SSE 엔드포인트와 브라우저 쪽은 그대로이며, 달라지는 것은 인스턴스 사이에서 이벤트가 전달되는 방식뿐입니다.
알림 개념
섹션 제목: “알림 개념”이 페이지는 전달 메커니즘만 다룹니다. 무엇이 알림을 생성하는지(범주, @멘션, 활동 종류별로 누가 알림을 받는지), 그리고 각 수신자가 알림을 켜고 끌 수 있는 설정은 협업 참고를 보세요.