diff --git a/CLAUDE.md b/CLAUDE.md index ec76d8d..3d88d36 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 프로젝트 -NOMOS 서버 — 여러 개발자가 각자 노트북에서 Claude Code 에이전트를 돌릴 때 그 사이의 계약·권한·분쟁을 조율하는 서버. 현재 구현 범위는 조직·레포·경로 소유권·초대, 로컬 계정과 CLI 연결(브라우저 승인 포함), 프로젝트·태스크·산출물·인계 노트·검증, 명세·태스크 작성, 내장 PM의 계획 수립, 승인 대기열(ACTION 게이트), 화면용 실시간 신호(마이그레이션 016까지), AWS 배포다. +NOMOS 서버 — 여러 개발자가 각자 노트북에서 Claude Code 에이전트를 돌릴 때 그 사이의 계약·권한·분쟁을 조율하는 서버. 현재 구현 범위는 조직·레포·경로 소유권·초대, 로컬 계정과 CLI 연결(브라우저 승인 포함), 프로젝트·태스크·산출물·인계 노트·검증, 명세·태스크 작성, 내장 PM의 계획 수립, 승인 대기열(ACTION 게이트), 화면용 실시간 신호, 역할별 룸 피드(마이그레이션 017까지), AWS 배포다. 설계 원칙(위반 금지): **P1** 상태는 서버가 소유하고 클라이언트는 전이를 요청만 한다. **P3** 모든 행동은 사람에게 귀속된다(`on_behalf_of` 없는 이벤트는 없다). **P5** 모든 상태 변화는 `events`에 append되고 events가 유일한 진실이다. @@ -44,7 +44,7 @@ npx vitest run -t "기본 경로 규칙" # 이름으로 단일 테스트 - **이미 설정된 환경변수가 `.env`보다 우선한다**(`loadEnvFile`의 동작, 실측 확인). `DATABASE_URL=... npm run seed`로 한 번만 덮어쓸 수 있다. - `NODE_ENV`가 `production` 또는 **`test`**면 `.env`를 읽지 않는다. 테스트는 `vitest.config.ts`의 `test.env`만 쓴다 — - 읽으면 개발용 `DATABASE_URL`·`GITHUB_TOKEN`이 테스트로 새어 **테스트가 개발 DB를 TRUNCATE**하거나 실제 네트워크를 호출한다. + 읽으면 개발용 `DATABASE_URL`·`ANTHROPIC_API_KEY`가 테스트로 새어 **테스트가 개발 DB를 TRUNCATE**하거나 실제 네트워크를 호출한다. `tests/env-loading.test.ts`가 이 격리를 고정한다. - `npm run migrate`는 node-pg-migrate라는 **별도 프로세스**라 `env.ts`를 거치지 않는다. 그래서 npm 스크립트에서 Node의 `--env-file-if-exists=.env`로 같은 파일을 읽힌다. 새 CLI 진입점을 추가하면 같은 처리를 해줄 것. @@ -144,7 +144,10 @@ await withTransaction(async (tx) => { ### GitHub 레포 만들기·협업자 초대 (`domain/github/rep-api.ts`) -대표 본인의 GitHub 토큰(`oauth_sessions`)으로 GitHub를 **바꾸는** 호출은 여기 한 곳이다. 읽기 전용인 `github/client.ts`(서버의 `GITHUB_TOKEN`)와 섞지 말 것. +GitHub API는 **그 조직 대표의 GitHub 토큰(`oauth_sessions`)으로만** 부른다 — 레포 목록·조회·협업자 확인(읽기)과 레포 만들기·초대(쓰기) 모두 여기 한 곳. +**서버 공용 PAT(`GITHUB_TOKEN`)를 다시 두지 말 것** — PAT 하나로 읽으면 모든 조직이 그 PAT 주인의 레포 목록을 보게 된다(운영 테스트 보고서가 "PAT 추가"를 제안했다가 되돌림). +대표가 GitHub를 연결하지 않았으면 목록은 빈 배열, 협업자 표시는 생략(`false`로 채우지 않는다). 직접 입력(fullName)으로 연결해도 대표 토큰으로 레포를 찾아 +`github_repo_id`·기본 브랜치·`clone_url`을 채운다(비어 있으면 V3가 SKIPPED였다). 못 찾으면 예전처럼 비워 두고 연결은 막지 않는다. - **레포는 GitHub 조직에만, 비공개로 만든다**(`POST /orgs/:orgId/github/repos`, 대표 전용). 팀 프로젝트라 개인 계정에는 만들지 않는다(대표 결정). 만들면서 바로 연결한다 — 기존 연결과 같은 `connectOneRepo` 한 벌이고, `ownerRole`(필수)·`github_repo_id`·`clone_url`이 채워져 V3가 처음부터 돈다. @@ -155,6 +158,8 @@ await withTransaction(async (tx) => { - **역할 배정 시 협업자 자동 초대**: 그 에이전트 주인의 `github_login`(멤버가 "GitHub 연결"로 등록 — 가입 때 아이디를 손으로 받지 않는다. 본인 확인이 안 된다)을 프로젝트의 GitHub 레포마다 push 권한으로 초대한다. 배정을 먼저 커밋하고 그 뒤에 부른다 — **초대 실패가 배정을 막지 않는다**(결과 `githubInvites`, 이벤트 `GITHUB_COLLABORATORS_INVITED`). 미연결·실패는 `POST .../members/:agentId/github-invite`로 다시 보낸다(시작 뒤에도 된다). 초대는 멤버가 GitHub에서 수락해야 효력이 있다. + 결과는 따로 저장하지 않는다 — 응답의 멤버 정보(`ProjectMember.githubInvites`)에 **그 이벤트에서 읽은 멤버별 마지막 결과**를 붙인다(새로고침하면 사라진다는 FE 보고). + `invited`는 보냈다는 뜻이고 수락 여부는 멤버 목록의 `isCollaborator`다. - GitHub 조직이 OAuth 앱 접근 제한을 켜 두었으면 그 조직에서 NOMOS 앱을 승인해야 목록·생성이 된다(403 `GITHUB_FORBIDDEN`). ### 경로 소유권 모델 @@ -181,7 +186,7 @@ owner가 NULL이므로, 그 파일들은 `**`의 소유 역할을 따른다. 상 그래서 **소유 역할이 지정된 경로 규칙이 하나도 없는 레포는 프로젝트에 넣을 수 없다**(`createProject`, 422 `REPO_OWNERSHIP_NOT_SET`, 메시지에 막힌 레포 전부와 부를 API를 함께 적는다). 그대로 두면 아무도 쓸 수 없는 레포라 원인이 제출 시점에야 드러나기 때문이다. 기준은 "하나라도 지정됐는가"이지 "`**`가 지정됐는가"가 아니다 — 모노레포처럼 `apps/*/**`만 나누고 루트를 비워 두는 설계를 막지 않는다. 그 경우 루트 파일은 기본 거부다(열리지 않는다). -각 규칙의 `action_key`는 **허용 레벨 정책표**(`action_catalog`, 17행)의 "탐지" 열을 경로로 옮긴 것이다. 정책표는 행동마다 누가 승인하는가(AUTO / PM_REVIEW / HUMAN / FORBIDDEN)를 L1~L4 열로 정의하고, `locked_mode`(🔒) 행은 네 레벨이 모두 같아야 한다는 CHECK로 DB가 막는다. **`PM_REVIEW`는 PM이 반려만 할 수 있고 통과는 AUTO 검증이 결정한다** — LLM이 게이트를 열 수 없다. PM이 응답하지 못할 때(타임아웃·예산 소진) AUTO 강등은 **PM_REVIEW 한 칸에만** 적용하고(🔒 행은 원천 제외, HUMAN·FORBIDDEN은 절대 강등 없음) 같은 트랜잭션에 `PM_REVIEW_DEGRADED`(사유·`policy_hash`)를 남긴다(`domain/policy/pm-review-fallback.ts`). `package.json`의 `dep:add`는 경로가 아니라 dependencies/devDependencies diff로 판정하도록 **판정기만 있고 연결되지 않았다**(`domain/policy/dependency-diff.ts`, 읽을 수 없으면 dep:add로 취급) — 지금은 `package.json`을 고쳐도 `dep:add`가 걸리지 않는다. 시드 40번(`requirements.txt`)만 경로로 잡힌다. `package.json`을 경로 행으로 추가해 메우지 말 것: scripts만 고친 변경까지 dep:add가 되어 매번 대표 승인 대기열에 쌓인다. 제출·V3가 변경 전후 내용을 읽을 때 연결한다. 비밀 파일 행의 `action_key`를 NULL로 두면 `code:own_path`로 해석되므로 반드시 `secret:touch`여야 한다. 시드 목록을 바꾸면 `source='seed'` 행만 골라 기존 레포에도 반영하는 마이그레이션을 같이 쓴다(004·005가 선례). +각 규칙의 `action_key`는 **허용 레벨 정책표**(`action_catalog`, 17행)의 "탐지" 열을 경로로 옮긴 것이다. 정책표는 행동마다 누가 승인하는가(AUTO / PM_REVIEW / HUMAN / FORBIDDEN)를 L1~L4 열로 정의하고, `locked_mode`(🔒) 행은 네 레벨이 모두 같아야 한다는 CHECK로 DB가 막는다. **`PM_REVIEW`는 PM이 반려만 할 수 있고 통과는 AUTO 검증이 결정한다** — LLM이 게이트를 열 수 없다. PM이 응답하지 못할 때(타임아웃·예산 소진) AUTO 강등은 **PM_REVIEW 한 칸에만** 적용하고(🔒 행은 원천 제외, HUMAN·FORBIDDEN은 절대 강등 없음) 같은 트랜잭션에 `PM_REVIEW_DEGRADED`(사유·`policy_hash`)를 남긴다(`domain/policy/pm-review-fallback.ts`). `package.json`의 `dep:add`는 경로가 아니라 dependencies/devDependencies diff로 판정한다(`domain/policy/dependency-diff.ts`). **서버 검증 중에** 커밋 직전·직후의 `package.json`(어느 깊이든)을 V3 검사기(`fileVersions`)로 읽어, 새 패키지·버전 변경이 있으면(새로 만든 `package.json`도) 산출물에 `dep:add`를 더하고 판정을 더 엄격한 쪽으로 다시 계산한다(`ACTION_DETECTED`, 운영 테스트 4-7). **못 읽으면 dep:add로 본다**(fail closed). 시드 40번(`requirements.txt`)은 여전히 경로로 잡힌다. `package.json`을 경로 행으로 추가하지 말 것: scripts만 고친 변경까지 dep:add가 되어 매번 대표 승인 대기열에 쌓인다. 비밀 파일 행의 `action_key`를 NULL로 두면 `code:own_path`로 해석되므로 반드시 `secret:touch`여야 한다. 시드 목록을 바꾸면 `source='seed'` 행만 골라 기존 레포에도 반영하는 마이그레이션을 같이 쓴다(004·005가 선례). - `priority`는 **항상 명시적 정수**이고 **레포 안에서 유일**하다(`uq_repo_paths_priority`). 대역은 `0~99` seed · `100~199` scan · `200~299` manual · `900+` 조직 상한이고 source별 CHECK(`repo_paths_priority_band_chk`)가 강제한다. manual 규칙은 priority를 안 주면 대역의 최댓값 + 1. "더 구체적인 패턴이 이긴다" 같은 규칙 기반 판정은 금지 — 재현 실험이 성립하려면 어느 규칙이 이기는지가 결정적이어야 한다. - `resolveRule`은 매칭 규칙 중 priority 최대값을 고르고, 동점이면 `path_pattern` **사전순 오름차순**으로 tie-break한다. priority가 유일하므로 DB 데이터에선 폴백이 발동하지 않지만, 함수 자체의 결정성은 유지한다. @@ -242,6 +247,11 @@ owner가 NULL이므로, 그 파일들은 `**`의 소유 역할을 따른다. 상 셸은 `ALLOWED_BASH_PREFIXES`(git·시험 실행)만. 없으면 모델이 계획만 세우고 "쓰기 권한을 승인해 달라"며 커밋 없이 끝난다(실제로 그랬다). **임의 셸(`Bash`·`Bash(*)`)을 열지 말 것** — 셸로 파일을 쓰면 Edit deny(.env·contracts)를 우회한다. Executor가 작업공간에 쓰는 파일(`.nomos-*`, `.claude/settings.json` 등)은 레포 `info/exclude`에 넣어 모델 커밋에 섞이지 않게 한다(섞이면 V3 FAIL). +- **의존성 설치는 Executor가 Claude 전에 한다**(`executor/setup.ts`) — 모델에게 `npm install`·`pip install`을 열지 말 것(설치 스크립트가 Edit 금지를 우회한다). + npm 계열은 `--ignore-scripts`(잠금 파일 없으면 `--no-package-lock` — 새 파일이 커밋에 섞인다), pip는 작업공간 가상환경(`.nomos-venv`, PATH 앞에 붙인다)에 + `--only-binary :all:`(소스 빌드는 코드 실행이다). 실패해도 진행하고 결과를 프롬프트에 적는다. `node_modules/`·`.nomos-venv/`도 info/exclude에 있다. +- 허용 명령에는 **시험 실행기만** 더한다(`python -m pytest`·`unittest`·`pnpm test` 등). 프롬프트(`# 작업 환경`)에 허용 목록·"`&&`·`;`·`|`로 잇지 말 것"· + "파일은 Read/Glob/Grep"·"막히면 이유를 마지막 메시지에"를 적는다 — 운영 테스트에서 BE가 거부된 명령을 바꿔 가며 13번 시도했다(41턴·$1.84). - **`--mcp-config`와 `--strict-mcp-config`는 항상 함께 간다**(`src/bridge/claude-args.ts`). 후자가 빠지면 사용자의 `~/.claude.json`에 등록된 MCP 서버가 함께 로드되고, GitHub MCP가 살아 있으면 `submit_artifact`를 건너뛰고 직접 push할 수 있다. 그러면 제출 시점 경로 검증이 아무것도 못 막는다. `tests/bridge-args.test.ts`가 이걸 고정한다. @@ -413,6 +423,7 @@ PM_REVIEW 반려·피드백 분류·이의 설명·보고서는 아직 없다. 바뀌고 나머지 흐름은 같다(`domain/pm/relay.ts`, 대기열은 메모리). 작업은 **그 조직 대표 본인의 에이전트만** 가져간다(`GET /pm/jobs/next`) — 작업에 프로젝트 맥락이 들어 있고 결과가 곧 초안이다. 아무도 안 가져가면 `PM_TIMEOUT_MS`로 `failed(timeout)`. 개인 구독을 여러 사용자의 PM으로 쓰지 말 것 — 운영은 API 모드(기본)다. 워커는 지침을 파일·프롬프트를 stdin으로 넘긴다(Windows 명령줄 32k자 제한). +- **PM은 INTEGRATION 태스크를 만들지 않는다** — 통합 확인 항목(`integrationChecks`)을 쓰고 대표가 G3에서 확인한다(위 "승인 대기열"). - **PM 지침의 선행(dependsOn)**: 계약만 보고 만들 수 있으면 걸지 않는다(병행). 상대가 구현하며 정할 것·실제 동작하는 상대 API에 기대면 건다. 계약을 먼저 정해야 하는데 명세에 다 못 적으면 "계약 확정"(DECIDED 노트) 태스크로 쪼개고 상대는 거기에만 건다. 선행을 거는 것 = 그 결과와 노트를 받고 시작한다는 뜻이다. - **PM은 시험지(spec_tests)를 쓰지 않는다 — 의도된 결정이다. PM 명세의 `tests: []`는 버그가 아니다.** 명세 content에 계약(API: 메서드·경로·상태코드·응답 필드 / @@ -427,9 +438,17 @@ PM_REVIEW 반려·피드백 분류·이의 설명·보고서는 아직 없다. 그 전에 레포별 시험 하네스(기동·시드·인증)를 헌법에 먼저 정할 것. - 모델·노력·한도는 설정값(`PM_MODEL` 기본 `claude-sonnet-5-5`, `PM_EFFORT` 기본 `high`, `PM_MAX_TOKENS`). 생각 토큰도 출력으로 과금되니 실제 비용을 보고 조정한다. -### 승인 대기열 (`domain/approval`, 015) — ACTION 게이트만 +### 승인 대기열 (`domain/approval`, 015) — ACTION 게이트 + G3 -검증은 통과했지만 정책 판정이 HUMAN·PM_REVIEW라 `AWAITING_APPROVAL`에 멈춘 산출물을 사람이 결정한다. G1·G2·G3 카드와 PM 예산 초과 승인은 아직 없다. +검증은 통과했지만 정책 판정이 HUMAN·PM_REVIEW라 `AWAITING_APPROVAL`에 멈춘 산출물을 사람이 결정한다(ACTION). G1·G2 카드와 PM 예산 초과 승인은 아직 없다. + +- **G3 = 통합 확인·완료 승인**(운영 테스트 4-3, 대표 결정). 통합 확인은 에이전트가 아니라 대표가 한다 — 에이전트에게는 선행 태스크 코드(main에 머지되지 않는다)· + 상대 역할의 레포·서버 실행 수단이 없어 통합 태스크가 늘 멈췄다. **PM은 INTEGRATION 태스크를 만들지 않고**(`pmOnlyProblems` → 교정) `integrationChecks`를 쓴다. + 태스크가 DONE이 되는 같은 트랜잭션(검증 결론·ACTION 승인)에서 `requestReleaseIfComplete`가 "모든 태스크 DONE"이면 카드를 하나 만든다 + (확인 항목 + 태스크별 레포·브랜치·마지막 커밋 스냅샷, `RELEASE_REQUESTED`·`system:pm`). 프로젝트 행을 `FOR NO KEY UPDATE`로 잠근 **뒤 다음 문장에서** 다시 읽는다 — + 두 태스크가 동시에 끝나면 서로의 DONE이 안 보여 둘 다 카드를 안 만든다. 대기 카드는 `ON CONFLICT DO NOTHING`(상태 변경을 롤백시키지 않는다). + 승인 → 프로젝트 `completed`, 반려 → 진행 중 그대로(고칠 태스크는 대표가 만든다, 다시 전부 DONE이면 새 카드). `RELEASE_DECIDED`. 응답의 `taskId`는 G3면 null. + 사람이 API로 만드는 INTEGRATION 태스크는 아직 막지 않는다(기존 데이터·테스트가 명세 없는 종류로 쓴다). - **결정은 대표만 한다.** "해당 역할의 사람"은 곧 제출한 에이전트의 주인이라 자기 승인이 된다. 대표가 자기 에이전트의 산출물을 승인하는 것은 지금은 허용하되(다른 승인자가 없다) `APPROVAL_RESULT.payload.selfApproval: true`로 남긴다 — 다른 승인자 규칙을 만들 때의 근거. @@ -442,6 +461,31 @@ PM_REVIEW 반려·피드백 분류·이의 설명·보고서는 아직 없다. - 결정 뒤 `tasksChanged` — 승인이면 뒤 태스크가 풀리고 반려면 이 태스크가 다시 READY다. - 015는 이미 `AWAITING_APPROVAL`에 멈춰 있던 태스크에 **지금 DB 값으로** 카드를 만든다(`payload.backfilled: true`, 단계 결과 없음). +### 룸 (`domain/room`, 017) — 보기 전용 + +룸 = **프로젝트 × 역할**. 테이블이 없다(역할당 에이전트가 하나라 구성이 저절로 정해진다). 피드는 세 가지를 시각순으로 섞은 한 줄씩이고 **문장은 서버가 만든다**(`room/render.ts`). +- **pm**(서버): "실행해 주세요"(`TASK_DISPATCHED`), 검증 결과, 승인, 프로젝트 시작·계획 적용(프로젝트 단위는 **모든 룸**에). + 검증은 **결론이 난 줄만** 낸다(VERIFYING 중간 보고·`APPROVAL_REQUESTED`는 줄을 만들지 않는다 — 실제 실행에서 같은 말이 세 줄씩 겹쳤다). +- **nomos**(Executor 보고): 에이전트 실행(`AGENT_RUN_STARTED`), 수령, 제출, 실행 종료(`AGENT_RUN_ENDED`). + 태스크는 실행 **안에서** 모델이 잡으므로(`claim_task`) 실행 시작 시점의 태스크는 보통 READY다 — 시작 보고는 "잡고 있음 또는 지금 잡을 수 있음"이면 받는다(실측: 잡은 뒤로 제한했더니 실제 실행의 시작 보고가 전부 거부됐다). +- **agent**: 도구 사용(`agent_activity`)과 인계 노트. + +- **팀원은 자기 역할 룸만, 대표는 전부**(`ROOM_NOT_VISIBLE`). 판정은 `room/service.ts`의 `visibleRoles` 한 곳. +- **"실행해 주세요"는 서비스마다 부르지 않는다.** `tasksChanged` 신호를 받아(`room/dispatch.ts`, `createApp`에서 구독) 가져갈 수 있게 된 태스크를 attempt마다 + 한 번 기록한다 — `task_dispatches` PK가 중복을, 같은 트랜잭션의 이벤트가 P5를 지킨다. 조건은 `listClaimableTasks`에서 역할만 뺀 것이다(둘이 갈리면 지시는 왔는데 못 가져간다). + 테스트는 `truncateAll`이 `drainTasksChanged()`로 리스너를 기다린 뒤 TRUNCATE한다(겹치면 교착). +- **제출 여부는 서버가 정한다.** Executor는 정상 종료·시간 초과·비정상과 커밋 여부만 보고하고, 서버가 태스크 상태로 `submitted`를 정해 + "끝났지만 제출하지 않았다"를 룸에 남긴다 — 데모에서 팀원 터미널에만 보이던 막다른 길이다. +- **활동은 도구 종류와 대상(작업공간 기준 경로·명령 첫 줄, 300자)만** 받는다. 파일 내용·명령 결과·모델 설명 문장은 받지 않는다(설명은 인계 노트의 몫, 대표 결정). + 상태 변화가 아니라 `events`가 아닌 `agent_activity`이고, 열린 실행(STARTED 뒤 ENDED 전)에만 붙는다. 화면 신호는 `room/activity-hub` → `/api/stream`의 `room` 토픽(서버 1대 전제). +- **제출 없이 끝난 태스크는 BLOCKED(`AGENT_STOPPED`)로 멈춘다**(`task/stall.ts`, 종료 보고와 같은 트랜잭션). CLAIMED로 두면 아무도 다시 못 가져가고(운영 테스트의 영구 정체), + READY로 되돌려 재시도를 깎으면 권한·환경 탓(쉘 거부·선행 코드 없음)을 에이전트 실패로 센다(대표 결정). **retry_count는 검증 실패·반려만 쓴다.** + 사유는 `TASK_BLOCKED`에 — 모델의 마지막 말과 Executor가 실행 기록에서 뽑은 **거부된 쉘 명령**(대표가 무엇을 풀어야 하는지). 대표가 `POST /tasks/:id/resume`으로 재개하면 READY(담당 비움), + `task_dispatches`의 resumes가 달라져 "다시 실행해 주세요 (재개)"가 다시 나간다. Executor가 꺼져 종료 보고조차 없으면 감시(`startStallWatchdog`, `startServer`에서만)가 + 45분 동안 실행 보고·도구 사용이 없는 수령을 같은 방식으로 멈춘다(`system:watchdog`). `tasks.claimed_at`(마지막 수령 시각)이 그 기준이자 화면의 "수령 후 경과"다. +- Executor의 룸 보고(`executor/activity.ts`)는 **실패해도 실행을 멈추지 않는다.** 옛 서버에는 API가 없으니 시작 보고가 실패하면 그 실행의 활동·종료 보고를 건너뛴다. +- 사람 메시지(룸 채팅)는 아직 없다. 넣을 때도 **실행 중인 모델에 끼워 넣지 않는다**(인계 노트와 같은 이유 — 리플레이가 깨지고 노트가 채널이 된다). + ### 프로젝트와 멤버 - 프로젝트 생성은 **한 트랜잭션**에서 프로젝트 행·레포 연결·`project_policies` 17행 복사·헌법 스냅샷·`policy_hash`를 @@ -506,7 +550,7 @@ PM_REVIEW 반려·피드백 분류·이의 설명·보고서는 아직 없다. 도메인 코드는 `src/errors.ts`의 `AppError`만 던진다. 상세(`details`)는 `PUBLIC_DETAIL_CODES`에 있는 코드만 응답에 싣고, 배열이든 객체든 `error.details`에 담는다 (`POLICY_STALE`만 예전 모양대로 `error.reason`으로 펼친다 — 배포된 브릿지가 읽는다). HTTP 상태는 `STATUS_BY_CODE` 테이블이 code로부터 결정하므로 서비스는 상태 코드를 몰라도 된다. `error-handler`는 항상 마지막에 등록하고, AppError가 아닌 예외는 500으로 감추고 상세는 로그로만 남긴다. -**GitHub API 실패가 우리 기능을 멈추면 안 된다.** collaborator 조회가 실패해도 members 목록은 반환된다 — try/catch로 감싸고 `isCollaborator` 필드를 **생략**한다. `false`로 채우지 말 것: "확인 안 됨"과 "권한 없음"은 다르다. 토큰이 없을 때 레포 목록 조회는 500이 아니라 빈 배열 + 경고 로그를 반환한다. +**GitHub API 실패가 우리 기능을 멈추면 안 된다.** collaborator 조회가 실패해도 members 목록은 반환된다 — try/catch로 감싸고 `isCollaborator` 필드를 **생략**한다. `false`로 채우지 말 것: "확인 안 됨"과 "권한 없음"은 다르다. 대표가 GitHub를 연결하지 않았을 때 레포 목록 조회는 500이 아니라 빈 배열 + 경고 로그를 반환한다. ## 배포 @@ -602,7 +646,7 @@ const { repoId } = req.params as z.infer ### 라우터 마운트 -라우터 13개(`auth`, `agents`, `orgs`, `repos`, `repo-paths`, `invites`, `oauth`, `approvals`, `tasks`, `notes`, `projects`, `specs`, `pm`)가 전부 `app.use("/api", ...)`로 마운트되고(`/health`·`/docs`는 `/api` 밖), 각 파일이 `/orgs/:orgId/...` 같은 전체 경로를 직접 선언한다. 그래서 URL 접두사가 아니라 **도메인 기준**으로 파일이 나뉜다 — 예를 들어 `POST /api/orgs/:orgId/repos`는 URL은 orgs 밑이지만 `routes/repos.ts`에 있고, `POST /api/orgs/:orgId/invites`는 `routes/invites.ts`에 있다. +라우터 14개(`auth`, `agents`, `orgs`, `repos`, `repo-paths`, `invites`, `oauth`, `approvals`, `tasks`, `notes`, `projects`, `specs`, `pm`, `rooms`)가 전부 `app.use("/api", ...)`로 마운트되고(`/health`·`/docs`는 `/api` 밖), 각 파일이 `/orgs/:orgId/...` 같은 전체 경로를 직접 선언한다. 그래서 URL 접두사가 아니라 **도메인 기준**으로 파일이 나뉜다 — 예를 들어 `POST /api/orgs/:orgId/repos`는 URL은 orgs 밑이지만 `routes/repos.ts`에 있고, `POST /api/orgs/:orgId/invites`는 `routes/invites.ts`에 있다. ## 스키마 변경 규칙 diff --git a/docs/erd.dbml b/docs/erd.dbml index 15989cf..c72d79f 100644 --- a/docs/erd.dbml +++ b/docs/erd.dbml @@ -606,6 +606,39 @@ Table disputes { Note: 'PM은 verdict를 뒤집을 수 없음' } +Table task_dispatches { + task_id uuid [pk, not null] + attempt integer [pk, not null, note: '그때의 tasks.retry_count. 재시도로 다시 READY가 되면 새 행'] + dispatched_at timestamptz [not null, default: `now()`] + + Note: ''' + ★ 017 룸: 서버(PM)가 "이 태스크를 실행해 주세요"를 보낸 기록. 가져갈 수 있게 된 순간(시작됨·READY·담당 없음·선행 완료) attempt마다 한 번. + tasksChanged 신호마다 계산하고, PK가 중복을 막는다. 새로 들어간 행에만 TASK_DISPATCHED(system:dispatcher)를 같은 트랜잭션에 남긴다. + CHECK attempt >= 0 + ''' +} + +Table agent_activity { + id bigserial [pk] + project_id uuid [not null] + task_id uuid [not null] + agent_id uuid [not null] + kind text [not null, note: 'read | edit | write | run | search | other'] + target text [not null, note: '작업공간 기준 경로 또는 명령 첫 줄, 1~300자'] + ts timestamptz [not null, default: `now()`] + + indexes { + (project_id, id) + } + Note: ''' + ★ 017 룸: 에이전트가 실행 중에 쓴 도구 한 줄씩(Executor가 Claude stream-json에서 뽑아 보낸다). + 상태 변화가 아니라 events에 넣지 않는다. 파일 내용·명령 결과·모델 설명은 받지 않는다(설명은 인계 노트의 몫). + 열린 실행(AGENT_RUN_STARTED 뒤 AGENT_RUN_ENDED 전)에만 붙는다. + agent_activity_kind_chk kind IN ('read','edit','write','run','search','other') + agent_activity_target_chk char_length(target) BETWEEN 1 AND 300 + ''' +} + Table approvals { id uuid [pk, default: `gen_random_uuid()`] project_id uuid [not null] @@ -800,6 +833,11 @@ Ref: disputes.raised_by > agents.id Ref: disputes.target_task_id > tasks.id Ref: disputes.blocking_task_id > tasks.id +Ref: task_dispatches.task_id > tasks.id +Ref: agent_activity.project_id > projects.id +Ref: agent_activity.task_id > tasks.id +Ref: agent_activity.agent_id > agents.id + Ref: approvals.project_id > projects.id [delete: cascade] Ref: approvals.decided_by > users.id Ref: approvals.artifact_id > artifacts.id diff --git a/docs/manual-test.md b/docs/manual-test.md index 6312a91..23057c3 100644 --- a/docs/manual-test.md +++ b/docs/manual-test.md @@ -165,7 +165,6 @@ cp .env.example .env | `APP_BASE_URL` | ❌ | — | **두 값으로 나뉘었다.** 남아 있으면 서버가 뜨지 않는다 — 지울 것 | | `DOCS_ENABLED` | | `true` | 로컬 개발은 켜짐, 그 외 꺼짐 | | `CORS_ALLOWED_ORIGINS` | | `http://localhost:3001` | CORS 헤더 없음(같은 오리진만) | -| `GITHUB_TOKEN` | | GitHub PAT | 레포 목록 조회가 **빈 배열**을 반환한다 (500이 아니다) | | `GITHUB_CLIENT_ID` · `GITHUB_CLIENT_SECRET` | | OAuth App 값 | GitHub Device Flow 두 API만 502 | | `SECRET_ENCRYPTION_KEY` | | base64 32바이트 | Device Flow **승인 완료 시점**에만 실패 | | `KMS_KEY_ID` | | **비워둘 것** | — | @@ -363,7 +362,7 @@ curl -s $BASE/orgs/$ORG_ID/github/repos -H "Authorization: Bearer $TOKEN" curl -s $BASE/orgs/$ORG_ID/members -H "Authorization: Bearer $TOKEN" ``` -`GITHUB_TOKEN`이 없으면 레포 목록은 **빈 배열**이고, 멤버 목록에서는 `isCollaborator` 필드가 **생략**된다 +대표가 GitHub를 연결하지 않았으면 레포 목록은 **빈 배열**이고, 멤버 목록에서는 `isCollaborator` 필드가 **생략**된다(GitHub 호출은 전부 대표의 토큰으로 한다 — 서버 공용 PAT는 없다) (`false`로 채우지 않는다 — "확인 안 됨"과 "권한 없음"은 다르다). 시드 계정은 이미 조직에 속해 있으므로 `POST /api/orgs`는 409가 정상이다. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 7090792..09b25af 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -160,8 +160,13 @@ components: id: { type: string, format: uuid } projectId: { type: string, format: uuid } projectName: { type: string } - gate: { type: string, enum: [G1, G2, G3, ACTION], description: '지금은 ACTION(정책 판정이 HUMAN·PM_REVIEW인 산출물)만 생긴다' } - taskId: { type: string, format: uuid } + gate: + type: string + enum: [G1, G2, G3, ACTION] + description: | + - ACTION: 검증은 통과했지만 정책 판정이 HUMAN·PM_REVIEW인 산출물(태스크 하나) + - G3: **통합 확인·완료 승인**(프로젝트 전체). 모든 태스크가 DONE이 되면 생긴다. 승인하면 프로젝트 completed, 반려하면 진행 중 그대로(고칠 태스크를 대표가 만든다) + taskId: { type: string, format: uuid, nullable: true, description: 'ACTION이면 그 태스크, G3면 null(프로젝트 전체)' } taskTitle: { type: string, nullable: true, description: '지금 태스크 제목(카드의 스냅샷은 payload.taskTitle)' } taskState: { type: string, nullable: true, description: '지금 태스크 상태' } artifactId: { type: string, format: uuid, nullable: true } @@ -174,7 +179,9 @@ components: type: object additionalProperties: true description: | - 요청 시점의 카드 스냅샷 — taskTitle, artifactId, attempt, commitSha, changedPaths, triggeredActions, gateMode, stages, submittedByAgentId. + 요청 시점의 카드 스냅샷. + - ACTION: taskTitle, artifactId, attempt, commitSha, changedPaths, triggeredActions, gateMode, stages, submittedByAgentId. + - G3: integrationChecks(PM이 쓴 확인 항목 문자열 목록), tasks([{ taskId, title, teamRole, repo, branchName, commitSha }] — 머지·실행해 볼 브랜치와 마지막 커밋). **backfilled: true**면 승인 기능 이전부터 멈춰 있던 태스크라 지금 DB에서 알 수 있는 값만 있다(stages 등 없음) — "정보 일부 없음"을 표시한다. decision: { type: string, enum: [APPROVE, REJECT, null], nullable: true, description: 'null이면 대기 중' } decidedBy: { type: string, format: uuid, nullable: true } @@ -332,6 +339,51 @@ components: - skipped: 멤버나 대표가 GitHub를 연결하지 않아 보내지 못했다(reason) - failed: GitHub가 거부·실패했다(reason) reason: { type: string } + RoomMessage: + type: object + required: [id, ts, speaker, type, taskId, taskTitle, text] + properties: + id: { type: string, description: '피드 커서로도 쓴다(before)' } + ts: { type: string, format: date-time } + speaker: + type: string + enum: [pm, nomos, agent] + description: | + - pm: 서버가 지시·판정을 알림(실행해 주세요, 검증 결과, 승인) + - nomos: Executor가 보고한 실행 상태(수령, 구현 시작, 제출, 실행 종료) + - agent: 에이전트가 쓴 도구(읽기·수정·실행) · 인계 노트 + type: + type: string + description: '원본 이벤트 타입(TASK_DISPATCHED 등) 또는 ACTIVITY_READ·ACTIVITY_EDIT·ACTIVITY_WRITE·ACTIVITY_RUN·ACTIVITY_SEARCH·ACTIVITY_OTHER. 아이콘용' + taskId: { type: string, format: uuid, nullable: true } + taskTitle: { type: string, nullable: true } + text: { type: string, description: '화면에 그대로 보여 줄 문장(서버가 만든다)' } + RoomSummary: + type: object + required: [role, agent, activeTasks] + properties: + role: { type: string, enum: [FRONTEND, BACKEND] } + agent: + type: object + nullable: true + description: 이 역할에 배정된 에이전트. 없으면 null + required: [agentId, agentName, online, lastSeenAt] + properties: + agentId: { type: string, format: uuid } + agentName: { type: string } + online: { type: boolean } + lastSeenAt: { type: string, format: date-time, nullable: true } + activeTasks: + type: array + description: 진행 중(CLAIMED·IN_PROGRESS·VERIFYING·AWAITING_APPROVAL) 태스크 + items: + type: object + required: [id, title, state, teamRole] + properties: + id: { type: string, format: uuid } + title: { type: string } + state: { type: string } + teamRole: { type: string, enum: [FRONTEND, BACKEND], nullable: true } RepoSettings: type: object required: [id, fullName, githubRepoId, defaultBranch, devBranch, cloneUrl] @@ -404,12 +456,26 @@ components: fullName: { type: string } ProjectMember: type: object - required: [agentId, agentName, teamRole, userId] + required: [agentId, agentName, teamRole, userId, githubInvites] properties: agentId: { type: string, format: uuid } agentName: { type: string } teamRole: { type: string, enum: [FRONTEND, BACKEND] } userId: { type: string, format: uuid, description: '에이전트 주인' } + githubInvites: + type: array + description: | + 레포별 **마지막** GitHub 협업자 초대 결과(배정·다시 초대 때 남은 이벤트에서 읽는다 — 새로고침해도 남는다). 초대한 적 없으면 빈 목록 + (GitHub 레포가 없는 프로젝트 등). invited는 초대장을 보냈다는 뜻이고 수락 여부는 GET /orgs/{orgId}/members의 isCollaborator로 본다. + items: + type: object + required: [repoId, fullName, status, at] + properties: + repoId: { type: string, format: uuid } + fullName: { type: string } + status: { type: string, enum: [invited, already_collaborator, skipped, failed] } + reason: { type: string } + at: { type: string, format: date-time, description: '그 초대를 시도한 시각' } ProjectDetail: type: object required: [project, repos, members] @@ -419,7 +485,7 @@ components: members: { type: array, items: { $ref: '#/components/schemas/ProjectMember' } } Task: type: object - required: [id, projectId, repoId, specId, kind, title, state, teamRole, assigneeAgentId, branchName, blockedReason, retryCount] + required: [id, projectId, repoId, specId, kind, title, state, teamRole, assigneeAgentId, branchName, blockedReason, retryCount, claimedAt] properties: id: { type: string, format: uuid } projectId: { type: string, format: uuid } @@ -431,7 +497,14 @@ components: teamRole: { type: string, enum: [FRONTEND, BACKEND], nullable: true } assigneeAgentId: { type: string, format: uuid, nullable: true } branchName: { type: string, nullable: true } - blockedReason: { type: string, enum: [DISPUTE, DEPENDENCY, QUESTION], nullable: true, description: 'state=BLOCKED일 때만 있다' } + blockedReason: + type: string + enum: [DISPUTE, DEPENDENCY, QUESTION, AGENT_STOPPED] + nullable: true + description: | + state=BLOCKED일 때만 있다. + AGENT_STOPPED = 에이전트가 제출 없이 멈췄다(권한·환경·응답 끊김). 사유는 이벤트 TASK_BLOCKED(룸 피드)에 있고, 대표가 `POST /tasks/{taskId}/resume`으로 재개한다. 재시도 횟수는 오르지 않는다 + claimedAt: { type: string, format: date-time, nullable: true, description: '마지막 수령 시각. 화면의 "수령 후 경과"에 쓴다(CLAIMED·IN_PROGRESS일 때 의미가 있다)' } retryCount: { type: integer, description: 'FAIL마다 오른다. 3이면 ESCALATED' } Artifact: type: object @@ -571,9 +644,15 @@ components: title: { type: string } repo: { type: string, description: '레포 fullName' } teamRole: { type: string, enum: [FRONTEND, BACKEND], nullable: true } - kind: { type: string, enum: [IMPLEMENT, INTEGRATION, REWORK] } + kind: { type: string, enum: [IMPLEMENT, INTEGRATION, REWORK], description: 'PM은 INTEGRATION을 내지 않는다(내면 교정 대상). 옛 초안에는 있을 수 있다' } spec: { type: string, nullable: true, description: '명세 featureKey' } dependsOn: { type: array, items: { type: string }, description: '먼저 끝나야 하는 태스크의 ref' } + integrationChecks: + type: array + items: { type: string } + description: | + 통합 확인 항목. PM은 INTEGRATION 태스크를 만들지 않고(에이전트에게는 선행 코드·상대 레포·실행 수단이 없다) 여기에 적는다. + 모든 태스크가 DONE이 되면 대표의 **G3 카드**(통합 확인·완료 승인)에 그대로 들어간다. 이 필드 이전에 만든 초안에는 없다. PmRelayJob: type: object description: '중계 모드에서 pm-worker가 실행할 모델 호출 한 건. 서버가 만든 지침·프롬프트·출력 스키마를 그대로 실행한다' @@ -830,6 +909,8 @@ tags: - name: 태스크·노트 - name: 검증 - name: 승인 + - name: 룸 + description: 프로젝트 × 역할. 서버(PM)의 지시·판정, Executor 실행 상태, 에이전트 도구 사용을 한 줄씩 paths: /auth/signup: @@ -1227,7 +1308,8 @@ paths: description: | 레포 연결이 조직 멤버 누구나 할 수 있으므로, 그 드롭다운을 채우는 이 목록도 함께 열려 있다. - `GITHUB_TOKEN`이 없으면 **500이 아니라 빈 배열**을 반환한다. + **그 조직 대표의 GitHub 토큰으로** 읽는다(서버 공용 토큰은 없다 — 있으면 모든 조직이 그 주인의 레포를 보게 된다). + 대표가 GitHub를 연결하지 않았으면 **500이 아니라 빈 배열**을 반환한다(직접 입력으로 연결할 수 있다). GitHub 실패가 우리 기능을 멈추게 하지 않는다. security: [{ userToken: [] }] parameters: [{ $ref: '#/components/parameters/OrgId' }] @@ -1339,7 +1421,7 @@ paths: tags: [조직] summary: 조직 멤버 목록 description: | - GitHub collaborator 조회가 실패하면 `isCollaborator` 필드를 **생략**한다. + 협업자 여부는 그 조직 대표의 GitHub 토큰으로 확인한다. 대표가 GitHub를 연결하지 않았거나 조회가 실패하면 `isCollaborator` 필드를 **생략**한다. `false`로 채우지 않는다 — "확인 안 됨"과 "권한 없음"은 다르다. security: [{ userToken: [] }] parameters: [{ $ref: '#/components/parameters/OrgId' }] @@ -2280,7 +2362,7 @@ paths: repos: - { id: '{{REPO_API}}', fullName: acme/study-api } members: - - { agentId: '{{AGENT_BE}}', agentName: be-laptop, teamRole: BACKEND } + - { agentId: '{{AGENT_BE}}', agentName: be-laptop, teamRole: BACKEND, githubInvites: [] } '403': description: 이 프로젝트의 멤버가 아님 content: @@ -2533,6 +2615,75 @@ paths: events: - { id: '1042', type: NOTE_PUBLISHED, ts: '2026-10-02T00:58:05.640Z', onBehalfOf: e8227252-a58b-4266-839b-8674f99b1e4a, actorAgentId: e6c879be-2a69-4f82-a30d-5063ff664262, payload: { noteId: 5c457d82-8804-4408-a76e-3f5a355b8484, kind: DECIDED }, tokenCost: null, pathViolation: null } nextBefore: null + /projects/{projectId}/rooms: + get: + tags: [룸] + summary: 내가 볼 수 있는 룸 목록 + description: | + 룸 = 프로젝트 × 역할. **팀원은 자기 에이전트가 배정된 역할 룸만, 대표는 전부** 본다. + 룸마다 담당 에이전트(온라인 여부)와 진행 중 태스크를 준다 — 룸 화면 머리. + security: [{ userToken: [] }] + parameters: [{ $ref: '#/components/parameters/ProjectId' }] + responses: + '200': + description: 룸 목록(FRONTEND, BACKEND 순) + content: + application/json: + schema: { type: object, required: [data], properties: { data: { type: object, required: [rooms], properties: { rooms: { type: array, items: { $ref: '#/components/schemas/RoomSummary' } } } } } } + + /projects/{projectId}/rooms/{role}/feed: + get: + tags: [룸] + summary: 룸 피드 (최신부터) + description: | + 서버(PM)의 지시·판정, Executor의 실행 상태, 에이전트의 도구 사용을 시각순으로 섞은 한 줄씩. **문장(`text`)은 서버가 만든다** — 화면은 그대로 보여 주면 된다. + + 예(오래된 것부터): + ``` + [pm] T-3 회원가입 API 실행해 주세요 + [nomos] 에이전트를 실행합니다 + [nomos] 태스크 수령 — T-3 회원가입 API · 구현을 시작합니다 + [agent] src/routes/auth.ts 읽는 중 + [agent] src/routes/auth.ts 수정 + [agent] npm test 실행 + [nomos] 제출했습니다 (커밋 a1b2c3d, 파일 3개) + [pm] 검증 완료 — V3 통과, V1A·V1B·V2·V4 건너뜀 → 완료되었습니다 + [nomos] 실행을 마쳤습니다 (240초) + ``` + 태스크에 딸린 줄은 그 태스크의 역할 룸에만(역할 제한 없는 태스크는 모든 룸), 프로젝트 단위 PM 알림(시작·계획 적용)은 모든 룸에 나온다. + 응답은 **최신부터**이고, `nextBefore`를 `before`로 넘기면 더 오래된 줄을 읽는다(없으면 null). + 실시간: `/api/stream`에서 그 프로젝트를 구독하면 `room` 토픽이 올 때 다시 읽는다. + 팀원이 다른 역할 룸을 읽으면 403 `ROOM_NOT_VISIBLE`. + security: [{ userToken: [] }] + parameters: + - $ref: '#/components/parameters/ProjectId' + - name: role + in: path + required: true + schema: { type: string, enum: [FRONTEND, BACKEND] } + example: BACKEND + - name: before + in: query + required: false + schema: { type: string } + description: 이전 응답의 nextBefore + - name: limit + in: query + required: false + schema: { type: integer, minimum: 1, maximum: 200, default: 50 } + responses: + '200': + description: 피드 + content: + application/json: + schema: { type: object, required: [data], properties: { data: { type: object, required: [messages, nextBefore], properties: { messages: { type: array, items: { $ref: '#/components/schemas/RoomMessage' } }, nextBefore: { type: string, nullable: true } } } } } + '403': + description: 자기 역할이 아닌 룸 + content: + application/json: + example: + error: { code: ROOM_NOT_VISIBLE, message: you cannot see the BACKEND room of this project } + /projects/{projectId}/members: post: tags: [프로젝트] @@ -2573,7 +2724,7 @@ paths: example: data: members: - - { agentId: '{{AGENT_BE}}', agentName: be-laptop, teamRole: BACKEND } + - { agentId: '{{AGENT_BE}}', agentName: be-laptop, teamRole: BACKEND, githubInvites: [] } githubInvites: - { repoId: '{{REPO_API}}', fullName: acme/study-api, status: invited } notice: 배정된 에이전트는 POST /api/agents/token/refresh로 토큰을 재발급해야 태스크·노트 API를 쓸 수 있습니다. @@ -3189,7 +3340,7 @@ paths: application/json: schema: { type: object, required: [data], properties: { data: { $ref: '#/components/schemas/Task' } } } example: - data: { id: 7f3b2e5a-9c4d-4e3f-b0a1-2c3d4e5f6071, projectId: '{{PROJECT_ID}}', repoId: '{{REPO_API}}', specId: null, kind: INTEGRATION, title: T-052 대기열 신청 버튼, state: READY, teamRole: FRONTEND, assigneeAgentId: null, branchName: null, blockedReason: null, retryCount: 0 } + data: { id: 7f3b2e5a-9c4d-4e3f-b0a1-2c3d4e5f6071, projectId: '{{PROJECT_ID}}', repoId: '{{REPO_API}}', specId: null, kind: INTEGRATION, title: T-052 대기열 신청 버튼, state: READY, teamRole: FRONTEND, assigneeAgentId: null, branchName: null, blockedReason: null, retryCount: 0, claimedAt: null } '403': description: 대표가 아니거나 다른 조직 content: @@ -3270,6 +3421,153 @@ paths: specTests: [{ id: 5f0e..., criterion: 정원이 찬 스터디 신청은 409다, lockedAt: '2026-09-21T00:00:00.000Z' }] policyHash: '{{POLICY_HASH}}' + /tasks/{taskId}/runs/start: + post: + tags: [룸] + summary: Claude 실행 시작 보고 (Executor) + description: | + Executor가 Claude를 띄우기 직전에 부른다. 룸에 "에이전트를 실행합니다"가 남는다(이벤트 `AGENT_RUN_STARTED`). + 태스크를 잡는 것(`claim_task`)은 실행 **안에서** 모델이 하므로, 이 에이전트가 그 태스크를 잡고 있거나(CLAIMED·IN_PROGRESS) + **지금 잡을 수 있으면**(READY·담당 없음·내 역할 또는 역할 제한 없음) 받는다. 아니면 409 `RUN_NOT_ALLOWED`. + security: [{ agentToken: [] }] + parameters: [{ $ref: '#/components/parameters/TaskId' }] + responses: + '201': + description: 시작 기록. attempt는 그때의 재시도 횟수 + content: + application/json: + schema: { type: object, required: [data], properties: { data: { type: object, required: [taskId, attempt], properties: { taskId: { type: string, format: uuid }, attempt: { type: integer } } } } } + '409': + description: 이 에이전트가 잡고 있지도, 잡을 수도 없는 태스크 + content: + application/json: + example: + error: { code: RUN_NOT_ALLOWED, message: task ... is not held by this agent (state READY) } + + /tasks/{taskId}/runs/end: + post: + tags: [룸] + summary: Claude 실행 종료 보고 (Executor) + description: | + Claude가 끝나면 부른다(이벤트 `AGENT_RUN_ENDED`). **제출 여부는 서버가 태스크 상태로 정한다** — 이 에이전트가 아직 잡고 있으면 + 제출하지 않은 것이다. 그때는 같은 트랜잭션에서 태스크가 **BLOCKED(AGENT_STOPPED)**로 바뀌고(이벤트 `TASK_BLOCKED`), 룸에 + "멈췄습니다 — 사유 / 거부된 명령 → 대표 확인 후 재개가 필요합니다"가 남는다. 재시도 횟수는 오르지 않는다. + Executor가 꺼져 이 보고조차 오지 않으면 서버 감시가 45분 동안 응답이 없는 수령을 같은 방식으로 멈춘다. + 열린 실행(시작하고 아직 안 끝낸 것)이 없으면 409 `RUN_NOT_OPEN`. + security: [{ agentToken: [] }] + parameters: [{ $ref: '#/components/parameters/TaskId' }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [outcome, committed, durationMs, exitCode] + properties: + outcome: { type: string, enum: [completed, timeout, failed], description: 'completed=정상 종료, timeout=시간 제한, failed=비정상 종료' } + committed: { type: boolean, description: '실행 동안 새 커밋이 생겼나' } + durationMs: { type: integer, minimum: 0 } + exitCode: { type: integer, nullable: true } + lastMessage: { type: string, maxLength: 2000, nullable: true, description: '모델의 마지막 말. 제출하지 않았으면 멈춤 사유로 룸에 보인다' } + deniedCommands: { type: array, maxItems: 20, items: { type: string, maxLength: 300 }, description: '실행 중 거부된 쉘 명령(Executor가 실행 기록에서 뽑는다)' } + example: { outcome: completed, committed: false, durationMs: 48000, exitCode: 0, lastMessage: 'git merge 권한이 없어 통합 확인을 할 수 없습니다', deniedCommands: ['git fetch --all'] } + responses: + '200': + description: 종료 기록 + content: + application/json: + schema: { type: object, required: [data], properties: { data: { type: object, required: [submitted, taskState, blocked], properties: { submitted: { type: boolean }, taskState: { type: string }, blocked: { type: boolean, description: '제출하지 않아 BLOCKED(AGENT_STOPPED)로 멈췄다' } } } } } + '409': + description: 열린 실행이 없음 + content: + application/json: + example: + error: { code: RUN_NOT_OPEN, message: no open run for task ... } + + /tasks/{taskId}/activity: + post: + tags: [룸] + summary: 실행 중 도구 사용 보고 (Executor) + description: | + Executor가 Claude 출력에서 도구 사용을 뽑아 2초마다 묶어 보낸다(최대 50줄). **도구 종류와 대상(작업공간 기준 경로·명령 첫 줄, 300자)만** 받는다 — + 파일 내용·명령 결과·모델 설명은 받지 않는다(설명은 인계 노트의 몫). 열린 실행에만 붙는다(409 `RUN_NOT_OPEN`). + 이벤트가 아니라 `agent_activity`에 쌓이고, 화면 스트림에는 `room` 토픽으로 알린다. + security: [{ agentToken: [] }] + parameters: [{ $ref: '#/components/parameters/TaskId' }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + minItems: 1 + maxItems: 50 + items: + type: object + required: [kind, target] + properties: + kind: { type: string, enum: [read, edit, write, run, search, other] } + target: { type: string, maxLength: 300 } + example: + items: + - { kind: read, target: src/routes/auth.ts } + - { kind: edit, target: src/routes/auth.ts } + - { kind: run, target: npm test } + responses: + '201': + description: 기록함 + content: + application/json: + schema: { type: object, required: [data], properties: { data: { type: object, required: [recorded], properties: { recorded: { type: integer } } } } } + '409': + description: 열린 실행이 없음 + content: + application/json: + example: + error: { code: RUN_NOT_OPEN, message: no open run for task ... } + + /tasks/{taskId}/resume: + post: + tags: [룸] + summary: 멈춘 태스크 재개 (대표 전용) + description: | + 에이전트가 제출 없이 멈춘 태스크(BLOCKED·`AGENT_STOPPED`)를 원인을 해결한 뒤 다시 돌린다. READY로 돌아가고 담당이 비어 + 같은 역할의 에이전트가 다시 가져간다(룸에 "다시 실행해 주세요 (재개)"). **재시도 횟수는 그대로다** — 권한·환경 탓은 에이전트 실패가 아니다. + 멈춘 상태가 아니면 409 `TASK_NOT_STOPPED`. + security: [{ userToken: [] }] + parameters: [{ $ref: '#/components/parameters/TaskId' }] + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + note: { type: string, maxLength: 500, nullable: true, description: '무엇을 해결했는지(룸에 보인다)' } + example: { note: 'python 실행을 허용했습니다' } + responses: + '200': + description: 재개됨(READY) + content: + application/json: + schema: { type: object, required: [data], properties: { data: { $ref: '#/components/schemas/Task' } } } + '403': + description: 대표가 아님 + content: + application/json: + example: + error: { code: NOT_REPRESENTATIVE, message: only the representative can resume a stopped task } + '409': + description: 멈춘 태스크가 아님 + content: + application/json: + example: + error: { code: TASK_NOT_STOPPED, message: 'task ... is not stopped (state CLAIMED, reason -)' } + /tasks/{taskId}/branch: patch: tags: [태스크·노트] diff --git a/migrations/017_rooms.sql b/migrations/017_rooms.sql new file mode 100644 index 0000000..2122362 --- /dev/null +++ b/migrations/017_rooms.sql @@ -0,0 +1,51 @@ +-- Up Migration + +-- 룸(프로젝트 × 역할)의 피드 재료 두 가지. 룸 자체는 테이블이 없다 — 역할당 에이전트가 하나라 구성이 저절로 정해진다. + +-- ① 서버(PM)가 "이 태스크를 실행해 주세요"를 보낸 기록. 가져갈 수 있게 된 순간(READY·담당 없음·선행 완료·시작됨) 한 번씩. +-- 재시도로 다시 READY가 되면 attempt(=그때의 retry_count)가, 대표가 재개하면 resumes(=TASK_RESUMED 수)가 달라 다시 보낸다. PK가 중복을 막는다 — +-- tasksChanged는 여러 번·동시에 불릴 수 있으므로 "한 번만"을 DB가 보장해야 한다. 이벤트(TASK_DISPATCHED)는 INSERT된 행에만 남긴다. +CREATE TABLE task_dispatches ( + task_id uuid NOT NULL REFERENCES tasks(id), + attempt integer NOT NULL CHECK (attempt >= 0), + resumes integer NOT NULL DEFAULT 0 CHECK (resumes >= 0), + dispatched_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (task_id, attempt, resumes) +); + +-- ② 에이전트가 실행 중에 쓴 도구 한 줄씩("src/a.ts 읽는 중", "npm test 실행"). Executor가 Claude 출력에서 뽑아 보낸다. +-- 상태 변화가 아니라서 events에 넣지 않는다(양이 많고, P5의 대상이 아니다). 파일 내용·명령 결과·모델 설명은 받지 않는다 — +-- 도구 종류와 대상(경로·명령)만. 설명은 인계 노트의 몫이다. +CREATE TABLE agent_activity ( + id bigserial PRIMARY KEY, + project_id uuid NOT NULL REFERENCES projects(id), + task_id uuid NOT NULL REFERENCES tasks(id), + agent_id uuid NOT NULL REFERENCES agents(id), + kind text NOT NULL, + target text NOT NULL, + ts timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT agent_activity_kind_chk CHECK (kind IN ('read', 'edit', 'write', 'run', 'search', 'other')), + CONSTRAINT agent_activity_target_chk CHECK (char_length(target) BETWEEN 1 AND 300) +); +CREATE INDEX idx_agent_activity_project ON agent_activity(project_id, id); + +-- ③ 에이전트가 제출 없이 멈춘 태스크는 BLOCKED(AGENT_STOPPED)로 둔다 — CLAIMED로 남으면 아무도 다시 가져가지 못하고(운영 테스트에서 영구 정체), +-- READY로 되돌려 재시도 횟수를 깎으면 권한·환경 탓을 에이전트 실패로 센다. 대표가 원인을 해결하고 재개하면 READY. +ALTER TABLE tasks DROP CONSTRAINT tasks_blocked_reason_chk; +ALTER TABLE tasks ADD CONSTRAINT tasks_blocked_reason_chk CHECK (blocked_reason IS NULL OR blocked_reason IN + ('DISPUTE', 'DEPENDENCY', 'QUESTION', 'AGENT_STOPPED')); + +-- ④ 마지막 수령 시각. 화면의 "수령 후 경과"와, 응답이 끊긴 에이전트를 찾는 감시(watchdog)가 쓴다. 이미 잡혀 있는 태스크는 updated_at으로 채운다. +ALTER TABLE tasks ADD COLUMN claimed_at timestamptz; +UPDATE tasks SET claimed_at = updated_at WHERE state IN ('CLAIMED', 'IN_PROGRESS'); + +-- Down Migration + +ALTER TABLE tasks DROP COLUMN IF EXISTS claimed_at; +ALTER TABLE tasks DROP CONSTRAINT tasks_blocked_reason_chk; +ALTER TABLE tasks ADD CONSTRAINT tasks_blocked_reason_chk CHECK (blocked_reason IS NULL OR blocked_reason IN + ('DISPUTE', 'DEPENDENCY', 'QUESTION')); + +DROP TABLE IF EXISTS agent_activity; +DROP TABLE IF EXISTS task_dispatches; diff --git a/packages/cli/package.json b/packages/cli/package.json index 9c9ee7d..35e9c3f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@capstone-nomos/cli", - "version": "0.1.3", + "version": "0.1.4", "description": "NOMOS 에이전트 CLI — 노트북의 Claude Code를 NOMOS 서버에 연결하고 배정된 태스크를 실행한다", "type": "module", "bin": { diff --git a/src/app.ts b/src/app.ts index 1d2a657..b7eb8ff 100644 --- a/src/app.ts +++ b/src/app.ts @@ -16,6 +16,8 @@ import { specsRouter } from './routes/specs.js'; import { pmRouter } from './routes/pm.js'; import { repoPathsRouter } from './routes/repo-paths.js'; import { reposRouter } from './routes/repos.js'; +import { roomsRouter } from './routes/rooms.js'; +import { startDispatchRecorder } from './domain/room/dispatch.js'; import { tasksRouter } from './routes/tasks.js'; export type AppOptions = { @@ -41,6 +43,8 @@ export function appOptionsFromEnv(): AppOptions { // 앱 조립만 한다. listen은 server.ts가 한다 — 통합 테스트가 임의 포트로 띄울 수 있어야 하기 때문이다. export function createApp(options: AppOptions = appOptionsFromEnv()): Express { const app = express(); + // 가져갈 수 있게 된 태스크마다 "실행해 주세요"(TASK_DISPATCHED)를 남긴다 — 룸 피드의 첫 줄. 한 번만 구독한다. + startDispatchRecorder(); // 운영은 Caddy 한 단 뒤에 있다. 이 설정이 없으면 req.ip가 Caddy 주소로 찍힌다(브라우저 승인 화면의 요청 IP가 쓸모없어진다). // 1단만 믿는다 — X-Forwarded-For의 맨 오른쪽(Caddy가 붙인 값)을 쓰므로 클라이언트가 앞에 끼운 값에 속지 않는다. app.set('trust proxy', 1); @@ -73,6 +77,7 @@ export function createApp(options: AppOptions = appOptionsFromEnv()): Express { app.use('/api', projectsRouter); app.use('/api', specsRouter); app.use('/api', pmRouter); + app.use('/api', roomsRouter); // 수동 테스트용 Swagger UI. 켤지와 인증은 DOCS_ENABLED·DOCS_BASIC_AUTH가 정한다(NODE_ENV와 분리). if (options.docs !== null) { diff --git a/src/bridge/claude-args.ts b/src/bridge/claude-args.ts index d7037a0..5e44a46 100644 --- a/src/bridge/claude-args.ts +++ b/src/bridge/claude-args.ts @@ -52,6 +52,14 @@ export const ALLOWED_BASH_PREFIXES = [ 'npm run test', 'npx vitest', 'node --test', + // 시험 실행기(운영 테스트 4-4: python이 막혀 BE가 시험을 한 번도 못 돌리고 거부만 13번). 설치는 Executor가 미리 한다 — 설치 명령은 열지 않는다. + 'pnpm test', + 'pnpm run test', + 'yarn test', + 'python -m pytest', + 'python -m unittest', + 'python3 -m pytest', + 'python3 -m unittest', ] as const; export function allowedBashRules(): string[] { diff --git a/src/bridge/nomos-client.ts b/src/bridge/nomos-client.ts index 94d6c3b..ec7084b 100644 --- a/src/bridge/nomos-client.ts +++ b/src/bridge/nomos-client.ts @@ -156,6 +156,29 @@ export class NomosClient { }; } + // 룸: Claude 실행 시작·끝, 실행 중 도구 사용(종류와 대상만). + async startRun(taskId: string): Promise { + await this.request('POST', `/api/tasks/${taskId}/runs/start`); + } + + async endRun( + taskId: string, + body: { + outcome: 'completed' | 'timeout' | 'failed'; + committed: boolean; + durationMs: number; + exitCode: number | null; + lastMessage?: string | null; + deniedCommands?: string[]; + }, + ): Promise<{ submitted: boolean; taskState: string; blocked: boolean }> { + return (await this.request('POST', `/api/tasks/${taskId}/runs/end`, body)) as { submitted: boolean; taskState: string; blocked: boolean }; + } + + async postActivity(taskId: string, items: { kind: string; target: string }[]): Promise { + await this.request('POST', `/api/tasks/${taskId}/activity`, { items }); + } + async reportBranch(taskId: string, branchName: string): Promise { await this.request('PATCH', `/api/tasks/${taskId}/branch`, { branchName }); } diff --git a/src/config/env.ts b/src/config/env.ts index 995bcee..1e53462 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -8,7 +8,7 @@ import { z } from 'zod'; // vitest의 test.env가 넘긴 값이 .env보다 우선한다. // // test에서도 읽지 않는 이유: vitest.config.ts의 test.env가 필요한 값을 전부 주므로 .env는 불필요하고, -// 읽으면 GITHUB_TOKEN 같은 개발용 값이 테스트로 새어 실제 네트워크 호출이 일어날 수 있다. +// 읽으면 ANTHROPIC_API_KEY 같은 개발용 값이 테스트로 새어 실제 네트워크 호출이 일어날 수 있다. const DOTENV_SKIPPED = new Set(['production', 'test']); if (!DOTENV_SKIPPED.has(process.env.NODE_ENV ?? 'development')) { @@ -30,7 +30,6 @@ const envSchema = z NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), PORT: z.coerce.number().int().positive().default(3000), DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'), - GITHUB_TOKEN: z.string().optional(), LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'), // 서버 자신의 공개 주소와 프론트 주소는 다르다(프론트는 Vercel). 초대 링크는 사람이 여는 것이므로 // 프론트 주소로 만든다 — API 주소로 만들면 링크를 연 사람이 JSON 화면을 본다. diff --git a/src/domain/approval/repository.ts b/src/domain/approval/repository.ts index 5329d5b..350787f 100644 --- a/src/domain/approval/repository.ts +++ b/src/domain/approval/repository.ts @@ -25,7 +25,8 @@ export type ApprovalRow = { // 목록용 — 지금 상태(태스크 제목·상태, 프로젝트 이름)를 함께 붙인다. 카드의 스냅샷은 payload에 따로 있다. export type ApprovalView = Omit & { projectName: string; - taskId: string; + // ACTION은 그 태스크, G3(통합 확인·완료)는 프로젝트 전체라 null. + taskId: string | null; taskTitle: string | null; taskState: string | null; }; @@ -51,7 +52,7 @@ function toApproval(row: QueryResultRow): ApprovalRow { export async function insertApproval( db: Queryable, - input: { projectId: string; gate: ApprovalGate; subjectId: string; artifactId: string; gateMode: string; payload: Record }, + input: { projectId: string; gate: ApprovalGate; subjectId: string; artifactId: string | null; gateMode: string | null; payload: Record }, ): Promise { const { rows } = await db.query( `INSERT INTO approvals (project_id, gate, subject_id, artifact_id, gate_mode, payload) @@ -121,12 +122,12 @@ export async function listApprovals( [filter.orgId ?? null, filter.projectId ?? null, filter.status, filter.limit], ); return rows.map((row) => { - // subject_id는 ACTION 게이트에서 태스크 id다 — 응답에는 taskId로만 낸다. + // subject_id는 ACTION 게이트에서 태스크 id, G3에서는 프로젝트 id다 — 응답에는 태스크일 때만 taskId로 낸다. const { subjectId: _subject, ...approval } = toApproval(row); return { ...approval, projectName: row.project_name as string, - taskId: row.subject_id as string, + taskId: approval.gate === 'ACTION' ? (row.subject_id as string) : null, taskTitle: (row.task_title as string | null) ?? null, taskState: (row.task_state as string | null) ?? null, }; @@ -157,3 +158,71 @@ export async function findLatestRejection( } : null; } + +// ── G3: 통합 확인·완료 승인 ────────────────────────────────────────────── + +// 모든 태스크가 DONE인가(하나 이상 있을 때). 프로젝트가 진행 중(active)일 때만 의미가 있다. +// 프로젝트 행을 먼저 잠그고(FOR NO KEY UPDATE — FOR UPDATE는 외래 키 검사와 충돌한다) **다음 문장에서** 다시 읽는다: +// 두 태스크가 동시에 DONE이 되면 서로의 DONE이 안 보여 둘 다 카드를 안 만들 수 있다. 잠금 뒤 새 문장은 먼저 커밋된 쪽을 본다. +export async function isProjectReadyForRelease(db: Queryable, projectId: string): Promise { + await db.query(`SELECT 1 FROM projects WHERE id = $1 FOR NO KEY UPDATE`, [projectId]); + const { rows } = await db.query( + `SELECT p.status = 'active' + AND EXISTS (SELECT 1 FROM tasks t WHERE t.project_id = p.id) + AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.project_id = p.id AND t.state <> 'DONE') + AND NOT EXISTS (SELECT 1 FROM approvals a WHERE a.project_id = p.id AND a.gate = 'G3' + AND (a.decision IS NULL OR a.decision = 'APPROVE')) AS ready + FROM projects p WHERE p.id = $1`, + [projectId], + ); + return rows[0]?.ready === true; +} + +// 적용된 계획들의 통합 확인 항목(PM이 쓴 것). 옛 초안에는 없다. +export async function listIntegrationChecks(db: Queryable, projectId: string): Promise { + const { rows } = await db.query( + `SELECT jsonb_array_elements_text(COALESCE(dag_snapshot->'integrationChecks', '[]'::jsonb)) AS check_text + FROM plans WHERE project_id = $1 AND applied_at IS NOT NULL + ORDER BY applied_at, id`, + [projectId], + ); + return rows.map((r) => r.check_text as string); +} + +// 카드에 붙일 태스크 요약 — 사람이 머지·실행해 볼 브랜치와 마지막 커밋. +export async function listReleaseTasks( + db: Queryable, + projectId: string, +): Promise<{ taskId: string; title: string; teamRole: string | null; repo: string; branchName: string | null; commitSha: string | null }[]> { + const { rows } = await db.query( + `SELECT t.id, t.title, t.team_role, r.full_name, t.branch_name, + (SELECT ar.commit_sha FROM artifacts ar WHERE ar.task_id = t.id ORDER BY ar.attempt DESC LIMIT 1) AS commit_sha + FROM tasks t JOIN repos r ON r.id = t.repo_id + WHERE t.project_id = $1 + ORDER BY t.created_at, t.id`, + [projectId], + ); + return rows.map((r) => ({ + taskId: r.id as string, + title: r.title as string, + teamRole: (r.team_role as string | null) ?? null, + repo: r.full_name as string, + branchName: (r.branch_name as string | null) ?? null, + commitSha: (r.commit_sha as string | null) ?? null, + })); +} + +// 대기 중인 G3는 프로젝트당 하나(uq_approvals_pending_subject). 이미 있으면 아무것도 하지 않고 null — 상태 변경을 롤백시키지 않는다. +export async function insertReleaseApproval(db: Queryable, projectId: string, payload: Record): Promise { + const { rows } = await db.query( + `INSERT INTO approvals (project_id, gate, subject_id, payload) VALUES ($1, 'G3', $1, $2) + ON CONFLICT (subject_id) WHERE decision IS NULL DO NOTHING + RETURNING id`, + [projectId, payload], + ); + return (rows[0]?.id as string | undefined) ?? null; +} + +export async function markProjectCompleted(db: Queryable, projectId: string): Promise { + await db.query(`UPDATE projects SET status = 'completed' WHERE id = $1 AND status = 'active'`, [projectId]); +} diff --git a/src/domain/approval/service.ts b/src/domain/approval/service.ts index 7742ba1..b726261 100644 --- a/src/domain/approval/service.ts +++ b/src/domain/approval/service.ts @@ -12,8 +12,13 @@ import { decideApproval, findApprovalForUpdate, insertApproval, + insertReleaseApproval, + isProjectReadyForRelease, listApprovals, + listIntegrationChecks, + listReleaseTasks, lockApprovalTask, + markProjectCompleted, type ApprovalDecision, type ApprovalStatusFilter, type ApprovalView, @@ -74,6 +79,28 @@ export async function requestActionApproval(tx: PoolClient, ctx: EventContext, i return approval.id; } +// ── G3: 통합 확인·완료 승인 ─────────────────────────────────────────────── +// 통합 확인은 에이전트가 아니라 대표가 한다(운영 테스트 4-3 — 에이전트에게는 선행 코드·상대 레포·실행 수단이 없다). +// 태스크가 DONE이 되는 **같은 트랜잭션에서** 부른다(검증 결론·ACTION 승인). 모든 태스크가 DONE이면 카드를 하나 만든다 — +// 확인 항목(PM의 integrationChecks)과 태스크별 레포·브랜치·마지막 커밋(사람이 머지·실행해 볼 것)을 스냅샷으로 담는다. +export const RELEASE_REQUESTER = 'system:pm'; + +export async function requestReleaseIfComplete(tx: PoolClient, ctx: { orgId: string; projectId: string }): Promise { + if (!(await isProjectReadyForRelease(tx, ctx.projectId))) return null; + const checks = await listIntegrationChecks(tx, ctx.projectId); + const tasks = await listReleaseTasks(tx, ctx.projectId); + const approvalId = await insertReleaseApproval(tx, ctx.projectId, { integrationChecks: checks, tasks }); + if (approvalId === null) return null; + await appendEvent(tx, { + orgId: ctx.orgId, + projectId: ctx.projectId, + type: 'RELEASE_REQUESTED', + onBehalfOf: RELEASE_REQUESTER, + payload: { approvalId, checkCount: checks.length, taskCount: tasks.length }, + }); + return approvalId; +} + // 조직 전체의 대기열(대시보드 "승인 · 결정 대기", 받은 편지함) — 대표 전용(라우트가 막는다). export async function listOrgApprovals(orgId: string, status: ApprovalStatusFilter, limit: number): Promise { return listApprovals(pool, { orgId, status, limit }); @@ -108,6 +135,22 @@ export async function decide( if (approval.decision !== null) { throw new AppError('APPROVAL_ALREADY_DECIDED', `approval was already decided (${approval.decision})`); } + if (approval.gate === 'G3') { + // 승인 = 통합 확인 끝 → 프로젝트 완료. 반려 = 진행 중 그대로(고칠 태스크는 대표가 만든다 — 다시 전부 DONE이 되면 새 카드). + if (!(await decideApproval(tx, approval.id, decision, actor.userId, reason))) { + throw new AppError('APPROVAL_ALREADY_DECIDED', 'approval was decided concurrently'); + } + if (decision === 'APPROVE') await markProjectCompleted(tx, approval.projectId); + const released = (await findProjectById(tx, approval.projectId))!; + await appendEvent(tx, { + orgId: actor.orgId, + projectId: approval.projectId, + type: 'RELEASE_DECIDED', + onBehalfOf: actor.userId, + payload: { approvalId: approval.id, decision, reason, projectStatus: released.status }, + }); + return approval.projectId; + } if (approval.gate !== 'ACTION') { throw new AppError('APPROVAL_NOT_FOUND', `approval ${approvalId} is not an action approval`); } @@ -156,6 +199,8 @@ export async function decide( ...(retryCount === null ? {} : { retryCount, retryCause: 'REJECTED' as const }), }, }); + // 이 승인으로 마지막 태스크가 DONE이 됐으면 통합 확인(G3)을 요청한다. + if (decision === 'APPROVE') await requestReleaseIfComplete(tx, { orgId: actor.orgId, projectId: approval.projectId }); return approval.projectId; }); diff --git a/src/domain/dispatch/tasks-changed.ts b/src/domain/dispatch/tasks-changed.ts index 73fe055..96fb2d9 100644 --- a/src/domain/dispatch/tasks-changed.ts +++ b/src/domain/dispatch/tasks-changed.ts @@ -9,6 +9,8 @@ import { logger } from '../../config/logger.js'; type Listener = (projectId: string) => void | Promise; const listeners = new Set(); +// 아직 끝나지 않은 리스너 호출. 테스트가 정리(TRUNCATE) 전에 기다린다 — 운영 코드는 기다리지 않는다. +const inflight = new Set>(); export function onTasksChanged(listener: Listener): () => void { listeners.add(listener); @@ -18,8 +20,15 @@ export function onTasksChanged(listener: Listener): () => void { export function tasksChanged(projectId: string): void { for (const listener of listeners) { // 알림 실패가 원래 요청을 실패시키면 안 된다 — 상태는 이미 커밋됐다. 놓친 알림은 에이전트의 안전망 폴링이 메운다. - Promise.resolve() + const p: Promise = Promise.resolve() .then(() => listener(projectId)) - .catch((err: unknown) => logger.warn('tasks-changed listener failed', { projectId, error: String(err) })); + .catch((err: unknown) => logger.warn('tasks-changed listener failed', { projectId, error: String(err) })) + .finally(() => inflight.delete(p)); + inflight.add(p); } } + +// 지금 돌고 있는 리스너가 전부 끝날 때까지 기다린다(그 사이에 새로 불린 것까지). +export async function drainTasksChanged(): Promise { + while (inflight.size > 0) await Promise.allSettled([...inflight]); +} diff --git a/src/domain/events/query.ts b/src/domain/events/query.ts index bd84cad..309ddff 100644 --- a/src/domain/events/query.ts +++ b/src/domain/events/query.ts @@ -23,12 +23,13 @@ export type EventQuery = { before?: string; limit: number; types?: string[] }; async function listProjectEvents(db: Queryable, projectId: string, query: EventQuery): Promise { const { rows } = await db.query( - `SELECT id::text AS id, type, ts, on_behalf_of, actor_agent_id, payload, token_cost::float8 AS token_cost, path_violation - FROM events - WHERE project_id = $1 - AND ($2::bigint IS NULL OR id < $2::bigint) - AND ($3::text[] IS NULL OR type = ANY($3::text[])) - ORDER BY id DESC + // ORDER BY는 반드시 e.id(bigint)로 — 그냥 id라고 쓰면 SELECT의 별칭(id::text)을 정렬해 문자열 순서가 된다("9" > "10", 운영 테스트에서 실제로 01:21이 01:01 아래로 갔다). + `SELECT e.id::text AS id, e.type, e.ts, e.on_behalf_of, e.actor_agent_id, e.payload, e.token_cost::float8 AS token_cost, e.path_violation + FROM events e + WHERE e.project_id = $1 + AND ($2::bigint IS NULL OR e.id < $2::bigint) + AND ($3::text[] IS NULL OR e.type = ANY($3::text[])) + ORDER BY e.id DESC LIMIT $4`, [projectId, query.before ?? null, query.types && query.types.length > 0 ? query.types : null, query.limit], ); diff --git a/src/domain/events/types.ts b/src/domain/events/types.ts index e7dd8e8..9ccad16 100644 --- a/src/domain/events/types.ts +++ b/src/domain/events/types.ts @@ -23,6 +23,14 @@ export type EventType = | 'APPROVAL_RESULT' | 'MEMBER_UNASSIGNED' | 'GITHUB_COLLABORATORS_INVITED' + | 'TASK_DISPATCHED' + | 'AGENT_RUN_STARTED' + | 'AGENT_RUN_ENDED' + | 'TASK_BLOCKED' + | 'TASK_RESUMED' + | 'RELEASE_REQUESTED' + | 'RELEASE_DECIDED' + | 'ACTION_DETECTED' | 'REPO_UPDATED' | 'TASKS_IMPORTED' | 'SPEC_CREATED' @@ -249,6 +257,82 @@ export type GithubCollaboratorsInvitedPayload = { results: GithubInviteResult[]; }; +// 서버가 "이 태스크를 실행해 주세요"를 보냈다 — 가져갈 수 있게 된 순간(시작됨·READY·담당 없음·선행 완료) attempt마다 한 번. +// on_behalf_of는 'system:dispatcher'. 실제 전달은 에이전트 스트림(tasksChanged)이 하고, 이건 룸에 남는 기록이다. +export type TaskDispatchedPayload = { + taskId: string; + title: string; + teamRole: string | null; + attempt: number; + // 대표가 재개한 횟수(TASK_RESUMED). 재개하면 같은 attempt라도 다시 보낸다. + resumes?: number; +}; + +// Executor가 Claude 실행을 시작했다/끝냈다. 끝은 Executor가 아는 결과(정상 종료·시간 초과·비정상)와 커밋 여부를 보고하고, +// 제출 여부(submitted)는 서버가 태스크 상태로 정한다 — "끝났는데 제출하지 않았다"(막다른 길)를 서버가 알게 된다. +export type AgentRunStartedPayload = { + taskId: string; + attempt: number; +}; + +// 에이전트가 제출 없이 멈췄다 → BLOCKED(AGENT_STOPPED). 재시도 횟수는 올리지 않는다 — 권한·환경 탓을 에이전트 실패로 세지 않는다. +// cause: not_submitted(정상 종료했지만 제출 없음) · timeout · failed(비정상 종료) · unresponsive(감시: 응답이 끊김, system:watchdog). +// lastMessage는 모델의 마지막 말, deniedCommands는 Executor가 실행 기록에서 뽑은 거부된 명령 — 대표가 무엇을 풀어야 하는지 보인다. +export type TaskBlockedPayload = { + taskId: string; + reason: 'AGENT_STOPPED'; + cause: 'not_submitted' | 'timeout' | 'failed' | 'unresponsive'; + agentId: string; + lastMessage: string | null; + deniedCommands: string[]; +}; + +// 제출 뒤 검증에서 행동 키가 새로 드러났다 — 지금은 package.json의 의존성 추가(dep:add)뿐이다. 경로로는 잡을 수 없어(scripts만 고친 변경까지 걸린다) +// 커밋 전후 내용을 읽어 판정한다. 산출물의 행동 키와 판정(gate_mode)이 더 엄격한 쪽으로 바뀐다. unreadable이면 못 읽어서 fail closed로 건 것. +export type ActionDetectedPayload = { + taskId: string; + artifactId: string; + actionKey: 'dep:add'; + paths: string[]; + changes: { path: string; section: string; name: string; from: string | null; to: string }[]; + unreadable: string[]; + gateModeBefore: string; + gateModeAfter: string; +}; + +// 모든 태스크가 DONE이 됐다 → 대표의 통합 확인·완료 승인 카드(G3). 통합 확인은 에이전트가 아니라 사람이 한다(운영 테스트 4-3, 대표 결정). +// checks는 적용된 계획의 integrationChecks. on_behalf_of는 'system:pm'. +export type ReleaseRequestedPayload = { + approvalId: string; + checkCount: number; + taskCount: number; +}; + +// 대표가 G3를 결정했다. 승인이면 프로젝트 completed, 반려면 그대로 진행 중(고칠 태스크는 대표가 만든다). +export type ReleaseDecidedPayload = { + approvalId: string; + decision: 'APPROVE' | 'REJECT'; + reason: string | null; + projectStatus: string; +}; + +// 대표가 원인을 해결하고 재개했다 → READY(담당 비움). note는 선택. +export type TaskResumedPayload = { + taskId: string; + note: string | null; +}; + +export type AgentRunEndedPayload = { + taskId: string; + attempt: number; + outcome: 'completed' | 'timeout' | 'failed'; + committed: boolean; + submitted: boolean; + durationMs: number; + exitCode: number | null; + taskState: string; +}; + export type GithubInviteResult = { repoId: string; fullName: string; @@ -368,6 +452,14 @@ export type EventPayloadMap = { APPROVAL_RESULT: ApprovalResultPayload; MEMBER_UNASSIGNED: MemberUnassignedPayload; GITHUB_COLLABORATORS_INVITED: GithubCollaboratorsInvitedPayload; + TASK_DISPATCHED: TaskDispatchedPayload; + AGENT_RUN_STARTED: AgentRunStartedPayload; + AGENT_RUN_ENDED: AgentRunEndedPayload; + TASK_BLOCKED: TaskBlockedPayload; + TASK_RESUMED: TaskResumedPayload; + RELEASE_REQUESTED: ReleaseRequestedPayload; + RELEASE_DECIDED: ReleaseDecidedPayload; + ACTION_DETECTED: ActionDetectedPayload; REPO_UPDATED: RepoUpdatedPayload; TASKS_IMPORTED: TasksImportedPayload; SPEC_CREATED: SpecCreatedPayload; diff --git a/src/domain/github/client.ts b/src/domain/github/client.ts index c72c89b..174ca5e 100644 --- a/src/domain/github/client.ts +++ b/src/domain/github/client.ts @@ -1,27 +1,4 @@ -import { env } from '../../config/env.js'; -import { logger } from '../../config/logger.js'; -import { AppError } from '../../errors.js'; - -const GITHUB_API_BASE = 'https://api.github.com'; - -// GITHUB_TOKEN이 설정되어 있는지 여부. 호출부가 "확인 안 됨"과 "false"를 구분하는 데 쓴다. -export function hasGithubToken(): boolean { - return Boolean(env.GITHUB_TOKEN); -} - -export type GithubRepo = { - fullName: string; - githubRepoId: number; - defaultBranch: string; -}; - -function githubHeaders(): Record { - return { - Authorization: `Bearer ${env.GITHUB_TOKEN}`, - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - }; -} +// GitHub API 공통 도구. 실제 호출은 조직 대표의 토큰으로 rep-api.ts가 한다 — 서버 공용 토큰(PAT)은 두지 않는다. // Link 헤더에서 rel="next" URL을 추출한다. 없으면 null (마지막 페이지). export function parseNextLink(linkHeader: string | null): string | null { @@ -32,46 +9,3 @@ export function parseNextLink(linkHeader: string | null): string | null { } return null; } - -// 대표가 접근 가능한 GitHub 레포 목록을 페이지네이션을 따라가며 전부 가져온다. -// 토큰이 없으면 500을 던지지 않고 빈 배열 + 경고 로그를 반환한다 (수동 등록 경로가 항상 열려 있어야 함). -export async function listUserRepos(): Promise { - if (!env.GITHUB_TOKEN) { - logger.warn('GITHUB_TOKEN not set; returning empty repo list'); - return []; - } - - const repos: GithubRepo[] = []; - let url: string | null = `${GITHUB_API_BASE}/user/repos?per_page=100&sort=updated`; - - while (url) { - const res = await fetch(url, { headers: githubHeaders() }); - if (!res.ok) { - throw new AppError('GITHUB_UNAVAILABLE', `GitHub repo list request failed with status ${res.status}`); - } - const body = (await res.json()) as Array<{ full_name: string; id: number; default_branch: string }>; - for (const r of body) { - repos.push({ fullName: r.full_name, githubRepoId: r.id, defaultBranch: r.default_branch }); - } - url = parseNextLink(res.headers.get('link')); - } - - return repos; -} - -// fullName(owner/repo) 레포에서 githubLogin 사용자가 collaborator인지 확인한다. -// GitHub는 collaborator면 204, 아니면 404를 반환한다. 토큰이 없거나 호출이 실패하면 -// GITHUB_UNAVAILABLE을 던진다 — 호출부(members 조회)가 try/catch로 감싸고 필드를 생략해야 한다. -export async function checkCollaborator(fullName: string, githubLogin: string): Promise { - if (!env.GITHUB_TOKEN) { - throw new AppError('GITHUB_UNAVAILABLE', 'GITHUB_TOKEN not set'); - } - - const res = await fetch(`${GITHUB_API_BASE}/repos/${fullName}/collaborators/${githubLogin}`, { - headers: githubHeaders(), - }); - - if (res.status === 204) return true; - if (res.status === 404) return false; - throw new AppError('GITHUB_UNAVAILABLE', `GitHub collaborator check failed with status ${res.status}`); -} diff --git a/src/domain/github/rep-api.ts b/src/domain/github/rep-api.ts index e63f01c..abe3bf2 100644 --- a/src/domain/github/rep-api.ts +++ b/src/domain/github/rep-api.ts @@ -1,7 +1,8 @@ import { AppError } from '../../errors.js'; -// 대표의 GitHub 토큰(oauth_sessions)으로 부르는 쓰기 API — GitHub 조직에 레포 만들기, 협업자 초대. -// 읽기 전용인 client.ts(서버의 GITHUB_TOKEN)와 따로 둔다: 누구의 권한으로 무엇을 바꾸는지가 다르다. +// 대표의 GitHub 토큰(oauth_sessions)으로 부르는 GitHub API — 레포 목록·조회, 협업자 확인(읽기)과 레포 만들기·협업자 초대(쓰기). +// **서버 공용 토큰(PAT)을 쓰지 않는다.** PAT 하나로 읽으면 모든 조직이 그 PAT 주인의 레포를 보게 된다(조직 간 정보 유출) — +// 조직마다 그 조직 대표의 권한으로만 본다. 대표가 GitHub를 연결하지 않았으면 목록은 비고 확인은 생략된다. // 토큰 값은 오류 메시지·로그에 절대 넣지 않는다. const DEFAULT_API = 'https://api.github.com'; const TIMEOUT_MS = 15_000; @@ -13,10 +14,16 @@ export const EMPTY_TREE_SHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; export const INITIAL_COMMIT_MESSAGE = 'Chore: 레포 초기화'; export type CreatedGithubRepo = { fullName: string; githubRepoId: number; defaultBranch: string }; +export type GithubRepoInfo = CreatedGithubRepo; export type InviteOutcome = 'invited' | 'already_collaborator'; export type GithubRepApi = { listOrgs(token: string): Promise; + // 대표가 접근할 수 있는 레포(소유·협업·조직 멤버). 연결 드롭다운. + listRepos(token: string): Promise; + // 레포 하나. 없거나 이 토큰으로 안 보이면 null. 직접 입력으로 연결할 때 github_repo_id를 채운다. + getRepo(token: string, fullName: string): Promise; + isCollaborator(token: string, fullName: string, githubLogin: string): Promise; createOrgRepo(token: string, input: { org: string; name: string; description?: string }): Promise; inviteCollaborator(token: string, fullName: string, githubLogin: string): Promise; }; @@ -61,7 +68,40 @@ export function createGithubRepApi(options: { fetchImpl?: FetchLike; apiBase?: s return (await res.json()) as T; } + const toInfo = (r: { id: number; full_name: string; default_branch: string }): GithubRepoInfo => ({ + fullName: r.full_name, + githubRepoId: r.id, + defaultBranch: r.default_branch, + }); + return { + async listRepos(token) { + const repos: GithubRepoInfo[] = []; + for (let page = 1; page <= 10; page += 1) { + const res = await call(token, 'GET', `/user/repos?per_page=100&page=${page}&sort=updated&affiliation=owner,collaborator,organization_member`); + if (!res.ok) throw await failure(res, 'GitHub 레포 목록'); + const body = await json>(res); + repos.push(...body.map(toInfo)); + if (body.length < 100) break; + } + return repos; + }, + + async getRepo(token, fullName) { + const res = await call(token, 'GET', `/repos/${fullName}`); + if (res.status === 404) return null; + if (!res.ok) throw await failure(res, `GitHub 레포 조회(${fullName})`); + return toInfo(await json<{ id: number; full_name: string; default_branch: string }>(res)); + }, + + // 204 = 협업자, 404 = 아님. 그 밖은 "확인 못 함"이라 던진다 — 호출부가 필드를 생략한다(false로 채우지 않는다). + async isCollaborator(token, fullName, githubLogin) { + const res = await call(token, 'GET', `/repos/${fullName}/collaborators/${encodeURIComponent(githubLogin)}`); + if (res.status === 204) return true; + if (res.status === 404) return false; + throw await failure(res, `협업자 확인(${fullName} ← ${githubLogin})`); + }, + async listOrgs(token) { const orgs: string[] = []; for (let page = 1; page <= 10; page += 1) { diff --git a/src/domain/github/rep-token.ts b/src/domain/github/rep-token.ts new file mode 100644 index 0000000..8d7c54a --- /dev/null +++ b/src/domain/github/rep-token.ts @@ -0,0 +1,15 @@ +import type { Queryable } from '../../config/db.js'; +import { decryptSecret } from '../../utils/secret-box.js'; +import { findRepresentativeGithubToken } from '../oauth/repository.js'; + +// 그 조직 대표의 GitHub 토큰(평문). 대표가 GitHub를 연결하지 않았으면 null — 호출부가 "확인 안 됨"으로 다룬다. +// 평문은 GitHub 호출에만 쓰고 로그·이벤트·오류 메시지에 넣지 않는다. +export async function representativeGithubToken(db: Queryable, orgId: string): Promise { + const sealed = await findRepresentativeGithubToken(db, orgId); + return sealed === null ? null : decryptSecret(sealed); +} + +// GitHub의 owner/repo 형식. 경로에 그대로 들어가므로 이 형식이 아니면 GitHub를 부르지 않는다. +export function isGithubFullName(fullName: string): boolean { + return /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]{1,100}$/.test(fullName) && !fullName.endsWith('/.') && !fullName.endsWith('/..'); +} diff --git a/src/domain/org/service.ts b/src/domain/org/service.ts index 63996f8..d3e49c9 100644 --- a/src/domain/org/service.ts +++ b/src/domain/org/service.ts @@ -3,7 +3,9 @@ import { pool, withTransaction } from '../../config/db.js'; import { AppError } from '../../errors.js'; import { assignAgentsToOrg } from '../agent/repository.js'; import { appendEvent } from '../events/append.js'; -import { checkCollaborator, hasGithubToken, listUserRepos, type GithubRepo } from '../github/client.js'; +import { logger } from '../../config/logger.js'; +import { githubRepApi, type GithubRepoInfo } from '../github/rep-api.js'; +import { representativeGithubToken } from '../github/rep-token.js'; import { listReposByOrg } from '../repo/repository.js'; import { assignUserToOrg, @@ -51,8 +53,15 @@ export async function createOrganization(userId: string, name: string): Promise< }); } -export async function listAvailableGithubRepos(): Promise { - return listUserRepos(); +// 연결 드롭다운: 그 조직 대표가 GitHub에서 접근할 수 있는 레포. 대표가 GitHub를 연결하지 않았으면 빈 배열(500이 아니다 — 직접 입력 경로가 열려 있다). +// 서버 공용 PAT를 쓰지 않는다 — 쓰면 모든 조직이 그 PAT 주인의 레포를 본다. +export async function listAvailableGithubRepos(orgId: string): Promise { + const token = await representativeGithubToken(pool, orgId); + if (token === null) { + logger.warn('representative has no GitHub link; returning empty repo list', { orgId }); + return []; + } + return githubRepApi().listRepos(token); } // 사용자를 가리키는 키는 다른 응답(createOrg·acceptInvite·프로젝트 멤버)과 같은 userId로 쓴다. @@ -79,7 +88,8 @@ export async function listMembers(orgId: string): Promise { orgRole: u.orgRole, }); - if (!hasGithubToken()) return users.map(base); + const token = await representativeGithubToken(pool, orgId); + if (token === null) return users.map(base); const repos = await listReposByOrg(pool, orgId); if (repos.length === 0) return users.map(base); @@ -90,7 +100,7 @@ export async function listMembers(orgId: string): Promise { if (u.githubLogin !== null) { for (const repo of repos) { try { - const result = await checkCollaborator(repo.fullName, u.githubLogin); + const result = await githubRepApi().isCollaborator(token, repo.fullName, u.githubLogin); isCollaborator = (isCollaborator ?? false) || result; } catch { continue; diff --git a/src/domain/pm/draft.ts b/src/domain/pm/draft.ts index 65b9fd4..5f7b766 100644 --- a/src/domain/pm/draft.ts +++ b/src/domain/pm/draft.ts @@ -17,7 +17,7 @@ const nullable = (schema: Record) => ({ anyOf: [schema, { type: export const PLAN_DRAFT_JSON_SCHEMA: Record = { type: 'object', additionalProperties: false, - required: ['mode', 'rationale', 'estimate', 'specs', 'tasks'], + required: ['mode', 'rationale', 'estimate', 'specs', 'tasks', 'integrationChecks'], properties: { mode: { type: 'string', enum: [...PLAN_MODES] }, rationale: { type: 'string' }, @@ -57,6 +57,8 @@ export const PLAN_DRAFT_JSON_SCHEMA: Record = { }, }, }, + // 통합 확인 항목 — 모든 태스크가 끝난 뒤 대표가 G3(통합 확인·완료 승인)에서 하나씩 확인한다. 에이전트에게 통합 태스크를 맡기지 않는다. + integrationChecks: { type: 'array', items: { type: 'string' } }, }, }; @@ -88,8 +90,18 @@ export const planDraftSchema = z.object({ ) .min(1) .max(AUTHORING_LIMITS.tasks), + // 옛 초안(이 필드 이전에 저장된 것)에는 없다 — 없으면 빈 목록. + integrationChecks: z.array(z.string().trim().min(1).max(300)).max(20).default([]), }); +// PM은 INTEGRATION 태스크를 만들지 않는다(운영 테스트 4-3: 통합 태스크는 선행 코드·상대 레포·실행 수단이 없어 늘 멈췄다). +// 통합 확인은 integrationChecks로 적고, 대표가 G3에서 확인한다. 형식(zod)이 아니라 위반으로 돌려줘 교정 1회에 싣는다. +export function pmOnlyProblems(draft: PlanDraft): string[] { + return draft.tasks + .filter((t) => t.kind === 'INTEGRATION') + .map((t) => `tasks[${t.ref}] INTEGRATION 태스크를 만들지 않는다 — 통합 확인은 integrationChecks에 확인 항목으로 적는다(대표가 모든 태스크가 끝난 뒤 확인한다)`); +} + export type PlanDraft = z.infer; // 초안 → 명세·태스크 생성 입력. 적용은 저장된 초안을 **그대로** 넣는다(다시 생성하거나 서버가 고치지 않는다). diff --git a/src/domain/pm/prompt.ts b/src/domain/pm/prompt.ts index 38aef6d..539b7e2 100644 --- a/src/domain/pm/prompt.ts +++ b/src/domain/pm/prompt.ts @@ -15,7 +15,7 @@ export const PM_SYSTEM_PROMPT = `당신은 NOMOS의 내장 PM이다. 대표의 테스트 코드는 쓰지 않는다. - 태스크(tasks): 에이전트 하나가 한 레포에서 끝낼 수 있는 크기. ref는 이 계획 안에서만 쓰는 짧은 영문 이름. repo는 아래 "연결된 레포" 목록의 이름 그대로, teamRole은 그 레포(또는 경로)를 소유한 역할로. - kind는 보통 IMPLEMENT이고 IMPLEMENT에는 반드시 spec(featureKey)을 붙인다. 레포를 가로지르는 확인은 INTEGRATION(spec 없어도 됨). + kind는 IMPLEMENT이고 반드시 spec(featureKey)을 붙인다. INTEGRATION 태스크는 만들지 않는다 — 에이전트는 다른 레포의 코드를 받거나 서버를 띄울 수 없다. dependsOn에는 먼저 끝나야 하는 태스크의 ref를 적는다. 순환을 만들지 말 것. dependsOn에 건 태스크는 선행이 끝난(DONE) 뒤에야 시작되고, 시작할 때 선행 태스크가 남긴 인계 노트를 받는다. 역할을 가로지르는 선행은 이렇게 정한다: @@ -24,10 +24,12 @@ export const PM_SYSTEM_PROMPT = `당신은 NOMOS의 내장 PM이다. 대표의 정렬, 상태 전이 같은 것)를 그대로 따라야 하는 일. · 계약 자체를 먼저 정해야 하는데 명세에 다 적을 수 없으면, 정하는 쪽 태스크를 "계약 확정"(작게 — 결정을 DECIDED 노트로 남긴다)과 "구현"으로 나누고, 상대 역할 태스크는 "계약 확정"에만 건다. 그래야 구현을 기다리지 않고 결정만 받아 시작한다. - · INTEGRATION은 연결하는 양쪽 태스크를 모두 dependsOn에 건다. - mode: SEQUENTIAL(불확실·소규모) | CONTRACT_PARALLEL(요구사항 명확·일정 촉박, API 계약 합의 후 병행) | HYBRID. 지금은 기록용이다 — 실제 실행 순서는 dependsOn이 정한다. - rationale: 모드와 분할의 근거를 짧게. estimate: 예상 작업일과 메모. +- integrationChecks: 모든 태스크가 끝난 뒤 대표가 직접 확인할 통합 항목. 레포·역할을 가로지르는 동작을 명세의 계약으로 적는다. + 한 항목에 확인 하나, 결과가 보이게. 예) "목록 화면이 GET /api/todos 응답의 항목을 순서대로 보여 준다", "추가 입력 → POST /api/todos 201 → 목록에 바로 보인다". + 레포가 하나뿐이거나 가로지르는 동작이 없으면 빈 목록. 지킬 것 - 이미 있는 명세의 featureKey, 이미 있는 태스크 제목을 다시 쓰지 말 것(중복은 거부된다). diff --git a/src/domain/pm/service.ts b/src/domain/pm/service.ts index 5f2e26a..67a9963 100644 --- a/src/domain/pm/service.ts +++ b/src/domain/pm/service.ts @@ -9,7 +9,7 @@ import { authorInTransaction } from '../authoring/apply.js'; import { findActor, lockProjectForAuthoring } from '../authoring/repository.js'; import { tasksChanged } from '../dispatch/tasks-changed.js'; import { appendEvent } from '../events/append.js'; -import { dagHashOf, draftToAuthoring, PLAN_DRAFT_JSON_SCHEMA, planDraftSchema, planStructure, type PlanDraft } from './draft.js'; +import { dagHashOf, draftToAuthoring, PLAN_DRAFT_JSON_SCHEMA, planDraftSchema, planStructure, pmOnlyProblems, type PlanDraft } from './draft.js'; import { getPmModel, type PmModel } from './model.js'; import { completeJob, @@ -521,6 +521,9 @@ async function judge( return { kind: 'fix', problems: parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`), draft: null }; } + const pmProblems = pmOnlyProblems(parsed.data); + if (pmProblems.length > 0) return { kind: 'fix', problems: pmProblems, draft: parsed.data }; + const client = await pool.connect(); try { await authorInTransaction(client, draftToAuthoring(parsed.data, ctx), { dryRun: true }); diff --git a/src/domain/project/repository.ts b/src/domain/project/repository.ts index 1dcea57..8f4da39 100644 --- a/src/domain/project/repository.ts +++ b/src/domain/project/repository.ts @@ -356,3 +356,33 @@ export async function isProjectMember(db: Queryable, projectId: string, agentId: const { rows } = await db.query(`SELECT 1 FROM project_members WHERE project_id = $1 AND agent_id = $2`, [projectId, agentId]); return rows.length > 0; } + +// 멤버별 마지막 GitHub 협업자 초대 결과(GITHUB_COLLABORATORS_INVITED). 이벤트가 유일한 진실이라 따로 저장하지 않고 여기서 읽는다. +// 배정·재초대 응답에만 있던 결과가 새로고침하면 사라졌다(FE 보고) — 프로젝트 조회의 멤버 정보에 붙인다. +export type MemberInviteStatus = { + repoId: string; + fullName: string; + status: 'invited' | 'already_collaborator' | 'skipped' | 'failed'; + reason?: string; + at: string; +}; + +export async function listLatestInvites(db: Queryable, projectId: string): Promise> { + const { rows } = await db.query( + `SELECT DISTINCT ON (payload->>'agentId') payload->>'agentId' AS agent_id, payload->'results' AS results, ts + FROM events + WHERE project_id = $1 AND type = 'GITHUB_COLLABORATORS_INVITED' + ORDER BY payload->>'agentId', id DESC`, + [projectId], + ); + const byAgent = new Map(); + for (const r of rows) { + const at = (r.ts as Date).toISOString(); + const results = (r.results ?? []) as { repoId: string; fullName: string; status: MemberInviteStatus['status']; reason?: string }[]; + byAgent.set( + r.agent_id as string, + results.map((x) => ({ repoId: x.repoId, fullName: x.fullName, status: x.status, ...(x.reason ? { reason: x.reason } : {}), at })), + ); + } + return byAgent; +} diff --git a/src/domain/project/service.ts b/src/domain/project/service.ts index 8488138..810115e 100644 --- a/src/domain/project/service.ts +++ b/src/domain/project/service.ts @@ -27,6 +27,7 @@ import { insertProjectMember, isProjectMember, linkProjectRepos, + listLatestInvites, listProjectGithubRepos, listProjectMembers, listProjectRepos, @@ -35,6 +36,7 @@ import { summarizeProjectTasks, type AutonomyPreset, type Project, + type MemberInviteStatus, type ProjectMember, type RepoRow, } from './repository.js'; @@ -52,12 +54,21 @@ export type CreateProjectInput = { repoIds: string[]; }; +// 응답에 나가는 멤버 — 레포별 마지막 GitHub 초대 결과를 붙인다(초대한 적 없으면 빈 목록). +export type ProjectMemberView = ProjectMember & { githubInvites: MemberInviteStatus[] }; + export type ProjectDetail = { project: Project; repos: RepoRow[]; - members: ProjectMember[]; + members: ProjectMemberView[]; }; +async function membersWithInvites(db: Queryable, projectId: string, members?: ProjectMember[]): Promise { + const list = members ?? (await listProjectMembers(db, projectId)); + const invites = await listLatestInvites(db, projectId); + return list.map((m) => ({ ...m, githubInvites: invites.get(m.agentId) ?? [] })); +} + // 어휘 위반은 형식 오류(zod 400)와 구분해 422로 답한다 — 무엇이 허용되는지 함께 알려준다. function assertPreset(value: string): AutonomyPreset { if (!PRESETS.includes(value as AutonomyPreset)) { @@ -177,7 +188,7 @@ export async function assignMember( projectId: string, agentId: string, teamRole: TeamRole, -): Promise { +): Promise { return withTransaction(async (tx) => { // 시작(G1)과 겹치지 않게 잠근다. 시작이 먼저 잡았으면 끝날 때까지 기다린 뒤 started_at을 본다. const project = await assertProjectInOrg(tx, actor.orgId, projectId, 'share'); @@ -222,7 +233,7 @@ export async function assignMember( payload: { agentId, teamRole }, }); - return listProjectMembers(tx, projectId); + return membersWithInvites(tx, projectId); }); } @@ -233,10 +244,11 @@ export async function assignMemberWithInvites( projectId: string, agentId: string, teamRole: TeamRole, -): Promise<{ members: ProjectMember[]; githubInvites: GithubInviteResult[] }> { - const members = await assignMember(actor, projectId, agentId, teamRole); +): Promise<{ members: ProjectMemberView[]; githubInvites: GithubInviteResult[] }> { + await assignMember(actor, projectId, agentId, teamRole); const githubInvites = await inviteToProjectGithubRepos(actor, projectId, agentId, 'assign'); - return { members, githubInvites }; + // 초대 결과(이벤트)가 남은 뒤에 읽어 멤버 정보에도 들어가게 한다. + return { members: await membersWithInvites(pool, projectId), githubInvites }; } // 초대를 다시 보낸다(대표 전용) — 배정 때 멤버가 GitHub를 연결하지 않았거나 GitHub가 실패한 경우. @@ -302,7 +314,7 @@ export async function unassignMember( actor: Actor, projectId: string, agentId: string, -): Promise { +): Promise { return withTransaction(async (tx) => { const project = await assertProjectInOrg(tx, actor.orgId, projectId, 'share'); assertNotStarted(project); @@ -323,7 +335,7 @@ export async function unassignMember( payload: { agentId, teamRole }, }); - return listProjectMembers(tx, projectId); + return membersWithInvites(tx, projectId); }); } @@ -384,7 +396,7 @@ export async function startProject(actor: Actor, projectId: string): Promise r.ownerRole !== undefined) && input.actorOrgRole !== 'REPRESENTATIVE') { throw new AppError('NOT_REPRESENTATIVE', 'only the organization representative can assign path ownership (ownerRole)'); } + // GitHub 호출은 트랜잭션 밖에서 끝낸다. + const repos = await fillFromGithub(input.orgId, input.repos); return withTransaction(async (tx) => { const results: ConnectRepoResult[] = []; - for (const r of input.repos) { + for (const r of repos) { results.push(await connectOneRepo(tx, input.orgId, input.actorUserId, r)); } return results; }); } +// 직접 입력(fullName만)으로 연결해도 대표의 GitHub 토큰으로 레포를 찾아 github_repo_id·기본 브랜치·clone_url을 채운다 — +// 비어 있으면 V3가 SKIPPED로 남는다(운영 테스트에서 그랬다). 대표가 GitHub를 연결하지 않았거나 레포가 안 보이면 예전처럼 비워 둔다(연결은 막지 않는다). +// 이름은 GitHub의 정본(full_name)으로 맞춘다 — 대소문자가 다르게 입력되면 같은 레포가 두 번 연결될 수 있다. +async function fillFromGithub(orgId: string, repos: ConnectRepoInput[]): Promise<(ConnectRepoInput & { cloneUrl?: string })[]> { + if (repos.every((r) => r.githubRepoId !== undefined)) return repos; + const token = await representativeGithubToken(pool, orgId); + if (token === null) return repos; + const filled: (ConnectRepoInput & { cloneUrl?: string })[] = []; + for (const r of repos) { + if (r.githubRepoId !== undefined || !isGithubFullName(r.fullName)) { + filled.push(r); + continue; + } + try { + const found = await githubRepApi().getRepo(token, r.fullName); + filled.push( + found === null + ? r + : { + ...r, + fullName: found.fullName, + githubRepoId: found.githubRepoId, + defaultBranch: r.defaultBranch ?? found.defaultBranch, + cloneUrl: validateCloneUrl(`https://github.com/${found.fullName}`, env.COMMIT_INSPECTOR), + }, + ); + } catch (err) { + logger.warn('GitHub repo lookup failed; connecting without github_repo_id', { fullName: r.fullName, error: String(err) }); + filled.push(r); + } + } + return filled; +} + // 레포 하나 연결: INSERT repos → 기본 경로 규칙 시드 → REPO_CONNECTED → (ownerRole이 있으면) '**' 소유 역할 지정. // 기존 레포 연결과 GitHub에 새로 만든 레포 연결이 같은 한 벌을 탄다. async function connectOneRepo( diff --git a/src/domain/room/activity-hub.ts b/src/domain/room/activity-hub.ts new file mode 100644 index 0000000..0e26577 --- /dev/null +++ b/src/domain/room/activity-hub.ts @@ -0,0 +1,16 @@ +// 에이전트 활동이 기록됐다는 신호. 활동은 events가 아니라서(016의 NOTIFY 트리거를 타지 않는다) 화면 스트림에 따로 알린다. +// 도메인은 전송 방식을 모른다 — realtime/user-stream.ts가 구독해 그 프로젝트를 구독한 화면에 'room' 토픽을 보낸다. +// 메모리 안의 구독이라 서버 1대 전제다(presence와 같다). 커밋한 뒤에 부른다. + +type Listener = (orgId: string, projectId: string) => void; + +const listeners = new Set(); + +export function onActivityRecorded(listener: Listener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function activityRecorded(orgId: string, projectId: string): void { + for (const listener of listeners) listener(orgId, projectId); +} diff --git a/src/domain/room/dispatch.ts b/src/domain/room/dispatch.ts new file mode 100644 index 0000000..482b40c --- /dev/null +++ b/src/domain/room/dispatch.ts @@ -0,0 +1,39 @@ +import { withTransaction } from '../../config/db.js'; +import { onTasksChanged } from '../dispatch/tasks-changed.js'; +import { appendEvent } from '../events/append.js'; +import { insertNewDispatches } from './repository.js'; + +// "이 태스크를 실행해 주세요"(TASK_DISPATCHED)를 남긴다. 가져갈 수 있는 태스크가 바뀌었을 수 있는 모든 순간에 +// 서비스가 이미 tasksChanged를 부르므로(시작·계획 적용·태스크 생성·수령·제출·검증 보고·승인 결정) 그 신호를 받아 계산한다. +// 서비스마다 호출을 넣지 않는다 — 빠뜨린 경로가 조용히 생긴다. +// +// 중복은 task_dispatches의 PK가 막는다(같은 신호가 여러 번·동시에 와도 한 번). 행이 새로 들어간 태스크에만 이벤트를 남기고, +// 둘은 한 트랜잭션이다 — 기록은 됐는데 이벤트가 없는 상태가 생기지 않는다. +export const DISPATCHER = 'system:dispatcher'; + +export async function recordDispatches(projectId: string): Promise { + return withTransaction(async (tx) => { + const dispatched = await insertNewDispatches(tx, projectId); + for (const d of dispatched) { + await appendEvent(tx, { + orgId: d.orgId, + projectId, + type: 'TASK_DISPATCHED', + onBehalfOf: DISPATCHER, + payload: { taskId: d.taskId, title: d.title, teamRole: d.teamRole, attempt: d.attempt, resumes: d.resumes }, + }); + } + return dispatched.length; + }); +} + +let started = false; + +// 앱이 뜰 때 한 번 건다(createApp). 여러 번 불려도 한 번만 구독한다. +export function startDispatchRecorder(): void { + if (started) return; + started = true; + onTasksChanged(async (projectId) => { + await recordDispatches(projectId); + }); +} diff --git a/src/domain/room/render.ts b/src/domain/room/render.ts new file mode 100644 index 0000000..e43d52e --- /dev/null +++ b/src/domain/room/render.ts @@ -0,0 +1,146 @@ +import { MAX_RETRIES } from '../task/retry.js'; +import type { FeedRow } from './repository.js'; + +// 룸 피드의 한 줄. 문장은 서버가 만든다 — 같은 이벤트를 화면마다 다르게 풀면 룸마다 말이 달라진다. +// speaker: pm(서버가 지시·판정을 알림) · nomos(Executor가 보고한 실행 상태) · agent(에이전트가 쓴 도구). +export type RoomMessage = { + id: string; + ts: string; + speaker: 'pm' | 'nomos' | 'agent'; + type: string; + taskId: string | null; + taskTitle: string | null; + text: string; +}; + +const ACTIVITY_TEXT: Record string> = { + read: (t) => `${t} 읽는 중`, + edit: (t) => `${t} 수정`, + write: (t) => `${t} 작성`, + run: (t) => `${t} 실행`, + search: (t) => `${t} 검색`, + other: (t) => t, +}; + +const STAGE_ORDER = ['V1A', 'V1B', 'V2', 'V3', 'V4']; + +function str(v: unknown): string { + return typeof v === 'string' ? v : ''; +} + +function num(v: unknown): number { + return typeof v === 'number' ? v : Number(v ?? 0); +} + +// "V3 통과, V1A·V1B·V2 건너뜀" — 결과별로 묶는다. +function stagesText(stages: unknown): string { + if (!Array.isArray(stages)) return ''; + const by: Record = {}; + for (const s of stages as { stage?: unknown; result?: unknown }[]) { + const result = str(s.result); + (by[result] ??= []).push(str(s.stage)); + } + const label: [string, string][] = [ + ['FAIL', '실패'], + ['PASS', '통과'], + ['SKIPPED', '건너뜀'], + ]; + return label + .filter(([r]) => by[r]?.length) + .map(([r, l]) => `${by[r]!.sort((a, b) => STAGE_ORDER.indexOf(a) - STAGE_ORDER.indexOf(b)).join('·')} ${l}`) + .join(', '); +} + +function eventText(row: FeedRow): { speaker: RoomMessage['speaker']; text: string } | null { + const p = row.payload; + const title = row.taskTitle ?? '태스크'; + switch (row.type) { + case 'PROJECT_STARTED': + return { speaker: 'pm', text: '프로젝트를 시작합니다' }; + case 'PLAN_APPLIED': + return { speaker: 'pm', text: '계획을 적용했습니다' }; + case 'RELEASE_REQUESTED': { + const checks = num(p.checkCount); + return { speaker: 'pm', text: `모든 태스크가 완료되었습니다 → 대표의 통합 확인을 기다립니다${checks > 0 ? ` (확인 항목 ${checks}개)` : ''}` }; + } + case 'RELEASE_DECIDED': + return p.decision === 'APPROVE' + ? { speaker: 'pm', text: '대표가 통합 확인을 마쳤습니다 → 프로젝트 완료' } + : { speaker: 'pm', text: `대표가 통합 확인에서 반려했습니다${p.reason ? `: ${str(p.reason)}` : ''}` }; + case 'TASK_DISPATCHED': { + const attempt = num(p.attempt); + if (num(p.resumes) > 0 && attempt === 0) return { speaker: 'pm', text: `${title} 다시 실행해 주세요 (재개)` }; + return attempt === 0 + ? { speaker: 'pm', text: `${title} 실행해 주세요` } + : { speaker: 'pm', text: `${title} 다시 실행해 주세요 (재시도 ${attempt}/${MAX_RETRIES})` }; + } + // 실행 순서: Executor가 에이전트를 띄우고(AGENT_RUN_STARTED) → 에이전트가 실행 안에서 태스크를 잡는다(TASK_CLAIMED). + case 'AGENT_RUN_STARTED': + return { speaker: 'nomos', text: '에이전트를 실행합니다' }; + case 'TASK_CLAIMED': + return { speaker: 'nomos', text: `태스크 수령 — ${title} · 구현을 시작합니다` }; + case 'AGENT_RUN_ENDED': { + // 제출하지 않았으면 바로 뒤의 TASK_BLOCKED 줄이 사유를 말한다 — 여기서는 끝났다는 사실만. + const seconds = Math.round(num(p.durationMs) / 1000); + return { speaker: 'nomos', text: p.submitted === true ? `실행을 마쳤습니다 (${seconds}초)` : `실행을 마쳤습니다 (${seconds}초, 제출 없음)` }; + } + case 'ARTIFACT_SUBMITTED': { + const sha = str(p.commitSha).slice(0, 7); + const files = row.changedPathCount === null ? '' : `, 파일 ${row.changedPathCount}개`; + return { speaker: 'nomos', text: `제출했습니다 (커밋 ${sha}${files})` }; + } + case 'VERIFICATION_COMPLETED': { + const stages = stagesText(p.stages); + switch (str(p.taskState)) { + case 'DONE': + return { speaker: 'pm', text: `검증 완료 — ${stages} → 완료되었습니다` }; + case 'AWAITING_APPROVAL': + return { speaker: 'pm', text: `검증 완료 — ${stages} → 대표 승인을 기다립니다` }; + case 'READY': + return { speaker: 'pm', text: `검증 실패 — ${stages} → 다시 시도합니다 (${num(p.retryCount)}/${MAX_RETRIES})` }; + case 'ESCALATED': + return { speaker: 'pm', text: `검증 실패 — ${stages} → 재시도 한도를 넘어 대표에게 넘깁니다` }; + default: + // 아직 단계가 남았다(VERIFYING) — 단계 보고마다 줄이 생겨 시끄러웠다(실측). 결론이 난 줄만 보인다. + return null; + } + } + case 'TASK_BLOCKED': { + const why: Record = { + not_submitted: '제출하지 않고 끝났습니다', + timeout: '시간 제한을 넘겼습니다', + failed: '비정상 종료했습니다', + unresponsive: '에이전트 응답이 끊겼습니다', + }; + const denied = Array.isArray(p.deniedCommands) && p.deniedCommands.length > 0 ? ` / 거부된 명령: ${(p.deniedCommands as unknown[]).map(str).join(', ')}` : ''; + const said = str(p.lastMessage) ? ` / 에이전트: "${str(p.lastMessage).slice(0, 200)}"` : ''; + return { speaker: 'pm', text: `멈췄습니다 — ${why[str(p.cause)] ?? '제출하지 않았습니다'}${said}${denied} → 대표 확인 후 재개가 필요합니다` }; + } + case 'TASK_RESUMED': + return { speaker: 'pm', text: `대표가 재개했습니다${p.note ? `: ${str(p.note)}` : ''}` }; + case 'APPROVAL_RESULT': + return p.decision === 'APPROVE' + ? { speaker: 'pm', text: '대표가 승인했습니다 → 완료되었습니다' } + : { speaker: 'pm', text: `대표가 반려했습니다${p.reason ? `: ${str(p.reason)}` : ''}` }; + case 'NOTE_PUBLISHED': + return { speaker: 'agent', text: `인계 노트를 남겼습니다 — ${str(p.title)}` }; + default: + return null; + } +} + +export function renderFeedRow(row: FeedRow): RoomMessage | null { + const base = { id: `${row.tsMicros}.${row.source}.${row.id}`, ts: row.ts, type: row.type, taskId: row.taskId, taskTitle: row.taskTitle }; + if (row.source === 'a') { + const toText = ACTIVITY_TEXT[row.type] ?? ACTIVITY_TEXT.other!; + return { ...base, type: `ACTIVITY_${row.type.toUpperCase()}`, speaker: 'agent', text: toText(str(row.payload.target)) }; + } + const rendered = eventText(row); + return rendered === null ? null : { ...base, ...rendered }; +} + +// 커서 문자열("<마이크로초>..")을 되돌린다. 형식이 틀리면 null(→ 400). +export function parseCursor(raw: string): { tsMicros: string; source: 'e' | 'a'; id: string } | null { + const m = /^(\d{1,20})\.([ea])\.(\d{1,20})$/.exec(raw); + return m ? { tsMicros: m[1]!, source: m[2] as 'e' | 'a', id: m[3]! } : null; +} diff --git a/src/domain/room/repository.ts b/src/domain/room/repository.ts new file mode 100644 index 0000000..b48e041 --- /dev/null +++ b/src/domain/room/repository.ts @@ -0,0 +1,177 @@ +import type { Queryable } from '../../config/db.js'; +import type { TeamRole } from '../roles.js'; + +// ── 배정 기록(task_dispatches) ─────────────────────────────────────────── + +export type DispatchedTask = { taskId: string; title: string; teamRole: TeamRole | null; attempt: number; resumes: number; orgId: string }; + +// 지금 가져갈 수 있는 태스크(시작한 프로젝트·READY·담당 없음·선행 전부 DONE)를 (attempt=retry_count, resumes=재개 수)별로 한 번만 기록한다. +// 조건은 task/repository.ts의 listClaimableTasks에서 역할 조건만 뺀 것이다 — 둘이 갈리면 "실행해 주세요"가 왔는데 못 가져가는 일이 생긴다. +// 이미 기록된 (task, attempt)는 PK 충돌로 건너뛰고, 새로 들어간 행만 돌려준다(그 행에만 이벤트를 남긴다). +export async function insertNewDispatches(db: Queryable, projectId: string): Promise { + const { rows } = await db.query( + `WITH ready AS ( + SELECT t.id, t.retry_count, + (SELECT count(*) FROM events e WHERE e.type = 'TASK_RESUMED' AND e.payload->>'taskId' = t.id::text)::int AS resumes + FROM tasks t + JOIN projects p ON p.id = t.project_id + WHERE t.project_id = $1 + AND p.started_at IS NOT NULL + AND t.state = 'READY' + AND t.assignee_agent_id IS NULL + AND NOT EXISTS ( + SELECT 1 FROM task_deps d JOIN tasks dt ON dt.id = d.depends_on + WHERE d.task_id = t.id AND dt.state <> 'DONE') + ), inserted AS ( + INSERT INTO task_dispatches (task_id, attempt, resumes) + SELECT id, retry_count, resumes FROM ready + ON CONFLICT DO NOTHING + RETURNING task_id, attempt, resumes + ) + SELECT i.task_id, i.attempt, i.resumes, t.title, t.team_role, p.org_id + FROM inserted i JOIN tasks t ON t.id = i.task_id JOIN projects p ON p.id = t.project_id + ORDER BY t.created_at, t.id`, + [projectId], + ); + return rows.map((r) => ({ + taskId: r.task_id as string, + title: r.title as string, + teamRole: (r.team_role as TeamRole | null) ?? null, + attempt: Number(r.attempt), + resumes: Number(r.resumes), + orgId: r.org_id as string, + })); +} + +// ── 실행(run) ───────────────────────────────────────────────────────────── + +// 이 에이전트가 이 태스크에서 시작하고 아직 끝내지 않은 실행 — 마지막 실행 이벤트가 STARTED면 그 attempt, 아니면 null. +export async function findOpenRun(db: Queryable, taskId: string, agentId: string): Promise<{ attempt: number } | null> { + const { rows } = await db.query( + `SELECT type, payload->>'attempt' AS attempt FROM events + WHERE type IN ('AGENT_RUN_STARTED', 'AGENT_RUN_ENDED') + AND actor_agent_id = $2 AND payload->>'taskId' = $1 + ORDER BY id DESC LIMIT 1`, + [taskId, agentId], + ); + return rows[0]?.type === 'AGENT_RUN_STARTED' ? { attempt: Number(rows[0].attempt) } : null; +} + +// ── 활동(agent_activity) ───────────────────────────────────────────────── + +export type ActivityKind = 'read' | 'edit' | 'write' | 'run' | 'search' | 'other'; +export const ACTIVITY_KINDS: readonly ActivityKind[] = ['read', 'edit', 'write', 'run', 'search', 'other']; + +export async function insertActivity( + db: Queryable, + input: { projectId: string; taskId: string; agentId: string; items: { kind: ActivityKind; target: string }[] }, +): Promise { + // 한 묶음은 한 문장으로 넣는다 — id(bigserial) 순서가 보낸 순서와 같다. + await db.query( + `INSERT INTO agent_activity (project_id, task_id, agent_id, kind, target) + SELECT $1, $2, $3, k, t FROM unnest($4::text[], $5::text[]) WITH ORDINALITY AS x(k, t, n) ORDER BY n`, + [input.projectId, input.taskId, input.agentId, input.items.map((i) => i.kind), input.items.map((i) => i.target)], + ); +} + +// ── 룸 피드 ────────────────────────────────────────────────────────────── + +// 룸에 보이는 이벤트. 태스크에 딸린 것은 그 태스크의 역할 룸에만(역할 제한 없는 태스크는 모든 룸), 프로젝트 단위는 모든 룸에. +export const TASK_FEED_EVENTS = [ + 'TASK_DISPATCHED', + 'TASK_CLAIMED', + 'AGENT_RUN_STARTED', + 'AGENT_RUN_ENDED', + 'TASK_BLOCKED', + 'TASK_RESUMED', + 'ARTIFACT_SUBMITTED', + 'VERIFICATION_COMPLETED', + // APPROVAL_REQUESTED는 넣지 않는다 — 같은 트랜잭션의 검증 결과 줄이 이미 "대표 승인을 기다립니다"라고 말한다(겹쳐 보였다). + 'APPROVAL_RESULT', + 'NOTE_PUBLISHED', +] as const; +export const PROJECT_FEED_EVENTS = ['PROJECT_STARTED', 'PLAN_APPLIED', 'RELEASE_REQUESTED', 'RELEASE_DECIDED'] as const; + +export type FeedRow = { + source: 'e' | 'a'; + id: string; + tsMicros: string; + ts: string; + type: string; + payload: Record; + taskId: string | null; + taskTitle: string | null; + changedPathCount: number | null; +}; + +// 이벤트와 활동을 시각순으로 섞어 최신부터 읽는다. 커서는 (시각 마이크로초, 출처, id) — 시각만으로는 같은 트랜잭션·같은 묶음에서 겹친다. +// 시각을 JS Date로 옮기면 마이크로초가 잘려 커서 경계에서 행이 빠지므로 정수로 비교한다. +export async function listRoomFeed( + db: Queryable, + projectId: string, + teamRole: TeamRole, + query: { before?: { tsMicros: string; source: 'e' | 'a'; id: string }; limit: number }, +): Promise { + const before = query.before ?? null; + const { rows } = await db.query( + `SELECT * FROM ( + SELECT 'e'::text AS source, e.id::text AS id, (extract(epoch FROM e.ts) * 1000000)::bigint AS ts_us, e.ts, + e.type, e.payload, t.id AS task_id, t.title AS task_title, + cardinality(ar.changed_paths) AS changed_path_count + FROM events e + LEFT JOIN tasks t ON t.id = (e.payload->>'taskId')::uuid + LEFT JOIN artifacts ar ON e.type = 'ARTIFACT_SUBMITTED' AND ar.id = (e.payload->>'artifactId')::uuid + WHERE e.project_id = $1 + AND ( + (e.type = ANY($3::text[]) AND t.id IS NOT NULL AND (t.team_role IS NULL OR t.team_role = $2)) + OR e.type = ANY($4::text[]) + ) + UNION ALL + SELECT 'a'::text, a.id::text, (extract(epoch FROM a.ts) * 1000000)::bigint, a.ts, + a.kind, jsonb_build_object('target', a.target), t.id, t.title, NULL::int + FROM agent_activity a + JOIN tasks t ON t.id = a.task_id + WHERE a.project_id = $1 AND (t.team_role IS NULL OR t.team_role = $2) + ) feed + WHERE $5::bigint IS NULL OR (ts_us, source, id::bigint) < ($5::bigint, $6::text, $7::bigint) + ORDER BY ts_us DESC, source DESC, id::bigint DESC + LIMIT $8`, + [ + projectId, + teamRole, + [...TASK_FEED_EVENTS], + [...PROJECT_FEED_EVENTS], + before?.tsMicros ?? null, + before?.source ?? null, + before?.id ?? null, + query.limit, + ], + ); + return rows.map((r) => ({ + source: r.source as 'e' | 'a', + id: r.id as string, + tsMicros: String(r.ts_us), + ts: (r.ts as Date).toISOString(), + type: r.type as string, + payload: (r.payload ?? {}) as Record, + taskId: (r.task_id as string | null) ?? null, + taskTitle: (r.task_title as string | null) ?? null, + changedPathCount: r.changed_path_count === null || r.changed_path_count === undefined ? null : Number(r.changed_path_count), + })); +} + +// ── 룸 목록 ────────────────────────────────────────────────────────────── + +export type RoomTask = { id: string; title: string; state: string; teamRole: TeamRole | null }; + +// 그 역할의 진행 중 태스크(담당이 있는 것). 화면 머리의 "지금 하는 일". +export async function listActiveRoleTasks(db: Queryable, projectId: string, teamRole: TeamRole): Promise { + const { rows } = await db.query( + `SELECT id, title, state, team_role FROM tasks + WHERE project_id = $1 AND (team_role IS NULL OR team_role = $2) + AND state IN ('CLAIMED', 'IN_PROGRESS', 'VERIFYING', 'AWAITING_APPROVAL') + ORDER BY updated_at DESC, id`, + [projectId, teamRole], + ); + return rows.map((r) => ({ id: r.id as string, title: r.title as string, state: r.state as string, teamRole: (r.team_role as TeamRole | null) ?? null })); +} diff --git a/src/domain/room/service.ts b/src/domain/room/service.ts new file mode 100644 index 0000000..b804737 --- /dev/null +++ b/src/domain/room/service.ts @@ -0,0 +1,177 @@ +import { pool, withTransaction, type Queryable } from '../../config/db.js'; +import { AppError } from '../../errors.js'; +import { presenceOf } from '../agent/presence.js'; +import { findAgentMembership } from '../policy/repository.js'; +import { appendEvent } from '../events/append.js'; +import { listProjectMembers } from '../project/repository.js'; +import { assertProjectVisibleToUser, type UserContext } from '../project/visibility.js'; +import { TEAM_ROLES, type TeamRole } from '../roles.js'; +import { findTaskById, type Task } from '../task/repository.js'; +import type { AgentContext } from '../task/service.js'; +import { tasksChanged } from '../dispatch/tasks-changed.js'; +import { blockStoppedTaskInTx } from '../task/stall.js'; +import { activityRecorded } from './activity-hub.js'; +import { parseCursor, renderFeedRow, type RoomMessage } from './render.js'; +import { findOpenRun, insertActivity, listActiveRoleTasks, listRoomFeed, type ActivityKind, type RoomTask } from './repository.js'; + +// 룸 = 프로젝트 × 역할. 그 역할의 에이전트가 무엇을 하는지(활동), 서버(PM)가 무엇을 지시·판정했는지를 한 줄씩 보여 준다. +// 팀원은 자기 역할 룸만, 대표는 전부 본다. PM(서버)의 프로젝트 단위 알림은 모든 룸에 나온다. + +const RUNNING_STATES = ['CLAIMED', 'IN_PROGRESS']; + +async function taskInProject(ctx: AgentContext, taskId: string, db: Queryable = pool): Promise { + const task = await findTaskById(db, taskId); + if (!task || task.projectId !== ctx.projectId) throw new AppError('TASK_NOT_FOUND', `task ${taskId} not found`); + return task; +} + +// ── Executor → 서버 ────────────────────────────────────────────────────── + +// Claude 실행을 시작했다. 태스크를 잡는 것(claim_task)은 실행 **안에서** 모델이 하므로, 시작 시점의 태스크는 보통 아직 READY다. +// 그래서 "이 에이전트가 잡고 있다" 또는 "이 에이전트가 지금 잡을 수 있다(READY·담당 없음·내 역할 또는 역할 제한 없음)"면 받는다. +// 선행 조건·정책은 여기서 다시 보지 않는다 — 실제로 잡을 수 있는지는 claim이 판정하고, 이건 기록일 뿐이다. +export async function startRun(ctx: AgentContext, taskId: string): Promise<{ taskId: string; attempt: number }> { + return withTransaction(async (tx) => { + const task = await taskInProject(ctx, taskId, tx); + const held = task.assigneeAgentId === ctx.agentId && RUNNING_STATES.includes(task.state); + const membership = await findAgentMembership(tx, ctx.agentId); + const claimable = + task.state === 'READY' && + task.assigneeAgentId === null && + membership?.projectId === ctx.projectId && + (task.teamRole === null || task.teamRole === membership.teamRole); + if (!held && !claimable) { + throw new AppError('RUN_NOT_ALLOWED', `task ${taskId} is neither held nor claimable by this agent (state ${task.state})`); + } + await appendEvent(tx, { + orgId: ctx.orgId, + projectId: ctx.projectId, + type: 'AGENT_RUN_STARTED', + actorAgentId: ctx.agentId, + onBehalfOf: ctx.onBehalfOf, + policyHash: ctx.policyHash, + payload: { taskId, attempt: task.retryCount }, + }); + return { taskId, attempt: task.retryCount }; + }); +} + +export type EndRunInput = { + outcome: 'completed' | 'timeout' | 'failed'; + committed: boolean; + durationMs: number; + exitCode: number | null; + // 제출하지 않고 끝났을 때 대표에게 보일 사유 — 모델의 마지막 말, 거부된 쉘 명령(Executor가 실행 기록에서 뽑는다). + lastMessage?: string | null; + deniedCommands?: string[]; +}; + +// Claude 실행이 끝났다. 제출 여부는 Executor가 아니라 서버가 정한다 — 이 에이전트가 아직 그 태스크를 잡고 있으면(CLAIMED·IN_PROGRESS) +// 제출하지 않은 것이다. 제출 뒤에는 검증이 상태를 이미 옮겨 두었다(VERIFYING·DONE·READY…). +// 제출하지 않았으면 같은 트랜잭션에서 BLOCKED(AGENT_STOPPED)로 멈추고 사유를 남긴다(task/stall.ts) — 재시도 횟수는 그대로. +export async function endRun( + ctx: AgentContext, + taskId: string, + input: EndRunInput, +): Promise<{ submitted: boolean; taskState: string; blocked: boolean }> { + const result = await withTransaction(async (tx) => { + const task = await taskInProject(ctx, taskId, tx); + const open = await findOpenRun(tx, taskId, ctx.agentId); + if (open === null) throw new AppError('RUN_NOT_OPEN', `no open run for task ${taskId}`); + const submitted = !(task.assigneeAgentId === ctx.agentId && RUNNING_STATES.includes(task.state)); + await appendEvent(tx, { + orgId: ctx.orgId, + projectId: ctx.projectId, + type: 'AGENT_RUN_ENDED', + actorAgentId: ctx.agentId, + onBehalfOf: ctx.onBehalfOf, + policyHash: ctx.policyHash, + payload: { + taskId, + attempt: open.attempt, + outcome: input.outcome, + committed: input.committed, + durationMs: input.durationMs, + exitCode: input.exitCode, + submitted, + taskState: task.state, + }, + }); + if (submitted) return { submitted, taskState: task.state, blocked: false }; + const blocked = await blockStoppedTaskInTx(tx, { orgId: ctx.orgId, projectId: ctx.projectId, onBehalfOf: ctx.onBehalfOf, policyHash: ctx.policyHash }, taskId, { + cause: input.outcome === 'completed' ? 'not_submitted' : input.outcome, + agentId: ctx.agentId, + lastMessage: input.lastMessage ?? null, + deniedCommands: input.deniedCommands ?? [], + }); + return { submitted, taskState: blocked ? blocked.state : task.state, blocked: blocked !== null }; + }); + if (result.blocked) tasksChanged(ctx.projectId); + return result; +} + +// 실행 중 도구 사용 묶음. 열린 실행에만 붙는다 — 끝난 뒤 늦게 온 묶음은 받지 않는다. +export async function recordActivity( + ctx: AgentContext, + taskId: string, + items: { kind: ActivityKind; target: string }[], +): Promise<{ recorded: number }> { + await taskInProject(ctx, taskId); + if ((await findOpenRun(pool, taskId, ctx.agentId)) === null) { + throw new AppError('RUN_NOT_OPEN', `no open run for task ${taskId}`); + } + await insertActivity(pool, { projectId: ctx.projectId, taskId, agentId: ctx.agentId, items }); + activityRecorded(ctx.orgId, ctx.projectId); + return { recorded: items.length }; +} + +// ── 사람 → 룸 ──────────────────────────────────────────────────────────── + +// 이 사람이 볼 수 있는 룸의 역할. 대표는 전부, 팀원은 자기 에이전트가 배정된 역할만. +async function visibleRoles(actor: UserContext, projectId: string): Promise { + await assertProjectVisibleToUser(pool, actor, projectId); + if (actor.orgRole === 'REPRESENTATIVE') return [...TEAM_ROLES]; + const members = await listProjectMembers(pool, projectId); + return TEAM_ROLES.filter((role) => members.some((m) => m.userId === actor.userId && m.teamRole === role)); +} + +export type RoomSummary = { + role: TeamRole; + agent: { agentId: string; agentName: string; online: boolean; lastSeenAt: string | null } | null; + activeTasks: RoomTask[]; +}; + +export async function listRooms(actor: UserContext, projectId: string): Promise { + const roles = await visibleRoles(actor, projectId); + const members = await listProjectMembers(pool, projectId); + const rooms: RoomSummary[] = []; + for (const role of roles) { + const member = members.find((m) => m.teamRole === role); + rooms.push({ + role, + agent: member ? { agentId: member.agentId, agentName: member.agentName, ...presenceOf(member.agentId) } : null, + activeTasks: await listActiveRoleTasks(pool, projectId, role), + }); + } + return rooms; +} + +// 최신부터. nextBefore를 다음 요청의 before로 넘기면 더 오래된 것을 읽는다. +export async function getRoomFeed( + actor: UserContext, + projectId: string, + role: TeamRole, + query: { before?: string; limit: number }, +): Promise<{ messages: RoomMessage[]; nextBefore: string | null }> { + const roles = await visibleRoles(actor, projectId); + if (!roles.includes(role)) throw new AppError('ROOM_NOT_VISIBLE', `you cannot see the ${role} room of this project`); + let before: ReturnType | undefined; + if (query.before !== undefined) { + before = parseCursor(query.before); + if (before === null) throw new AppError('VALIDATION_ERROR', 'before is not a feed cursor'); + } + const rows = await listRoomFeed(pool, projectId, role, { ...(before ? { before } : {}), limit: query.limit }); + const messages = rows.map(renderFeedRow).filter((m): m is RoomMessage => m !== null); + const last = rows[rows.length - 1]; + return { messages, nextBefore: rows.length === query.limit && last ? `${last.tsMicros}.${last.source}.${last.id}` : null }; +} diff --git a/src/domain/task/repository.ts b/src/domain/task/repository.ts index 1b48d26..5c773ba 100644 --- a/src/domain/task/repository.ts +++ b/src/domain/task/repository.ts @@ -36,6 +36,8 @@ export type Task = { branchName: string | null; blockedReason: string | null; retryCount: number; + // 마지막 수령 시각(017). 화면의 "수령 후 경과"와 응답 없는 에이전트 감시가 쓴다. 수령한 적이 없으면 null. + claimedAt: string | null; }; function toTask(row: QueryResultRow): Task { @@ -52,6 +54,7 @@ function toTask(row: QueryResultRow): Task { branchName: row.branch_name, blockedReason: row.blocked_reason, retryCount: row.retry_count, + claimedAt: row.claimed_at ? (row.claimed_at as Date).toISOString() : null, }; } @@ -78,7 +81,7 @@ export async function countUnfinishedDeps(db: Queryable, taskId: string): Promis export async function claimTaskRow(db: Queryable, taskId: string, agentId: string): Promise { const { rows } = await db.query( `UPDATE tasks - SET state = 'CLAIMED', assignee_agent_id = $2, updated_at = now() + SET state = 'CLAIMED', assignee_agent_id = $2, claimed_at = now(), updated_at = now() WHERE id = $1 AND state = 'READY' AND assignee_agent_id IS NULL RETURNING *`, [taskId, agentId], @@ -298,3 +301,67 @@ export async function listClaimableTasks(db: Queryable, projectId: string, teamR ); return rows.map(toTask); } + +// ── 멈춘 태스크(BLOCKED · AGENT_STOPPED) ────────────────────────────────── + +// 에이전트가 잡고 있던(CLAIMED·IN_PROGRESS) 태스크를 BLOCKED(AGENT_STOPPED)로. 담당은 남겨 둔다(누가 멈췄는지 화면에 보인다). +// 조건부 UPDATE라 그사이 제출로 상태가 바뀌었으면 아무것도 하지 않고 null. +export async function blockStoppedTask(db: Queryable, taskId: string): Promise { + const { rows } = await db.query( + `UPDATE tasks SET state = 'BLOCKED', blocked_reason = 'AGENT_STOPPED', updated_at = now() + WHERE id = $1 AND state IN ('CLAIMED', 'IN_PROGRESS') + RETURNING *`, + [taskId], + ); + return rows[0] ? toTask(rows[0]) : null; +} + +// 대표가 재개: READY로 돌리고 담당을 비운다(같은 역할의 에이전트가 다시 가져간다). retry_count는 건드리지 않는다. +export async function resumeStoppedTask(db: Queryable, taskId: string): Promise { + const { rows } = await db.query( + `UPDATE tasks SET state = 'READY', blocked_reason = NULL, assignee_agent_id = NULL, updated_at = now() + WHERE id = $1 AND state = 'BLOCKED' AND blocked_reason = 'AGENT_STOPPED' + RETURNING *`, + [taskId], + ); + return rows[0] ? toTask(rows[0]) : null; +} + +// 응답이 끊긴 수령: 잡은 지(claimed_at) threshold가 지났고, 그 뒤로 실행 보고·도구 사용이 threshold 동안 없는 태스크. +export async function listUnresponsiveClaims(db: Queryable, thresholdMs: number): Promise<{ taskId: string; projectId: string; orgId: string; agentId: string; onBehalfOf: string }[]> { + const { rows } = await db.query( + `SELECT t.id, t.project_id, p.org_id, t.assignee_agent_id, a.user_id + FROM tasks t + JOIN projects p ON p.id = t.project_id + JOIN agents a ON a.id = t.assignee_agent_id + WHERE t.state IN ('CLAIMED', 'IN_PROGRESS') + AND t.claimed_at < now() - make_interval(secs => $1::float8 / 1000) + AND NOT EXISTS (SELECT 1 FROM agent_activity aa + WHERE aa.task_id = t.id AND aa.ts > now() - make_interval(secs => $1::float8 / 1000)) + AND NOT EXISTS (SELECT 1 FROM events e + WHERE e.type IN ('AGENT_RUN_STARTED', 'AGENT_RUN_ENDED') AND e.payload->>'taskId' = t.id::text + AND e.ts > now() - make_interval(secs => $1::float8 / 1000))`, + [thresholdMs], + ); + return rows.map((r) => ({ + taskId: r.id as string, + projectId: r.project_id as string, + orgId: r.org_id as string, + agentId: r.assignee_agent_id as string, + onBehalfOf: r.user_id as string, + })); +} + +// 검증 중에 행동 키가 새로 드러났다(예: package.json의 의존성 추가 → dep:add). 더 엄격한 쪽으로만 바뀐다 — 호출부가 계산한다. +export async function escalateArtifactActions( + db: Queryable, + artifactId: string, + triggeredActions: string[], + gateMode: string, +): Promise { + const { rows } = await db.query( + `UPDATE artifacts SET triggered_actions = $2, gate_mode = $3 WHERE id = $1 RETURNING *`, + [artifactId, triggeredActions, gateMode], + ); + return toArtifact(rows[0]!); +} diff --git a/src/domain/task/service.ts b/src/domain/task/service.ts index eb62d04..fcfd630 100644 --- a/src/domain/task/service.ts +++ b/src/domain/task/service.ts @@ -2,6 +2,7 @@ import { withTransaction } from '../../config/db.js'; import { AppError, type ErrorCode } from '../../errors.js'; import { tasksChanged } from '../dispatch/tasks-changed.js'; import { appendEvent } from '../events/append.js'; +import { recordDispatches } from '../room/dispatch.js'; import { findLatestRejection } from '../approval/repository.js'; import type { DenialStage, PathDenialReason } from '../events/types.js'; import { settle, type Outcome } from '../outcome.js'; @@ -310,7 +311,10 @@ export async function submitArtifact( } // 이 에이전트가 지금 가져갈 수 있는 태스크(푸시와 같은 스냅샷). 웹소켓이 끊겼을 때 폴링하는 자리다. +// 보내기 전에 "실행해 주세요"(TASK_DISPATCHED)를 먼저 기록한다 — 서버 밖(seed:tasks 스크립트)에서 들어온 태스크는 tasksChanged가 없어 +// 기록되지 않은 채 에이전트에게 먼저 갈 수 있다. 에이전트가 받는 목록에 있는 태스크는 반드시 지시가 남아 있게 한다(중복은 PK가 막는다). export async function listClaimableTasksForAgent(ctx: AgentContext): Promise { + await recordDispatches(ctx.projectId); return withTransaction(async (tx) => { const membership = await findAgentMembership(tx, ctx.agentId); if (!membership || membership.projectId !== ctx.projectId) { diff --git a/src/domain/task/stall.ts b/src/domain/task/stall.ts new file mode 100644 index 0000000..b2854c7 --- /dev/null +++ b/src/domain/task/stall.ts @@ -0,0 +1,100 @@ +import type { PoolClient } from 'pg'; +import { pool, withTransaction } from '../../config/db.js'; +import { logger } from '../../config/logger.js'; +import { AppError } from '../../errors.js'; +import { tasksChanged } from '../dispatch/tasks-changed.js'; +import { appendEvent } from '../events/append.js'; +import { findProjectById } from '../project/repository.js'; +import type { TaskBlockedPayload } from '../events/types.js'; +import { blockStoppedTask, findTaskById, listUnresponsiveClaims, resumeStoppedTask, type Task } from './repository.js'; + +// 에이전트가 제출 없이 멈춘 태스크. CLAIMED로 남기면 아무도 다시 가져가지 못하고(운영 테스트의 영구 정체), +// READY로 되돌려 재시도를 깎으면 권한·환경 탓(쉘 거부·선행 코드 없음)을 에이전트 실패로 센다(대표 결정). +// 그래서 BLOCKED(AGENT_STOPPED)로 멈추고 사유를 남긴 뒤, 대표가 원인을 해결하고 재개하면 READY. retry_count는 검증 실패·반려만 쓴다. + +export const WATCHDOG = 'system:watchdog'; +// Executor의 실행 제한(30분) + 여유. 그동안 실행 보고·도구 사용이 하나도 없으면 에이전트가 사라진 것으로 본다. +export const UNRESPONSIVE_AFTER_MS = 45 * 60 * 1000; + +type BlockInput = Omit; + +// 같은 트랜잭션에서 상태와 이벤트를 함께 바꾼다. 그사이 제출로 상태가 바뀌었으면 null(아무것도 하지 않음). +export async function blockStoppedTaskInTx( + tx: PoolClient, + ctx: { orgId: string; projectId: string; onBehalfOf: string; policyHash?: string }, + taskId: string, + input: BlockInput, +): Promise { + const blocked = await blockStoppedTask(tx, taskId); + if (blocked === null) return null; + await appendEvent(tx, { + orgId: ctx.orgId, + projectId: ctx.projectId, + type: 'TASK_BLOCKED', + actorAgentId: input.agentId, + onBehalfOf: ctx.onBehalfOf, + ...(ctx.policyHash === undefined ? {} : { policyHash: ctx.policyHash }), + payload: { taskId, reason: 'AGENT_STOPPED', ...input }, + }); + return blocked; +} + +// 대표의 재개. 원인(허용 명령·설치·명세)을 해결했다는 판단은 사람이 한다. +export async function resumeTask( + actor: { userId: string; orgId: string; orgRole: string }, + taskId: string, + note: string | null, +): Promise { + if (actor.orgRole !== 'REPRESENTATIVE') throw new AppError('NOT_REPRESENTATIVE', 'only the representative can resume a stopped task'); + const resumed = await withTransaction(async (tx) => { + const task = await findTaskById(tx, taskId); + if (!task) throw new AppError('TASK_NOT_FOUND', `task ${taskId} not found`); + const project = await findProjectById(tx, task.projectId); + if (project?.orgId !== actor.orgId) throw new AppError('CROSS_ORG_ACCESS', 'cannot access another organization'); + const next = await resumeStoppedTask(tx, taskId); + if (next === null) { + throw new AppError('TASK_NOT_STOPPED', `task ${taskId} is not stopped (state ${task.state}, reason ${task.blockedReason ?? '-'})`); + } + await appendEvent(tx, { + orgId: actor.orgId, + projectId: task.projectId, + type: 'TASK_RESUMED', + onBehalfOf: actor.userId, + payload: { taskId, note }, + }); + return next; + }); + // 다시 가져갈 수 있게 됐다 — "다시 실행해 주세요"와 에이전트 푸시. + tasksChanged(resumed.projectId); + return resumed; +} + +// 감시: 응답이 끊긴 수령을 멈춤으로 바꾼다(Executor가 꺼져 종료 보고조차 오지 않은 경우). +export async function sweepUnresponsiveClaims(thresholdMs: number = UNRESPONSIVE_AFTER_MS): Promise { + const stale = await listUnresponsiveClaims(pool, thresholdMs); + let count = 0; + for (const s of stale) { + const blocked = await withTransaction((tx) => + blockStoppedTaskInTx(tx, { orgId: s.orgId, projectId: s.projectId, onBehalfOf: WATCHDOG }, s.taskId, { + cause: 'unresponsive', + agentId: s.agentId, + lastMessage: null, + deniedCommands: [], + }), + ); + if (blocked) { + count += 1; + tasksChanged(s.projectId); + } + } + return count; +} + +// startServer에서만 건다(테스트 앱·migrate에는 걸지 않는다). +export function startStallWatchdog(intervalMs = 5 * 60 * 1000): () => void { + const timer = setInterval(() => { + sweepUnresponsiveClaims().catch((err: unknown) => logger.warn('stall watchdog failed', { error: String(err) })); + }, intervalMs); + timer.unref(); + return () => clearInterval(timer); +} diff --git a/src/domain/verification/commit-inspector.ts b/src/domain/verification/commit-inspector.ts index 616d24a..a252071 100644 --- a/src/domain/verification/commit-inspector.ts +++ b/src/domain/verification/commit-inspector.ts @@ -7,7 +7,7 @@ import { pool } from '../../config/db.js'; import { env } from '../../config/env.js'; import { logger } from '../../config/logger.js'; import { githubInspector } from './github-inspector.js'; -import { CommitNotFoundError, InspectionSkipped, type CommitInspector } from './inspection.js'; +import { CommitNotFoundError, InspectionSkipped, MAX_MANIFEST_BYTES, type CommitInspector } from './inspection.js'; export { CommitNotFoundError, InspectionSkipped, type CommitInspector, type InspectInput } from './inspection.js'; @@ -81,6 +81,24 @@ export function gitMirrorInspector(root: string = mirrorRoot()): CommitInspector .map((line) => line.trim()) .filter((line) => line.length > 0); }, + + // changedPaths가 먼저 돌아 mirror가 최신이다. 경로는 git의 `:` 문법에 들어가므로 콜론·줄바꿈이 든 경로는 받지 않는다. + async fileVersions({ repoId, commitSha }, filePath) { + if (/[:\r\n\0]/.test(filePath) || filePath.startsWith('-')) throw new Error(`unsupported path: ${filePath}`); + const dir = path.join(root, `${repoId}.git`); + const show = async (rev: string): Promise => { + try { + const { stdout } = await run('git', ['--git-dir', dir, 'show', `${rev}:${filePath}`], { maxBuffer: MAX_MANIFEST_BYTES }); + return stdout; + } catch (err) { + const message = String((err as { stderr?: unknown }).stderr ?? err); + // 그 시점에 파일이 없었다(새 파일·지운 파일) 또는 부모가 없다(첫 커밋). + if (/does not exist|exists on disk, but not in|invalid object name|bad revision|unknown revision/i.test(message)) return null; + throw err; + } + }; + return { before: await show(`${commitSha}^`), after: await show(commitSha) }; + }, }; } diff --git a/src/domain/verification/github-inspector.ts b/src/domain/verification/github-inspector.ts index e8dc891..057bdf3 100644 --- a/src/domain/verification/github-inspector.ts +++ b/src/domain/verification/github-inspector.ts @@ -2,7 +2,7 @@ import type { Queryable } from '../../config/db.js'; import { decryptSecret } from '../../utils/secret-box.js'; import { parseNextLink } from '../github/client.js'; import { findRepresentativeGithubToken } from '../oauth/repository.js'; -import { CommitNotFoundError, InspectionSkipped, type CommitInspector } from './inspection.js'; +import { CommitNotFoundError, InspectionSkipped, MAX_MANIFEST_BYTES, type CommitInspector } from './inspection.js'; // GitHub 커밋 API로 "바뀐 파일 목록"만 읽는다. patch(코드 내용)는 응답에 오더라도 쓰지 않고 저장하지도 않는다. // @@ -97,5 +97,35 @@ export function githubInspector(options: { db: Queryable; fetchImpl?: FetchLike; return [...paths]; }, + + // 부모 sha는 커밋 API에서, 내용은 contents API(raw)에서. 404면 그 시점에 파일이 없었다. + async fileVersions({ orgId, githubRepoId, commitSha }, filePath) { + if (githubRepoId === null) throw new InspectionSkipped('repos.github_repo_id 미설정'); + const sealed = await findRepresentativeGithubToken(options.db, orgId); + if (sealed === null) throw new InspectionSkipped('GitHub 미연결'); + const token = await decryptSecret(sealed); + const commit = await get(`${api}/repositories/${githubRepoId}/commits/${encodeURIComponent(commitSha)}`, token); + if (!commit.ok) throw new Error(`GitHub 응답 ${commit.status}`); + const parent = ((await commit.json()) as { parents?: { sha: string }[] }).parents?.[0]?.sha ?? null; + const encoded = filePath.split('/').map(encodeURIComponent).join('/'); + const read = async (ref: string | null): Promise => { + if (ref === null) return null; + const res = await fetchImpl(`${api}/repositories/${githubRepoId}/contents/${encoded}?ref=${encodeURIComponent(ref)}`, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github.raw+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'nomos-server', + }, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + if (res.status === 404) return null; + if (!res.ok) throw new Error(`GitHub 응답 ${res.status}`); + const text = await res.text(); + if (text.length > MAX_MANIFEST_BYTES) throw new Error('manifest too large'); + return text; + }; + return { before: await read(parent), after: await read(commitSha) }; + }, }; } diff --git a/src/domain/verification/inspection.ts b/src/domain/verification/inspection.ts index a25fe54..865a6cc 100644 --- a/src/domain/verification/inspection.ts +++ b/src/domain/verification/inspection.ts @@ -14,8 +14,14 @@ export type CommitInspector = { // 커밋이 건드린 경로 목록. 레포 루트 기준 상대경로, 슬래시 구분. // 없는 커밋이면 CommitNotFoundError(→ FAIL), 서버 쪽 사정으로 못 읽으면 InspectionSkipped(→ SKIPPED). changedPaths(input: InspectInput): Promise; + // 그 커밋 직전(부모)과 직후의 파일 내용 — dep:add 판정(package.json의 의존성 diff)이 쓴다. 없던 파일·지운 파일은 null. + // 못 읽으면 던진다(호출부는 fail closed — dep:add로 본다). 구현하지 않은 검사기도 같은 취급이다. + fileVersions?(input: InspectInput, filePath: string): Promise<{ before: string | null; after: string | null }>; }; +// 매니페스트는 작다. 이보다 크면 의존성 판정 대상이 아니라고 보고 읽지 않는다(던진다 → fail closed). +export const MAX_MANIFEST_BYTES = 512 * 1024; + export class CommitNotFoundError extends Error { constructor(readonly commitSha: string, cause?: unknown) { super(`commit ${commitSha} not found`); diff --git a/src/domain/verification/service.ts b/src/domain/verification/service.ts index 962c93f..ae94b11 100644 --- a/src/domain/verification/service.ts +++ b/src/domain/verification/service.ts @@ -2,12 +2,14 @@ import type { PoolClient } from 'pg'; import { withTransaction, type Queryable } from '../../config/db.js'; import { tasksChanged } from '../dispatch/tasks-changed.js'; import { MAX_RETRIES } from '../task/retry.js'; -import { requestActionApproval } from '../approval/service.js'; +import { requestActionApproval, requestReleaseIfComplete } from '../approval/service.js'; import { AppError } from '../../errors.js'; import { logger } from '../../config/logger.js'; import { appendEvent } from '../events/append.js'; import { getPolicySnapshot } from '../policy/policy-cache.js'; -import { inspectPaths } from '../policy/scope-check.js'; +import { diffDependencies } from '../policy/dependency-diff.js'; +import type { PolicyMode } from '../policy/pm-review-fallback.js'; +import { inspectPaths, strictestMode } from '../policy/scope-check.js'; import { assertProjectVisibleToUser, type UserContext } from '../project/visibility.js'; import { findRepoById } from '../repo/repository.js'; import type { RepoPath } from '../repo/types.js'; @@ -15,6 +17,7 @@ import type { TeamRole } from '../roles.js'; import { countSpecTestsLockedBefore, failTaskForRetry, + escalateArtifactActions, findArtifactById, findTaskById, markTaskState, @@ -268,8 +271,80 @@ async function recordSummary( ...(summary.outcome === 'RETRY' || summary.outcome === 'ESCALATED' ? { retryCause: 'VERIFICATION_FAILED' as const } : {}), }, }); + // 이 결론으로 마지막 태스크가 DONE이 됐으면 통합 확인(G3)을 요청한다 — 검증 결과 줄 뒤에 오게 이벤트 다음에 부른다. + if (summary.taskState === 'DONE') await requestReleaseIfComplete(tx, { orgId: ctx.orgId, projectId: ctx.projectId }); } +// ── dep:add — package.json의 의존성 추가 ────────────────────────────────── +// 경로 규칙으로는 잡지 않는다(scripts만 고친 변경까지 dep:add가 되어 매번 승인 대기열에 쌓인다). 커밋 직전·직후의 package.json을 읽어 +// dependencies·devDependencies에 새 패키지나 버전 변경이 있을 때만 건다(domain/policy/dependency-diff.ts). 새로 만든 package.json도 추가다. +// 못 읽으면(검사기가 못 읽음·지원 안 함·JSON 깨짐) **dep:add로 본다**(fail closed) — 운영 테스트에서 FE의 새 package.json이 AUTO로 통과했다. +type DependencyDetection = { + paths: string[]; + changes: { path: string; section: string; name: string; from: string | null; to: string }[]; + unreadable: string[]; +}; + +async function detectDependencyAdditions(plan: Plan): Promise { + const manifests = plan.artifact.changedPaths.filter((p) => p.replace(/\\/g, '/').split('/').pop()?.toLowerCase() === 'package.json'); + const result: DependencyDetection = { paths: [], changes: [], unreadable: [] }; + const inspector = getCommitInspector(); + for (const manifest of manifests) { + try { + if (!inspector.fileVersions) throw new Error('inspector cannot read file contents'); + const { before, after } = await inspector.fileVersions( + { repoId: plan.repo.id, orgId: plan.repo.orgId, cloneUrl: plan.repo.cloneUrl, githubRepoId: plan.repo.githubRepoId, commitSha: plan.artifact.commitSha }, + manifest, + ); + const diff = diffDependencies(before, after); + if (diff.unparseable) { + result.paths.push(manifest); + result.unreadable.push(manifest); + } else if (diff.changes.length > 0) { + result.paths.push(manifest); + result.changes.push(...diff.changes.map((c) => ({ path: manifest, section: c.section, name: c.name, from: c.from, to: c.to }))); + } + } catch (err) { + logger.warn('package.json 내용을 읽지 못해 dep:add로 본다', { artifactId: plan.artifact.id, path: manifest, err: String(err) }); + result.paths.push(manifest); + result.unreadable.push(manifest); + } + } + return result; +} + +// 산출물에 dep:add를 더하고 판정을 더 엄격한 쪽으로 다시 계산한다(정책 사본 기준). 이미 걸려 있으면(requirements.txt 경로 규칙 등) 그대로. +async function escalateForDependencies(tx: PoolClient, ctx: EventContext, plan: Plan, deps: DependencyDetection): Promise { + const current = (await findArtifactById(tx, plan.artifact.id))!; + if (deps.paths.length === 0 || current.triggeredActions.includes(DEP_ADD)) return current; + const snapshot = await getPolicySnapshot(tx, ctx.projectId, ctx.policyHash); + const triggered = [...new Set([...current.triggeredActions, DEP_ADD])].sort(); + const modes = triggered.map((key) => snapshot.policies.find((p) => p.actionKey === key)?.mode ?? 'HUMAN') as PolicyMode[]; + const gateMode = strictestMode(modes); + const escalated = await escalateArtifactActions(tx, current.id, triggered, gateMode); + await appendEvent(tx, { + orgId: ctx.orgId, + projectId: ctx.projectId, + type: 'ACTION_DETECTED', + ...(ctx.agentId === undefined ? {} : { actorAgentId: ctx.agentId }), + onBehalfOf: ctx.onBehalfOf, + policyHash: ctx.policyHash, + payload: { + taskId: plan.task.id, + artifactId: current.id, + actionKey: DEP_ADD, + paths: deps.paths, + changes: deps.changes.slice(0, 50), + unreadable: deps.unreadable, + gateModeBefore: current.gateMode, + gateModeAfter: gateMode, + }, + }); + return escalated; +} + +const DEP_ADD = 'dep:add'; + // ── 서버 단계 실행 ──────────────────────────────────────────────────────── // 제출 응답 안에서 동기로 돈다. V1A·V1B·V3는 전부 서버가 혼자 판정할 수 있고, // 비동기로 돌리면 "제출은 됐는데 결과는 언제 오는가"를 관리하는 상태가 하나 더 는다. @@ -321,12 +396,15 @@ export async function runServerVerifications( await evaluateV1B(plan), await evaluateV3(plan), ]; + // 의존성 추가(dep:add)는 경로가 아니라 내용 diff로 판정한다 — 네트워크·git이라 트랜잭션 밖에서. + const deps = await detectDependencyAdditions(plan); return withTransaction(async (tx) => { for (const r of results) await insertVerification(tx, r); // 결론을 내리기 직전에 태스크를 다시 읽는다 — 위 I/O 동안 상태가 바뀌었을 수 있다. const task = (await findTaskById(tx, plan.task.id))!; - const summary = await settle(tx, ctx, plan.artifact, task); + const artifact = await escalateForDependencies(tx, ctx, plan, deps); + const summary = await settle(tx, ctx, artifact, task); await recordSummary(tx, ctx, task, summary); return summary; }); diff --git a/src/errors.ts b/src/errors.ts index 25621ba..ee320a2 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -34,6 +34,10 @@ export type ErrorCode = | 'GITHUB_NOT_LINKED' | 'GITHUB_FORBIDDEN' | 'GITHUB_REPO_NAME_TAKEN' + | 'RUN_NOT_ALLOWED' + | 'RUN_NOT_OPEN' + | 'ROOM_NOT_VISIBLE' + | 'TASK_NOT_STOPPED' | 'TASK_NOT_FOUND' | 'TASK_ALREADY_CLAIMED' | 'ARTIFACT_NOT_FOUND' @@ -115,6 +119,14 @@ const STATUS_BY_CODE: Record = { // GitHub가 대표 토큰의 요청을 거부했다(조직 권한 없음, 조직이 NOMOS 앱을 승인하지 않음 등). GITHUB_FORBIDDEN: 403, GITHUB_REPO_NAME_TAKEN: 409, + // 실행 시작은 그 태스크를 잡고 있는 에이전트만(CLAIMED·IN_PROGRESS). + RUN_NOT_ALLOWED: 409, + // 시작하지 않았거나 이미 끝낸 실행에 활동·종료를 보냈다. + RUN_NOT_OPEN: 409, + // 팀원은 자기 역할 룸만 본다(대표는 전부). + ROOM_NOT_VISIBLE: 403, + // 재개는 BLOCKED(AGENT_STOPPED)인 태스크만. + TASK_NOT_STOPPED: 409, TASK_NOT_FOUND: 404, // 409 — 권한 문제가 아니라 경합에서 진 것이다. 재시도하면 다른 태스크를 잡으면 된다. TASK_ALREADY_CLAIMED: 409, diff --git a/src/executor/activity.ts b/src/executor/activity.ts new file mode 100644 index 0000000..18b52e5 --- /dev/null +++ b/src/executor/activity.ts @@ -0,0 +1,194 @@ +import path from 'node:path'; +import type { NomosClient } from '../bridge/nomos-client.js'; + +// Claude 실행(stream-json)에서 "무슨 도구로 무엇을 건드렸나"만 뽑아 룸으로 보낸다. +// 보내는 것은 도구 종류와 대상(작업공간 기준 경로·명령 첫 줄)뿐이다. 파일 내용·명령 결과·모델의 설명 문장은 보내지 않는다 — +// 코드가 서버에 쌓이고, 설명은 인계 노트의 몫이다(대표 결정). + +export type ActivityKind = 'read' | 'edit' | 'write' | 'run' | 'search' | 'other'; +export type ActivityItem = { kind: ActivityKind; target: string }; + +const MAX_TARGET = 300; + +function clip(text: string): string { + const oneLine = text.split(/\r?\n/)[0]!.trim(); + return oneLine.length > MAX_TARGET ? `${oneLine.slice(0, MAX_TARGET - 1)}…` : oneLine; +} + +// 작업공간 안의 경로는 상대 경로로(슬래시 통일), 밖이면 그대로. 노트북의 사용자 폴더 이름이 룸에 찍히지 않게 한다. +function relative(cwd: string, p: unknown): string | null { + if (typeof p !== 'string' || p === '') return null; + const abs = path.resolve(cwd, p); + const rel = path.relative(cwd, abs); + if (rel === '') return '.'; + if (rel.startsWith('..') || path.isAbsolute(rel)) return path.basename(abs); + return rel.split(path.sep).join('/'); +} + +// 명령 안의 작업공간 절대 경로를 '.'으로 바꾼다 — 모델이 `git -C "C:/Users/<이름>/.nomos/..."`처럼 쓰면 사용자 폴더 이름이 룸에 찍힌다(실측). +function hideWorkspace(command: string, cwd: string): string { + const forms = new Set([cwd, cwd.split(path.sep).join('/'), cwd.split('/').join('\\')]); + let out = command; + for (const form of [...forms].sort((a, b) => b.length - a.length)) { + if (form) out = out.split(form).join('.'); + } + return out; +} + +// 도구 하나 → 활동 한 줄. 룸에 보일 필요가 없는 것(NOMOS 도구 — 제출·노트는 서버 이벤트로 이미 보인다, 할 일 목록)은 null. +export function toActivity(tool: string, input: Record, cwd: string): ActivityItem | null { + // ToolSearch는 Claude Code가 도구 정의를 불러오는 내부 동작이다(실측: 실행마다 첫 줄로 찍혔다). + if (tool.startsWith('mcp__nomos__') || ['TodoWrite', 'AskUserQuestion', 'ToolSearch'].includes(tool)) return null; + const file = (key: string): string | null => relative(cwd, input[key]); + let item: ActivityItem | null = null; + switch (tool) { + case 'Read': { + const f = file('file_path'); + item = f ? { kind: 'read', target: f } : null; + break; + } + case 'Edit': + case 'MultiEdit': { + const f = file('file_path'); + item = f ? { kind: 'edit', target: f } : null; + break; + } + case 'NotebookEdit': { + const f = file('notebook_path'); + item = f ? { kind: 'edit', target: f } : null; + break; + } + case 'Write': { + const f = file('file_path'); + item = f ? { kind: 'write', target: f } : null; + break; + } + case 'Bash': + item = typeof input.command === 'string' && input.command.trim() ? { kind: 'run', target: hideWorkspace(input.command, cwd) } : null; + break; + case 'Grep': + case 'Glob': + item = typeof input.pattern === 'string' && input.pattern ? { kind: 'search', target: input.pattern } : null; + break; + default: + item = { kind: 'other', target: tool }; + } + return item ? { kind: item.kind, target: clip(item.target) } : null; +} + +// stream-json 한 줄 → 그 줄에 든 도구 호출들. 형식이 다르거나 깨진 줄은 조용히 건너뛴다(룸 표시가 실행을 막으면 안 된다). +export function activityFromStreamLine(line: string, cwd: string): ActivityItem[] { + const trimmed = line.trim(); + if (!trimmed.startsWith('{')) return []; + let msg: { type?: unknown; message?: { content?: unknown } }; + try { + msg = JSON.parse(trimmed) as typeof msg; + } catch { + return []; + } + if (msg.type !== 'assistant' || !Array.isArray(msg.message?.content)) return []; + const items: ActivityItem[] = []; + for (const block of msg.message.content as { type?: unknown; name?: unknown; input?: unknown }[]) { + if (block.type !== 'tool_use' || typeof block.name !== 'string') continue; + const item = toActivity(block.name, (block.input ?? {}) as Record, cwd); + if (item) items.push(item); + } + return items; +} + +// 활동을 모아 2초마다 한 번에 보낸다. 보내기 실패는 기록만 하고 넘어간다 — 룸 표시가 태스크 실행을 멈추면 안 된다. +export class ActivityReporter { + private queue: ActivityItem[] = []; + private timer: ReturnType | null = null; + private sending: Promise = Promise.resolve(); + private warned = false; + + constructor( + private readonly client: Pick, + private readonly taskId: string, + private readonly log: (line: string) => void, + intervalMs = 2_000, + ) { + this.timer = setInterval(() => void this.flush(), intervalMs); + this.timer.unref(); + } + + push(items: ActivityItem[]): void { + this.queue.push(...items); + } + + flush(): Promise { + this.sending = this.sending.then(async () => { + while (this.queue.length > 0) { + const batch = this.queue.splice(0, 50); + try { + await this.client.postActivity(this.taskId, batch); + } catch (err) { + if (!this.warned) { + this.warned = true; + this.log(`룸 활동 보고 실패(실행은 계속한다): ${err instanceof Error ? err.message : String(err)}`); + } + } + } + }); + return this.sending; + } + + async close(): Promise { + if (this.timer) clearInterval(this.timer); + this.timer = null; + await this.flush(); + } +} + +// 실행이 제출 없이 끝났을 때 대표에게 보일 사유를 모은다 — 거부된 쉘 명령과 모델의 마지막 말. +// 거부는 Claude Code가 돌려준 도구 결과(is_error)의 문구로 가른다("requires approval" 등). 문구가 바뀌면 빠질 수 있으나 판정이 아니라 안내라 괜찮다. +const DENIAL = /requires (manual )?approval|permission|not allowed|denied/i; +const SHELL_TOOLS = new Set(['Bash', 'PowerShell']); + +export class RunObserver { + private readonly commands = new Map(); + private readonly denied: string[] = []; + private lastText: string | null = null; + private resultText: string | null = null; + + constructor(private readonly cwd: string) {} + + observe(line: string): void { + const trimmed = line.trim(); + if (!trimmed.startsWith('{')) return; + let msg: { type?: unknown; result?: unknown; message?: { content?: unknown } }; + try { + msg = JSON.parse(trimmed) as typeof msg; + } catch { + return; + } + if (msg.type === 'result' && typeof msg.result === 'string') this.resultText = msg.result; + if (!Array.isArray(msg.message?.content)) return; + for (const block of msg.message.content as Record[]) { + if (msg.type === 'assistant' && block.type === 'text' && typeof block.text === 'string' && block.text.trim()) { + this.lastText = block.text; + } + if (msg.type === 'assistant' && block.type === 'tool_use' && typeof block.name === 'string' && SHELL_TOOLS.has(block.name)) { + const command = (block.input as { command?: unknown } | undefined)?.command; + if (typeof block.id === 'string' && typeof command === 'string') this.commands.set(block.id, command); + } + if (msg.type === 'user' && block.type === 'tool_result' && block.is_error === true && typeof block.tool_use_id === 'string') { + const command = this.commands.get(block.tool_use_id); + const content = typeof block.content === 'string' ? block.content : JSON.stringify(block.content ?? ''); + if (command !== undefined && DENIAL.test(content)) { + const shown = clip(hideWorkspace(command, this.cwd)); + if (!this.denied.includes(shown) && this.denied.length < 20) this.denied.push(shown); + } + } + } + } + + summary(): { lastMessage: string | null; deniedCommands: string[] } { + const text = this.resultText ?? this.lastText; + return { + lastMessage: text ? hideWorkspace(text, this.cwd).trim().slice(0, 2000) : null, + deniedCommands: [...this.denied], + }; + } +} diff --git a/src/executor/cli.ts b/src/executor/cli.ts index 906c881..9dc45ed 100644 --- a/src/executor/cli.ts +++ b/src/executor/cli.ts @@ -21,7 +21,8 @@ import os from 'node:os'; import path from 'node:path'; import { createInterface } from 'node:readline/promises'; import { writeBriefingNotes } from '../bridge/briefing-notes.js'; -import { buildMcpConfig } from '../bridge/claude-args.js'; +import { ALLOWED_BASH_PREFIXES, buildMcpConfig } from '../bridge/claude-args.js'; +import { prepareDependencies } from './setup.js'; import { credentialsPath, readCredentials, @@ -29,6 +30,7 @@ import { writeCredentials, } from '../bridge/credentials.js'; import { NomosApiError, NomosClient } from '../bridge/nomos-client.js'; +import { ActivityReporter, activityFromStreamLine, RunObserver } from './activity.js'; import { connect, DEFAULT_SERVER, serverCalls } from './connect.js'; import { deviceLogin } from './device-login.js'; import { cliVersion, mcpServerPath } from './paths.js'; @@ -107,14 +109,44 @@ async function handleTask(client: NomosClient, projectId: string, task: TaskSumm buildMcpConfig({ serverPath: mcpServerPath(), workspaceDir: workspace.dir }), ); + // 룸: 실행 시작을 알리고, 도는 동안 도구 사용을 보낸다. 룸 보고가 실패해도 실행은 그대로 간다(옛 서버에는 이 API가 없다). + const runOpen = await client + .startRun(task.id) + .then(() => true) + .catch((err: unknown) => { + log(`룸에 실행 시작을 알리지 못했다(실행은 계속한다): ${err instanceof Error ? err.message : String(err)}`); + return false; + }); + const reporter = runOpen ? new ActivityReporter(client, task.id, log) : null; + const observer = new RunObserver(workspace.dir); + + // 의존성은 모델이 아니라 여기서 설치한다(설치 스크립트 끔). 실패해도 진행한다 — 프롬프트에 결과를 적는다. + const setup = await prepareDependencies(workspace.dir, log); + const before = headSha(workspace.dir); const result = await runClaude({ workspaceDir: workspace.dir, - prompt: buildTaskPrompt(briefing, workspace.branch), + prompt: buildTaskPrompt(briefing, workspace.branch, { allowedCommands: ALLOWED_BASH_PREFIXES, setup: setup.steps }), mcpConfigPath, + pathPrepend: setup.venvBin, + onStdoutLine: (line: string) => { + observer.observe(line); + reporter?.push(activityFromStreamLine(line, workspace.dir)); + }, }); const committed = headSha(workspace.dir) !== before; + if (reporter) { + await reporter.close(); + // 제출했는지는 서버가 태스크 상태로 판단해 룸에 남긴다("끝났지만 제출하지 않았다"가 대표에게 보인다). + await client + .endRun(task.id, { outcome: result.outcome, committed, durationMs: result.durationMs, exitCode: result.exitCode, ...observer.summary() }) + .then((r) => { + if (r.blocked) log('제출 없이 끝나 태스크가 멈춤(BLOCKED)으로 바뀌었다 — 대표가 원인을 해결하고 재개하면 다시 가져간다'); + }) + .catch((err: unknown) => log(`룸에 실행 종료를 알리지 못했다: ${err instanceof Error ? err.message : String(err)}`)); + } + // 자동 재시도는 넣지 않는다. tasks.retry_count는 서버가 관리하는 값이고, // Executor가 멋대로 돌리면 M4(재작업률)가 오염된다. if (result.outcome === 'completed' && committed) { diff --git a/src/executor/prompt.ts b/src/executor/prompt.ts index 7149d2c..1f7c94f 100644 --- a/src/executor/prompt.ts +++ b/src/executor/prompt.ts @@ -11,7 +11,13 @@ export type PromptBriefing = { writablePaths: { pathPattern: string }[]; }; -export function buildTaskPrompt(briefing: PromptBriefing, branch: string): string { +// 작업 환경(운영 테스트 4-4). 거부된 명령을 바꿔 가며 재시도하느라 턴을 쓰지 않게, 쓸 수 있는 것과 없는 것을 미리 말한다. +export type PromptEnvironment = { + allowedCommands: readonly string[]; + setup: { command: string; ok: boolean }[]; +}; + +export function buildTaskPrompt(briefing: PromptBriefing, branch: string, environment?: PromptEnvironment): string { const { task, repo, spec } = briefing; const sections: string[] = []; @@ -62,6 +68,24 @@ export function buildTaskPrompt(briefing: PromptBriefing, branch: string): strin ].join('\n'), ); + if (environment) { + const installed = environment.setup.length === 0 + ? '- 설치할 의존성 파일(package.json·requirements.txt 등)이 없었다.' + : environment.setup.map((s) => `- ${s.ok ? '설치됨' : '설치 실패'}: ${s.command}`).join('\n'); + sections.push( + [ + '# 작업 환경', + '- 쉘 명령은 Bash 도구로, 아래 접두사로 시작하는 것만 쓸 수 있다. 그 밖의 명령과 PowerShell은 승인할 사람이 없어 **자동으로 거부된다** — 바꿔 가며 다시 시도하지 않는다.', + ...environment.allowedCommands.map((c) => ` - ${c}`), + '- 명령은 한 번에 하나씩 쓴다. `&&`·`;`·`|`로 잇거나 리다이렉트하면 거부된다.', + '- 파일을 보거나 찾을 때는 쉘(ls·cat·find) 대신 Read·Glob·Grep 도구를 쓴다. 작업 폴더 밖은 볼 수 없다.', + '- 의존성은 NOMOS가 미리 설치해 두었다. 직접 설치(npm install·pip install)는 할 수 없다. 새 의존성이 필요하면 매니페스트(package.json·requirements.txt)만 고치고 GOTCHA 노트에 남긴다.', + installed, + '- 환경이 막혀 태스크를 끝낼 수 없으면 지어내거나 시험 없이 통과했다고 하지 않는다. 할 수 있는 만큼 하고, 막힌 이유를 마지막 메시지에 분명히 적고 끝낸다 — 그 문장이 대표에게 그대로 전달되고 대표가 해결한 뒤 다시 시작된다.', + ].join('\n'), + ); + } + sections.push(`# 지금 할 일\n태스크 ${task.id}를 claim_task로 받고 위 규칙에 따라 진행하라.`); return sections.join('\n\n'); diff --git a/src/executor/runner.ts b/src/executor/runner.ts index 379f889..c6e7b8e 100644 --- a/src/executor/runner.ts +++ b/src/executor/runner.ts @@ -33,12 +33,22 @@ export function resolveClaudeCommand(args: string[]): { command: string; command return { command: 'claude', commandArgs: args }; } +// Windows는 환경변수 이름이 대소문자를 가리지 않아 Path로 들어 있는 경우가 많다 — 있는 이름을 그대로 쓴다. +export function withPathPrepended(env: NodeJS.ProcessEnv, dir: string): NodeJS.ProcessEnv { + const key = Object.keys(env).find((k) => k.toUpperCase() === 'PATH') ?? 'PATH'; + return { ...env, [key]: env[key] ? `${dir}${path.delimiter}${env[key]}` : dir }; +} + export type RunInput = { workspaceDir: string; prompt: string; mcpConfigPath: string; model?: string; timeoutMs?: number; + // stdout(stream-json) 한 줄씩 실시간으로. 룸 활동 보고가 쓴다. 던져도 실행은 계속된다. + onStdoutLine?: (line: string) => void; + // PATH 앞에 붙일 폴더(작업공간의 파이썬 가상환경) — 모델의 `python -m pytest`가 미리 설치한 의존성을 쓰게. + pathPrepend?: string | null; }; export async function runClaude(input: RunInput): Promise { @@ -57,11 +67,29 @@ export async function runClaude(input: RunInput): Promise { const child = spawn(command, commandArgs, { cwd: input.workspaceDir, stdio: ['ignore', 'pipe', 'pipe'], + ...(input.pathPrepend ? { env: withPathPrepended(process.env, input.pathPrepend) } : {}), }); const chunks: string[] = []; const collect = (c: Buffer) => chunks.push(c.toString()); - child.stdout.on('data', collect); + let partial = ''; + const emitLines = (c: Buffer) => { + if (!input.onStdoutLine) return; + partial += c.toString(); + const lines = partial.split('\n'); + partial = lines.pop() ?? ''; + for (const line of lines) { + try { + input.onStdoutLine(line); + } catch { + // 룸 표시 실패가 실행을 멈추면 안 된다. + } + } + }; + child.stdout.on('data', (c: Buffer) => { + collect(c); + emitLines(c); + }); child.stderr.on('data', collect); let timedOut = false; diff --git a/src/executor/setup.ts b/src/executor/setup.ts new file mode 100644 index 0000000..dd31358 --- /dev/null +++ b/src/executor/setup.ts @@ -0,0 +1,78 @@ +import { exec as execShell, execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +// 의존성 설치는 모델이 아니라 Executor가 Claude를 띄우기 전에 한다(운영 테스트 4-4). +// 모델에게 `npm install`·`pip install`을 열면 설치 스크립트(postinstall·setup.py)가 임의 코드를 돌려 Edit 금지 규칙(.env·contracts)을 우회할 수 있다. +// 그래서 여기서도 스크립트를 끈다: npm 계열은 --ignore-scripts, pip는 바이너리 휠만(--only-binary :all: — 소스 빌드는 코드 실행이다). +// 설치가 실패해도 태스크는 진행한다 — 모델은 설치 없이 할 수 있는 만큼 하고, 못 한 것은 멈춤 사유(거부 명령)로 드러난다. + +const run = promisify(execFile); +const runShell = promisify(execShell); +const INSTALL_TIMEOUT_MS = 5 * 60 * 1000; + +export type SetupStep = { manager: string; command: string; ok: boolean; detail?: string }; +export type SetupResult = { steps: SetupStep[]; venvBin: string | null }; + +// 작업공간에 만든 파이썬 가상환경 이름. info/exclude에 들어 있어 커밋에 섞이지 않는다(workspace.ts). +export const VENV_DIR = '.nomos-venv'; + +type Exec = (cmd: string, args: string[], cwd: string) => Promise; + +const NODE_MANAGERS = new Set(['npm', 'pnpm', 'yarn']); + +const defaultExec: Exec = async (cmd, args, cwd) => { + // Windows에서 npm·pnpm·yarn은 .cmd라 셸 없이 못 띄운다. 그 셋은 명령 이름과 인자가 전부 이 파일의 고정 문자열이라 셸에 넘겨도 주입될 값이 없다. + // 파이썬은 실행 파일 경로(공백이 들어갈 수 있다)를 직접 띄우므로 셸을 쓰지 않는다. + const options = { cwd, timeout: INSTALL_TIMEOUT_MS, windowsHide: true, maxBuffer: 16 * 1024 * 1024 }; + if (process.platform === 'win32' && NODE_MANAGERS.has(cmd)) { + await runShell([cmd, ...args].join(' '), options); + return; + } + await run(cmd, args, options); +}; + +function pythonBin(dir: string): string { + return process.platform === 'win32' ? path.join(dir, VENV_DIR, 'Scripts') : path.join(dir, VENV_DIR, 'bin'); +} + +// 어떤 설치를 할지(순수 함수). 잠금 파일이 있으면 그대로 재현(ci/frozen), 없으면 package.json만으로 설치하되 잠금 파일을 만들지 않는다 +// (만들면 작업공간에 새 파일이 생겨 모델이 커밋에 섞는다). +export function planSetup(has: (file: string) => boolean): { manager: string; cmd: string; args: string[] }[] { + const steps: { manager: string; cmd: string; args: string[] }[] = []; + if (has('pnpm-lock.yaml')) steps.push({ manager: 'pnpm', cmd: 'pnpm', args: ['install', '--frozen-lockfile', '--ignore-scripts'] }); + else if (has('yarn.lock')) steps.push({ manager: 'yarn', cmd: 'yarn', args: ['install', '--frozen-lockfile', '--ignore-scripts'] }); + else if (has('package-lock.json')) steps.push({ manager: 'npm', cmd: 'npm', args: ['ci', '--ignore-scripts', '--no-audit', '--no-fund'] }); + else if (has('package.json')) { + steps.push({ manager: 'npm', cmd: 'npm', args: ['install', '--ignore-scripts', '--no-package-lock', '--no-audit', '--no-fund'] }); + } + if (has('requirements.txt')) { + steps.push({ manager: 'python', cmd: process.platform === 'win32' ? 'python' : 'python3', args: ['-m', 'venv', VENV_DIR] }); + steps.push({ manager: 'pip', cmd: 'venv-python', args: ['-m', 'pip', 'install', '--only-binary', ':all:', '--disable-pip-version-check', '-r', 'requirements.txt'] }); + } + return steps; +} + +export async function prepareDependencies(dir: string, log: (line: string) => void, exec: Exec = defaultExec): Promise { + const plan = planSetup((file) => existsSync(path.join(dir, file))); + const steps: SetupStep[] = []; + let venvBin: string | null = null; + for (const step of plan) { + const cmd = step.cmd === 'venv-python' ? path.join(pythonBin(dir), 'python') : step.cmd; + // venv를 못 만들었으면 pip도 건너뛴다. + if (step.cmd === 'venv-python' && venvBin === null) continue; + const command = `${step.cmd === 'venv-python' ? 'python' : step.cmd} ${step.args.join(' ')}`; + try { + await exec(cmd, step.args, dir); + steps.push({ manager: step.manager, command, ok: true }); + if (step.manager === 'python') venvBin = pythonBin(dir); + log(`의존성 설치: ${command} — OK`); + } catch (err) { + const detail = err instanceof Error ? err.message.split('\n')[0]!.slice(0, 200) : String(err); + steps.push({ manager: step.manager, command, ok: false, detail }); + log(`의존성 설치 실패(태스크는 진행한다): ${command} — ${detail}`); + } + } + return { steps, venvBin }; +} diff --git a/src/executor/workspace.ts b/src/executor/workspace.ts index cf48b21..e7126ba 100644 --- a/src/executor/workspace.ts +++ b/src/executor/workspace.ts @@ -99,7 +99,16 @@ export function prepareWorkspace(input: PrepareInput): PreparedWorkspace { // Executor가 작업공간에 쓰는 파일(MCP 설정·실행 로그·브리핑 노트·정책 표시·settings.json)이 모델의 `git add`에 섞이지 않게 한다. // 커밋에 섞이면 신고 경로와 실제 diff가 어긋나 V3가 FAIL을 낸다. info/exclude는 레포 공통이라 모든 worktree에 걸린다. -export const NOMOS_LOCAL_FILES = ['/.nomos-mcp.json', '/.nomos-run.log', '/.nomos-briefing.json', '/.claude/.nomos-policy.json', '/.claude/settings.json']; +// node_modules·.nomos-venv는 Executor가 미리 설치한 의존성(setup.ts) — 레포에 .gitignore가 없어도 커밋에 섞이지 않게. +export const NOMOS_LOCAL_FILES = [ + '/.nomos-mcp.json', + '/.nomos-run.log', + '/.nomos-briefing.json', + '/.claude/.nomos-policy.json', + '/.claude/settings.json', + '/node_modules/', + '/.nomos-venv/', +]; export function excludeNomosFiles(repoPath: string): void { try { diff --git a/src/realtime/topics.ts b/src/realtime/topics.ts index dd4c533..5938f0f 100644 --- a/src/realtime/topics.ts +++ b/src/realtime/topics.ts @@ -7,7 +7,8 @@ import type { EventType } from '../domain/events/types.js'; // 프로젝트가 있는 이벤트는 전부 'events'(활동 로그)를 함께 받는다 — 여기 매핑이 없는 새 타입도 활동 로그로는 빠지지 않는다. // Record라서 이벤트 타입을 추가하면 여기를 채우기 전에는 컴파일이 안 된다. -export const PROJECT_TOPICS = ['project', 'tasks', 'specs', 'plans', 'approvals', 'notes', 'members', 'events'] as const; +// room: 룸 피드(GET /projects/:id/rooms/:role/feed). 에이전트 활동(agent_activity)은 이벤트가 아니라 room/activity-hub로 따로 온다. +export const PROJECT_TOPICS = ['project', 'tasks', 'specs', 'plans', 'approvals', 'notes', 'members', 'events', 'room'] as const; export const ORG_TOPICS = ['projects', 'approvals', 'agents', 'repos', 'members'] as const; export type ProjectTopic = (typeof PROJECT_TOPICS)[number]; @@ -33,7 +34,7 @@ export const TOPICS_BY_EVENT: Record = { REPO_UPDATED: { org: ['repos'] }, PROJECT_CREATED: { project: ['project'], org: ['projects', 'repos'] }, - PROJECT_STARTED: { project: ['project', 'tasks', 'specs', 'plans'], org: ['projects'] }, + PROJECT_STARTED: { project: ['project', 'tasks', 'specs', 'plans', 'room'], org: ['projects'] }, MEMBER_ASSIGNED: { project: ['members'], org: ['agents'] }, MEMBER_UNASSIGNED: { project: ['members'], org: ['agents'] }, GITHUB_COLLABORATORS_INVITED: { project: ['members'], org: ['members'] }, @@ -41,23 +42,31 @@ export const TOPICS_BY_EVENT: Record = { SPEC_CREATED: { project: ['specs'] }, TASK_CREATED: { project: ['tasks'] }, TASKS_IMPORTED: { project: ['tasks', 'specs'] }, - TASK_CLAIMED: { project: ['tasks'] }, - ARTIFACT_SUBMITTED: { project: ['tasks'] }, - VERIFICATION_COMPLETED: { project: ['tasks'] }, + TASK_CLAIMED: { project: ['tasks', 'room'] }, + ARTIFACT_SUBMITTED: { project: ['tasks', 'room'] }, + VERIFICATION_COMPLETED: { project: ['tasks', 'room'] }, + TASK_DISPATCHED: { project: ['room'] }, + AGENT_RUN_STARTED: { project: ['room'] }, + AGENT_RUN_ENDED: { project: ['room', 'tasks'] }, + TASK_BLOCKED: { project: ['room', 'tasks'] }, + TASK_RESUMED: { project: ['room', 'tasks'] }, + RELEASE_REQUESTED: { project: ['approvals', 'project', 'room'], org: ['approvals', 'projects'] }, + ACTION_DETECTED: { project: ['tasks'] }, + RELEASE_DECIDED: { project: ['approvals', 'project', 'room'], org: ['approvals', 'projects'] }, NOTES_ACK_REQUIRED: {}, TOOL_DENIED: {}, PM_REVIEW_DEGRADED: {}, - NOTE_PUBLISHED: { project: ['notes'] }, + NOTE_PUBLISHED: { project: ['notes', 'room'] }, - APPROVAL_REQUESTED: { project: ['approvals', 'tasks'], org: ['approvals'] }, - APPROVAL_RESULT: { project: ['approvals', 'tasks'], org: ['approvals'] }, + APPROVAL_REQUESTED: { project: ['approvals', 'tasks', 'room'], org: ['approvals'] }, + APPROVAL_RESULT: { project: ['approvals', 'tasks', 'room'], org: ['approvals'] }, PM_PLAN_REQUESTED: { project: ['plans'] }, PM_CALL: { project: ['plans'] }, PM_PLAN_DRAFTED: { project: ['plans'] }, PM_PLAN_FAILED: { project: ['plans'] }, PLAN_REJECTED: { project: ['plans'] }, - PLAN_APPLIED: { project: ['plans', 'tasks', 'specs'] }, + PLAN_APPLIED: { project: ['plans', 'tasks', 'specs', 'room'] }, }; export function topicsFor(type: string, hasProject: boolean): { project: ProjectTopic[]; org: OrgTopic[] } { diff --git a/src/realtime/user-stream.ts b/src/realtime/user-stream.ts index 6143a4a..8d64ac3 100644 --- a/src/realtime/user-stream.ts +++ b/src/realtime/user-stream.ts @@ -12,6 +12,7 @@ import { isAllowedOrigin, type OriginRule } from '../middleware/cors.js'; import type { CommittedEvent } from './event-feed.js'; import { topicsFor } from './topics.js'; import { routeUpgrade } from './upgrade-router.js'; +import { onActivityRecorded } from '../domain/room/activity-hub.js'; // 사람용 실시간 스트림 — 화면이 폴링 대신 "다시 읽어라" 신호를 받는다. // @@ -134,6 +135,14 @@ export function attachUserStream(server: Server, options: UserStreamOptions): Us .catch((err: unknown) => logger.warn('user stream presence failed', { agentId, error: String(err) })); }); + // 에이전트 활동(룸 피드의 에이전트 줄)은 이벤트가 아니라 따로 온다. 그 프로젝트를 구독한 연결에만 'room'을 보낸다. + // 신호에는 역할을 싣지 않는다 — 다른 역할 룸이 바뀌었다는 것만 알 뿐 내용은 피드 API가 역할로 거른다. + const unsubscribeActivity = onActivityRecorded((orgId, projectId) => { + for (const conn of byOrg.get(orgId) ?? []) { + if (conn.projects.has(projectId)) queue(conn, projectId, ['room'], null); + } + }); + const handleMessage = async (conn: Connection, raw: RawData) => { let msg: { type?: unknown; projectId?: unknown }; try { @@ -278,6 +287,7 @@ export function attachUserStream(server: Server, options: UserStreamOptions): Us clearInterval(ping); clearInterval(sweep); unsubscribePresence(); + unsubscribeActivity(); unroute(); for (const ws of wss.clients) ws.terminate(); await new Promise((resolve) => wss.close(() => resolve())); diff --git a/src/routes/orgs.ts b/src/routes/orgs.ts index 9e61032..8c9d3f6 100644 --- a/src/routes/orgs.ts +++ b/src/routes/orgs.ts @@ -20,14 +20,15 @@ orgsRouter.post('/orgs', validate({ body: createOrgBodySchema }), authenticate, // GET /api/orgs/:orgId/github/repos — 연결할 수 있는 GitHub 레포 목록(조직 멤버 누구나). // 레포 연결을 팀원에게 열었으므로 그 드롭다운을 채우는 이 목록도 함께 열려 있어야 한다. -// 토큰이 없으면 빈 배열을 반환한다 (500을 던지지 않음). +// 그 조직 대표의 GitHub 토큰으로 읽는다. 대표가 GitHub를 연결하지 않았으면 빈 배열(500을 던지지 않음). orgsRouter.get( '/orgs/:orgId/github/repos', validate({ params: orgIdParamsSchema }), authenticate, requireSameOrg, - async (_req, res) => { - const repos = await listAvailableGithubRepos(); + async (req, res) => { + const { orgId } = req.params as z.infer; + const repos = await listAvailableGithubRepos(orgId); res.status(200).json({ data: { repos } }); }, ); diff --git a/src/routes/rooms.ts b/src/routes/rooms.ts new file mode 100644 index 0000000..a35747f --- /dev/null +++ b/src/routes/rooms.ts @@ -0,0 +1,110 @@ +import { Router, type Request } from 'express'; +import { z } from 'zod'; +import { ACTIVITY_KINDS } from '../domain/room/repository.js'; +import { endRun, getRoomFeed, listRooms, recordActivity, startRun } from '../domain/room/service.js'; +import { resumeTask } from '../domain/task/stall.js'; +import { TEAM_ROLES } from '../domain/roles.js'; +import type { UserContext } from '../domain/project/visibility.js'; +import { AppError } from '../errors.js'; +import { authenticate, orgIdOf } from '../middleware/auth.js'; +import { agentContextOf, authenticateAgent } from '../middleware/agent-auth.js'; +import { validate } from '../middleware/validate.js'; + +// 룸(프로젝트 × 역할): Executor가 실행 시작·끝·도구 사용을 보고하고(에이전트 토큰), 사람이 룸 피드를 읽는다(사람 토큰). + +export const roomsRouter = Router(); + +const taskIdParamsSchema = z.object({ taskId: z.string().uuid() }); + +// POST /api/tasks/:taskId/runs/start — Claude 실행 시작(그 태스크를 잡고 있는 에이전트만). +roomsRouter.post('/tasks/:taskId/runs/start', authenticateAgent, validate({ params: taskIdParamsSchema }), async (req, res) => { + const { taskId } = req.params as z.infer; + res.status(201).json({ data: await startRun(agentContextOf(req), taskId) }); +}); + +const endRunBodySchema = z.object({ + outcome: z.enum(['completed', 'timeout', 'failed']), + committed: z.boolean(), + durationMs: z.number().int().min(0), + exitCode: z.number().int().nullable(), + // 제출하지 않았을 때 대표에게 보일 사유(선택). 모델의 마지막 말, 거부된 쉘 명령. + lastMessage: z.string().max(2000).nullable().optional(), + deniedCommands: z.array(z.string().max(300)).max(20).optional(), +}); + +// POST /api/tasks/:taskId/runs/end — Claude 실행 끝. 제출 여부는 서버가 태스크 상태로 정한다. +roomsRouter.post( + '/tasks/:taskId/runs/end', + authenticateAgent, + validate({ params: taskIdParamsSchema, body: endRunBodySchema }), + async (req, res) => { + const { taskId } = req.params as z.infer; + res.status(200).json({ data: await endRun(agentContextOf(req), taskId, req.body as z.infer) }); + }, +); + +// 도구 종류와 대상(경로·명령)만 받는다. 파일 내용·명령 결과·모델 설명은 받지 않는다(설명은 인계 노트의 몫). +const activityBodySchema = z.object({ + items: z + .array(z.object({ kind: z.enum(ACTIVITY_KINDS as [string, ...string[]]), target: z.string().trim().min(1).max(300) })) + .min(1) + .max(50), +}); + +// POST /api/tasks/:taskId/activity — 실행 중 도구 사용 묶음(열린 실행에만). +roomsRouter.post( + '/tasks/:taskId/activity', + authenticateAgent, + validate({ params: taskIdParamsSchema, body: activityBodySchema }), + async (req, res) => { + const { taskId } = req.params as z.infer; + const { items } = req.body as { items: { kind: (typeof ACTIVITY_KINDS)[number]; target: string }[] }; + res.status(201).json({ data: await recordActivity(agentContextOf(req), taskId, items) }); + }, +); + +const resumeBodySchema = z.object({ note: z.string().trim().max(500).nullable().optional() }); + +// POST /api/tasks/:taskId/resume — 멈춘 태스크(BLOCKED·AGENT_STOPPED) 재개(대표 전용). READY로 돌아가 같은 역할의 에이전트가 다시 가져간다. +// 재시도 횟수는 그대로다 — 권한·환경 탓은 에이전트 실패가 아니다. +roomsRouter.post( + '/tasks/:taskId/resume', + validate({ params: taskIdParamsSchema, body: resumeBodySchema }), + authenticate, + async (req, res) => { + const { taskId } = req.params as z.infer; + const { note } = req.body as z.infer; + res.status(200).json({ data: await resumeTask(userOf(req), taskId, note ?? null) }); + }, +); + +function userOf(req: Request): UserContext { + if (!req.user) throw new AppError('UNAUTHENTICATED', 'authentication required'); + return { userId: req.user.id, orgId: orgIdOf(req), orgRole: req.user.orgRole }; +} + +const projectIdParamsSchema = z.object({ projectId: z.string().uuid() }); + +// GET /api/projects/:projectId/rooms — 내가 볼 수 있는 룸(대표는 전부, 팀원은 자기 역할)과 담당 에이전트·진행 중 태스크. +roomsRouter.get('/projects/:projectId/rooms', validate({ params: projectIdParamsSchema }), authenticate, async (req, res) => { + const { projectId } = req.params as z.infer; + res.status(200).json({ data: { rooms: await listRooms(userOf(req), projectId) } }); +}); + +const feedParamsSchema = z.object({ projectId: z.string().uuid(), role: z.enum(TEAM_ROLES) }); +const feedQuerySchema = z.object({ + before: z.string().max(80).optional(), + limit: z.coerce.number().int().min(1).max(200).default(50), +}); + +// GET /api/projects/:projectId/rooms/:role/feed — 룸 피드(최신부터). nextBefore로 더 오래된 것을 읽는다. +roomsRouter.get( + '/projects/:projectId/rooms/:role/feed', + validate({ params: feedParamsSchema, query: feedQuerySchema }), + authenticate, + async (req, res) => { + const { projectId, role } = req.params as z.infer; + const query = req.query as unknown as z.infer; + res.status(200).json({ data: await getRoomFeed(userOf(req), projectId, role, query) }); + }, +); diff --git a/src/server.ts b/src/server.ts index 4fc8431..01ae87f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,6 +4,7 @@ import { env } from './config/env.js'; import { logger } from './config/logger.js'; import { assertSchemaUpToDate } from './config/migrations.js'; import { recoverInterruptedPlans } from './domain/pm/service.js'; +import { startStallWatchdog } from './domain/task/stall.js'; import { attachRealtime } from './realtime/index.js'; // 서버 기동. 로컬(index.ts, npm run dev)과 운영(boot.ts server)이 같은 경로를 탄다. @@ -12,6 +13,8 @@ export async function startServer(): Promise { await assertSchemaUpToDate(pool); // 서버로 뜰 때만 — migrate 단계(boot.ts migrate)에서는 옛 서버가 아직 PM 작업 중일 수 있어 건드리지 않는다. await recoverInterruptedPlans(); + // 응답이 끊긴 에이전트가 잡고 있는 태스크를 멈춤(BLOCKED)으로 바꾼다 — Executor가 꺼지면 종료 보고조차 오지 않는다. + startStallWatchdog(); const app = createApp(); await new Promise((resolve) => { const server = app.listen(env.PORT, () => { diff --git a/tests/approvals.test.ts b/tests/approvals.test.ts index d8c800e..45eddda 100644 --- a/tests/approvals.test.ts +++ b/tests/approvals.test.ts @@ -178,7 +178,9 @@ describe('승인 카드 생성과 조회', () => { const { approvalId } = await submitToApproval(ctx); await call('POST', `/approvals/${approvalId}/approve`, ctx.repToken); - expect((await call('GET', `/orgs/${ctx.orgId}/approvals`, ctx.repToken)).json.data.approvals).toEqual([]); + // 이 승인으로 프로젝트의 마지막 태스크가 DONE이 되어 G3(통합 확인) 카드가 대기에 생긴다 — ACTION 카드는 대기에 없다. + const pending = (await call('GET', `/orgs/${ctx.orgId}/approvals`, ctx.repToken)).json.data.approvals as { gate: string }[]; + expect(pending.map((a) => a.gate)).toEqual(['G3']); const decided = await call('GET', `/orgs/${ctx.orgId}/approvals?status=decided`, ctx.repToken); expect(decided.json.data.approvals).toHaveLength(1); expect(decided.json.data.approvals[0]).toMatchObject({ decision: 'APPROVE', taskState: 'DONE' }); diff --git a/tests/bridge-args.test.ts b/tests/bridge-args.test.ts index 6759fe6..18c822d 100644 --- a/tests/bridge-args.test.ts +++ b/tests/bridge-args.test.ts @@ -40,7 +40,7 @@ describe('MCP 격리', () => { const bash = args[args.indexOf('--allowedTools') + 1]!.split(',').filter((t) => t.startsWith('Bash')); expect(bash).toContain('Bash(git commit:*)'); // 접두사 없는 Bash·와일드카드 Bash는 없다. - for (const rule of bash) expect(rule, rule).toMatch(/^Bash\([a-z][a-z -]+:\*\)$/); + for (const rule of bash) expect(rule, rule).toMatch(/^Bash\([a-z][a-z0-9 -]+:\*\)$/); }); it('도구 이름은 설정의 서버 이름에서 파생된다', () => { diff --git a/tests/dependency-gate.test.ts b/tests/dependency-gate.test.ts new file mode 100644 index 0000000..47344ab --- /dev/null +++ b/tests/dependency-gate.test.ts @@ -0,0 +1,162 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { createApp } from '../src/app.js'; +import { pool } from '../src/config/db.js'; +import { connectAgent } from '../src/domain/agent/service.js'; +import { login, signup } from '../src/domain/auth/service.js'; +import { createTask } from '../src/domain/authoring/service.js'; +import { drainTasksChanged } from '../src/domain/dispatch/tasks-changed.js'; +import { acceptInvite, createInvite } from '../src/domain/invite/service.js'; +import { createOrganization } from '../src/domain/org/service.js'; +import { assignMember, createProject, startProject } from '../src/domain/project/service.js'; +import { connectRepos, updateRepoSettings } from '../src/domain/repo/service.js'; +import { gitMirrorInspector, setCommitInspector } from '../src/domain/verification/commit-inspector.js'; +import { assignRootOwner } from './fixtures.js'; +import { resetSchema, testPool, truncateAll } from './test-db.js'; + +// dep:add는 경로가 아니라 package.json의 의존성 diff로 판정한다(운영 테스트 4-7: FE가 새 package.json에 의존성을 넣었는데 AUTO로 통과했다). +// 실제 git 레포와 mirror 검사기로 커밋 전후 내용을 읽는다. + +let server: Server; +let baseUrl: string; +let tmp: string; +let repoDir: string; + +function git(...args: string[]): string { + return execFileSync('git', ['-C', repoDir, '-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { encoding: 'utf8' }).trim(); +} + +function commit(files: Record, message: string): string { + for (const [name, content] of Object.entries(files)) writeFileSync(path.join(repoDir, name), content); + git('add', '-A'); + git('commit', '-q', '-m', message); + return git('rev-parse', 'HEAD'); +} + +beforeAll(async () => { + await resetSchema(); + server = createApp().listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +beforeEach(async () => { + await truncateAll(); + tmp = mkdtempSync(path.join(os.tmpdir(), 'nomos-dep-')); + repoDir = path.join(tmp, 'repo'); + execFileSync('git', ['init', '-q', '-b', 'main', repoDir]); + commit({ 'README.md': '# x\n' }, 'init'); + setCommitInspector(gitMirrorInspector(path.join(tmp, 'mirrors'))); +}); + +afterAll(async () => { + setCommitInspector(gitMirrorInspector()); + await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))); + await pool.end(); + await testPool.end(); + rmSync(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); +}); + +async function http(method: string, p: string, token: string, body?: unknown) { + const res = await fetch(`${baseUrl}/api${p}`, { + method, + headers: { Authorization: `Bearer ${token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + return { status: res.status, body: (await res.json()) as { data?: Record } }; +} + +async function world() { + const signupAs = async (loginId: string) => { + const { userId, connectKey } = await signup({ loginId, password: 'correct-horse-battery', nickname: loginId }); + return { userId, connectKey, token: (await login({ loginId, password: 'correct-horse-battery' })).accessToken }; + }; + const rep = await signupAs('rep'); + const { orgId } = await createOrganization(rep.userId, 'Acme'); + const fe = await signupAs('fe-dev'); + const { token } = await createInvite(orgId, rep.userId); + await acceptInvite(token, fe.userId); + const agent = await connectAgent({ connectKey: fe.connectKey, agentName: 'fe-laptop', harness: 'claude-code', skills: [], maxConcurrent: 2 }); + const [repo] = await connectRepos({ orgId, actorUserId: rep.userId, repos: [{ fullName: 'acme/web' }] }); + await assignRootOwner(orgId, rep.userId, repo!.id, 'FRONTEND'); + await updateRepoSettings(orgId, rep.userId, repo!.id, { cloneUrl: repoDir }); + const actor = { userId: rep.userId, orgId, orgRole: 'REPRESENTATIVE' }; + const { project } = await createProject(orgId, rep.userId, { name: 'p', autonomyPreset: 'L2', pmBudgetUsd: 1, repoIds: [repo!.id] }); + await assignMember(actor, project.id, agent.agentId, 'FRONTEND'); + const tasks = []; + for (const title of ['T-1 화면 뼈대', 'T-2 스크립트 정리']) { + tasks.push(await createTask(rep.userId, project.id, { title, teamRole: 'FRONTEND', kind: 'INTEGRATION', repoId: repo!.id, specId: null, dependsOn: [] })); + } + await startProject(actor, project.id); + await drainTasksChanged(); + const refreshed = await fetch(`${baseUrl}/api/agents/token/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken: agent.refreshToken }), + }); + const agentToken = ((await refreshed.json()) as { data: { accessToken: string } }).data.accessToken; + return { projectId: project.id, agentToken, tasks }; +} + +async function submit(agentToken: string, taskId: string, sha: string, changedPaths: string[]) { + await http('POST', `/tasks/${taskId}/claim`, agentToken); + const submitted = await http('POST', `/tasks/${taskId}/artifacts`, agentToken, { commitSha: sha, changedPaths }); + expect(submitted.status).toBe(201); + const artifactId = (submitted.body.data as { id: string }).id; + for (const stage of ['V2', 'V4']) { + await http('POST', `/artifacts/${artifactId}/verifications`, agentToken, { stage, result: 'SKIPPED', detail: { reason: '없음' } }); + } + const { rows } = await testPool.query( + `SELECT t.state, a.gate_mode, a.triggered_actions FROM tasks t JOIN artifacts a ON a.task_id = t.id WHERE a.id = $1`, + [artifactId], + ); + return rows[0] as { state: string; gate_mode: string; triggered_actions: string[] }; +} + +describe('dep:add — package.json 의존성 diff', () => { + it('새 package.json에 의존성이 있으면 dep:add로 판정이 올라간다(L2: PM_REVIEW → 승인 대기)', async () => { + const w = await world(); + const sha = commit({ 'package.json': JSON.stringify({ name: 'web', dependencies: { react: '^19.0.0', vite: '^6.0.0' } }) }, 'scaffold'); + const result = await submit(w.agentToken, w.tasks[0]!.id, sha, ['package.json']); + expect(result).toMatchObject({ state: 'AWAITING_APPROVAL', gate_mode: 'PM_REVIEW' }); + expect(result.triggered_actions).toContain('dep:add'); + + const { rows } = await testPool.query(`SELECT payload FROM events WHERE type = 'ACTION_DETECTED'`); + expect(rows[0].payload).toMatchObject({ + actionKey: 'dep:add', + paths: ['package.json'], + unreadable: [], + gateModeBefore: 'AUTO', + gateModeAfter: 'PM_REVIEW', + }); + expect(rows[0].payload.changes.map((c: { name: string; from: string | null }) => `${c.name}:${c.from}`)).toEqual(['react:null', 'vite:null']); + // 승인 카드의 스냅샷에도 dep:add가 들어 있다. + const card = await testPool.query(`SELECT payload->'triggeredActions' AS actions FROM approvals WHERE gate = 'ACTION'`); + expect(card.rows[0].actions).toContain('dep:add'); + }); + + it('scripts만 고친 변경은 dep:add가 아니다(경로 규칙으로 잡지 않는 이유)', async () => { + const w = await world(); + commit({ 'package.json': JSON.stringify({ name: 'web', scripts: { dev: 'vite' }, dependencies: { react: '^19.0.0' } }) }, 'base'); + const sha = commit({ 'package.json': JSON.stringify({ name: 'web', scripts: { dev: 'vite', test: 'vitest' }, dependencies: { react: '^19.0.0' } }) }, 'scripts'); + const result = await submit(w.agentToken, w.tasks[1]!.id, sha, ['package.json']); + expect(result).toMatchObject({ state: 'DONE', gate_mode: 'AUTO' }); + expect(result.triggered_actions).not.toContain('dep:add'); + expect((await testPool.query(`SELECT 1 FROM events WHERE type = 'ACTION_DETECTED'`)).rowCount).toBe(0); + }); + + it('내용을 읽을 수 없는 검사기면 dep:add로 본다(fail closed)', async () => { + const w = await world(); + const sha = commit({ 'package.json': '{}' }, 'empty manifest'); + setCommitInspector({ kind: 'fake', async changedPaths() { return ['package.json']; } }); // fileVersions 없음 + const result = await submit(w.agentToken, w.tasks[0]!.id, sha, ['package.json']); + expect(result).toMatchObject({ state: 'AWAITING_APPROVAL', gate_mode: 'PM_REVIEW' }); + const { rows } = await testPool.query(`SELECT payload->'unreadable' AS unreadable FROM events WHERE type = 'ACTION_DETECTED'`); + expect(rows[0].unreadable).toEqual(['package.json']); + }); +}); diff --git a/tests/env-loading.test.ts b/tests/env-loading.test.ts index 6a4ee2d..0efa282 100644 --- a/tests/env-loading.test.ts +++ b/tests/env-loading.test.ts @@ -13,7 +13,7 @@ describe('환경변수 로딩', () => { it('.env 전용 값이 테스트로 새지 않는다', () => { // vitest.config.ts의 test.env가 주지 않는 값은 undefined여야 한다. // 새면 GitHub 호출이 실제로 나가고, 테스트가 네트워크에 의존하게 된다. - expect(env.GITHUB_TOKEN).toBeUndefined(); + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); expect(env.KMS_KEY_ID).toBeUndefined(); }); diff --git a/tests/executor-activity.test.ts b/tests/executor-activity.test.ts new file mode 100644 index 0000000..b268921 --- /dev/null +++ b/tests/executor-activity.test.ts @@ -0,0 +1,119 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { ActivityReporter, activityFromStreamLine, RunObserver, toActivity, type ActivityItem } from '../src/executor/activity.js'; + +// Claude stream-json → 룸 활동 한 줄. 보내는 것은 도구 종류와 대상(작업공간 기준 경로·명령 첫 줄)뿐이다. +const cwd = path.resolve('/work/space'); +const inWs = (p: string) => path.join(cwd, p); + +function assistant(...blocks: unknown[]): string { + return JSON.stringify({ type: 'assistant', message: { content: blocks } }); +} + +describe('도구 → 활동', () => { + it('파일 도구는 작업공간 기준 상대 경로로(사용자 폴더 이름이 룸에 찍히지 않게)', () => { + expect(toActivity('Read', { file_path: inWs('src/routes/auth.ts') }, cwd)).toEqual({ kind: 'read', target: 'src/routes/auth.ts' }); + expect(toActivity('Edit', { file_path: inWs('src/a.ts') }, cwd)).toEqual({ kind: 'edit', target: 'src/a.ts' }); + expect(toActivity('MultiEdit', { file_path: 'src/b.ts' }, cwd)).toEqual({ kind: 'edit', target: 'src/b.ts' }); + expect(toActivity('Write', { file_path: inWs('docs/x.md') }, cwd)).toEqual({ kind: 'write', target: 'docs/x.md' }); + // 작업공간 밖은 파일 이름만. + expect(toActivity('Read', { file_path: path.resolve('/home/someone/.ssh/config') }, cwd)).toEqual({ kind: 'read', target: 'config' }); + }); + + it('명령은 첫 줄만, 300자로 자른다 — 결과는 보내지 않는다', () => { + expect(toActivity('Bash', { command: 'npm test' }, cwd)).toEqual({ kind: 'run', target: 'npm test' }); + expect(toActivity('Bash', { command: 'git commit -m "x"\nsecond line' }, cwd)).toEqual({ kind: 'run', target: 'git commit -m "x"' }); + const long = toActivity('Bash', { command: 'x'.repeat(400) }, cwd)!; + expect(long.target.length).toBe(300); + expect(toActivity('Grep', { pattern: 'TODO' }, cwd)).toEqual({ kind: 'search', target: 'TODO' }); + // 명령 안의 작업공간 절대 경로는 '.'으로 — 사용자 폴더 이름이 룸에 찍히지 않게(실측: 모델이 git -C <절대 경로>를 썼다). + const slash = cwd.split(path.sep).join('/'); + expect(toActivity('Bash', { command: `git -C "${slash}" ls-files` }, cwd)).toEqual({ kind: 'run', target: 'git -C "." ls-files' }); + expect(toActivity('Bash', { command: `ls ${cwd}${path.sep}src` }, cwd)).toEqual({ kind: 'run', target: `ls .${path.sep}src` }); + }); + + it('NOMOS 도구·할 일 목록은 보내지 않는다(제출·노트는 서버 이벤트로 이미 보인다), 모르는 도구는 이름만', () => { + expect(toActivity('mcp__nomos__submit_artifact', { commitSha: 'x' }, cwd)).toBeNull(); + expect(toActivity('TodoWrite', { todos: [] }, cwd)).toBeNull(); + expect(toActivity('ToolSearch', { query: 'select:x' }, cwd)).toBeNull(); + expect(toActivity('WebFetch', { url: 'https://x' }, cwd)).toEqual({ kind: 'other', target: 'WebFetch' }); + }); +}); + +describe('stream-json 줄 → 활동', () => { + it('assistant 메시지의 tool_use만 뽑고, 설명 문장(text)은 버린다', () => { + const line = assistant( + { type: 'text', text: '가입 라우트에 이메일 중복 검사를 추가하겠습니다' }, + { type: 'tool_use', name: 'Read', input: { file_path: inWs('src/a.ts') } }, + { type: 'tool_use', name: 'Bash', input: { command: 'npm test' } }, + ); + expect(activityFromStreamLine(line, cwd)).toEqual([ + { kind: 'read', target: 'src/a.ts' }, + { kind: 'run', target: 'npm test' }, + ]); + }); + + it('도구 결과(user)·시스템·깨진 줄은 무시한다', () => { + expect(activityFromStreamLine(JSON.stringify({ type: 'user', message: { content: [{ type: 'tool_result', content: 'SECRET' }] } }), cwd)).toEqual([]); + expect(activityFromStreamLine(JSON.stringify({ type: 'system', subtype: 'init' }), cwd)).toEqual([]); + expect(activityFromStreamLine('{not json', cwd)).toEqual([]); + expect(activityFromStreamLine('', cwd)).toEqual([]); + }); +}); + +describe('활동 묶어 보내기', () => { + it('모아서 50줄씩 보내고, 보내기가 실패해도 던지지 않고 한 번만 기록한다', async () => { + const sent: ActivityItem[][] = []; + let fail = true; + const logs: string[] = []; + const reporter = new ActivityReporter( + { + async postActivity(_taskId: string, items: { kind: string; target: string }[]) { + if (fail) throw new Error('offline'); + sent.push(items as ActivityItem[]); + }, + }, + 'task-1', + (l) => logs.push(l), + 60_000, + ); + reporter.push([{ kind: 'read', target: 'a' }]); + await reporter.flush(); + reporter.push([{ kind: 'read', target: 'b' }]); + await reporter.flush(); + expect(logs).toHaveLength(1); + + fail = false; + reporter.push(Array.from({ length: 70 }, (_, i) => ({ kind: 'read' as const, target: `f${i}` }))); + await reporter.close(); + expect(sent.map((b) => b.length)).toEqual([50, 20]); + }); +}); + +describe('멈춤 사유 모으기(RunObserver)', () => { + it('거부된 쉘 명령(작업공간 경로 가림)과 마지막 결과 문장을 모은다 — 성공한 명령은 넣지 않는다', () => { + const o = new RunObserver(cwd); + const slash = cwd.split(path.sep).join('/'); + o.observe(assistant({ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'git fetch --all' } })); + o.observe(JSON.stringify({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 't1', is_error: true, content: 'This command requires approval' }] } })); + o.observe(assistant({ type: 'tool_use', id: 't2', name: 'Bash', input: { command: 'git status' } })); + o.observe(JSON.stringify({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 't2', content: 'clean' }] } })); + o.observe(assistant({ type: 'tool_use', id: 't3', name: 'PowerShell', input: { command: `python -m unittest -s ${slash}/tests` } })); + o.observe(JSON.stringify({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 't3', is_error: true, content: [{ type: 'text', text: 'The following part requires approval' }] }] } })); + // 같은 명령은 한 번만. + o.observe(assistant({ type: 'tool_use', id: 't4', name: 'Bash', input: { command: 'git fetch --all' } })); + o.observe(JSON.stringify({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 't4', is_error: true, content: 'This command requires approval' }] } })); + o.observe(assistant({ type: 'text', text: '중간 설명' })); + o.observe(JSON.stringify({ type: 'result', subtype: 'success', result: '통합 확인에 필요한 git merge 권한이 없어 멈춥니다' })); + expect(o.summary()).toEqual({ + lastMessage: '통합 확인에 필요한 git merge 권한이 없어 멈춥니다', + deniedCommands: ['git fetch --all', 'python -m unittest -s ./tests'], + }); + }); + + it('결과 메시지가 없으면 마지막 assistant 문장을 쓴다', () => { + const o = new RunObserver(cwd); + o.observe(assistant({ type: 'text', text: '권한이 없어 진행할 수 없습니다' })); + expect(o.summary()).toEqual({ lastMessage: '권한이 없어 진행할 수 없습니다', deniedCommands: [] }); + }); +}); diff --git a/tests/executor-setup.test.ts b/tests/executor-setup.test.ts new file mode 100644 index 0000000..77d3d7e --- /dev/null +++ b/tests/executor-setup.test.ts @@ -0,0 +1,78 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { ALLOWED_BASH_PREFIXES } from '../src/bridge/claude-args.js'; +import { planSetup, prepareDependencies, VENV_DIR } from '../src/executor/setup.js'; +import { buildTaskPrompt } from '../src/executor/prompt.js'; +import { withPathPrepended } from '../src/executor/runner.js'; +import { NOMOS_LOCAL_FILES } from '../src/executor/workspace.js'; + +// 의존성 설치는 Executor가 Claude 전에 한다 — 모델에게 설치 명령을 열지 않는다(설치 스크립트가 Edit 금지를 우회한다). +const has = (...files: string[]) => (f: string) => files.includes(f); + +describe('설치 계획', () => { + it('잠금 파일이 있으면 그대로 재현하고, 설치 스크립트는 언제나 끈다', () => { + expect(planSetup(has('package.json', 'package-lock.json'))).toEqual([ + { manager: 'npm', cmd: 'npm', args: ['ci', '--ignore-scripts', '--no-audit', '--no-fund'] }, + ]); + expect(planSetup(has('package.json', 'pnpm-lock.yaml'))[0]).toMatchObject({ cmd: 'pnpm', args: ['install', '--frozen-lockfile', '--ignore-scripts'] }); + expect(planSetup(has('package.json', 'yarn.lock'))[0]).toMatchObject({ cmd: 'yarn', args: ['install', '--frozen-lockfile', '--ignore-scripts'] }); + for (const step of planSetup(has('package.json'))) expect(step.args).toContain('--ignore-scripts'); + }); + + it('잠금 파일 없이 package.json만 있으면 잠금 파일을 만들지 않고 설치한다(새 파일이 커밋에 섞이지 않게)', () => { + expect(planSetup(has('package.json'))).toEqual([ + { manager: 'npm', cmd: 'npm', args: ['install', '--ignore-scripts', '--no-package-lock', '--no-audit', '--no-fund'] }, + ]); + }); + + it('requirements.txt는 작업공간 가상환경에 바이너리 휠만(소스 빌드는 코드 실행이다)', () => { + const plan = planSetup(has('requirements.txt')); + expect(plan[0]).toMatchObject({ manager: 'python', args: ['-m', 'venv', VENV_DIR] }); + expect(plan[1]).toMatchObject({ manager: 'pip', args: ['-m', 'pip', 'install', '--only-binary', ':all:', '--disable-pip-version-check', '-r', 'requirements.txt'] }); + }); + + it('설치할 것이 없으면 아무것도 하지 않는다', () => { + expect(planSetup(has('README.md'))).toEqual([]); + }); +}); + +describe('설치 실행', () => { + it('실패해도 던지지 않고 결과에 적는다. 가상환경을 못 만들면 pip도 건너뛴다', async () => { + const dir = path.resolve('/tmp/nomos-setup-test-absent'); + // 존재하지 않는 폴더라 파일이 없다 → 계획이 비어야 한다. + const empty = await prepareDependencies(dir, () => undefined, async () => undefined); + expect(empty).toEqual({ steps: [], venvBin: null }); + }); +}); + +describe('Executor가 쓰는 파일은 커밋에 섞이지 않는다', () => { + it('미리 설치한 node_modules·가상환경도 info/exclude 목록에 있다', () => { + expect(NOMOS_LOCAL_FILES).toEqual(expect.arrayContaining(['/node_modules/', `/${VENV_DIR}/`])); + }); + + it('PATH 앞에 가상환경을 붙인다 — Windows의 Path 이름도 그대로', () => { + expect(withPathPrepended({ Path: 'C:\\bin' }, 'C:\\venv\\Scripts')).toEqual({ Path: `C:\\venv\\Scripts${path.delimiter}C:\\bin` }); + expect(withPathPrepended({}, '/venv/bin')).toEqual({ PATH: '/venv/bin' }); + }); +}); + +describe('허용 명령과 프롬프트', () => { + it('시험 실행기는 열고, 설치 명령은 열지 않는다', () => { + expect(ALLOWED_BASH_PREFIXES).toEqual(expect.arrayContaining(['python -m pytest', 'python -m unittest', 'pnpm test'])); + for (const p of ALLOWED_BASH_PREFIXES) expect(p).not.toMatch(/install|pip|npm ci|^npx (?!vitest)/); + }); + + it('프롬프트에 허용 명령·잇지 말 것·설치 결과·막혔을 때 할 일을 적는다', () => { + const prompt = buildTaskPrompt( + { task: { id: 't1', title: 'T', teamRole: 'BACKEND' }, repo: { fullName: 'a/b' }, spec: null, notesBlock: '', writablePaths: [{ pathPattern: '**' }] }, + 'task/t1', + { allowedCommands: ALLOWED_BASH_PREFIXES, setup: [{ command: 'npm ci --ignore-scripts', ok: true }, { command: 'python -m pip install', ok: false }] }, + ); + expect(prompt).toContain('# 작업 환경'); + expect(prompt).toContain(' - python -m pytest'); + expect(prompt).toContain('`&&`·`;`·`|`로 잇거나'); + expect(prompt).toContain('- 설치됨: npm ci --ignore-scripts'); + expect(prompt).toContain('- 설치 실패: python -m pip install'); + expect(prompt).toContain('막힌 이유를 마지막 메시지에'); + }); +}); diff --git a/tests/github-inspector.test.ts b/tests/github-inspector.test.ts index c1bf9c1..0f52f12 100644 --- a/tests/github-inspector.test.ts +++ b/tests/github-inspector.test.ts @@ -183,3 +183,27 @@ describe('githubInspector', () => { ); }); }); + +describe('githubInspector — 파일 내용(dep:add 판정용)', () => { + it('부모 커밋과 그 커밋의 package.json을 raw로 읽고, 그 시점에 없던 파일은 null', async () => { + const { input } = await setup(); + const urls: string[] = []; + const fetchImpl = async (url: string, init?: RequestInit): Promise => { + urls.push(url); + expect(new Headers(init?.headers).get('Authorization')).toBe(`Bearer ${TOKEN}`); + if (url === COMMIT_URL) return new Response(JSON.stringify({ parents: [{ sha: 'parent0' }] }), { status: 200 }); + if (url === `${API}/repositories/${REPO_ID}/contents/apps/web/package.json?ref=parent0`) return new Response('', { status: 404 }); + if (url === `${API}/repositories/${REPO_ID}/contents/apps/web/package.json?ref=${SHA}`) return new Response('{"dependencies":{"react":"^19"}}', { status: 200 }); + return new Response('not routed', { status: 500 }); + }; + const inspector = githubInspector({ db: pool, fetchImpl, apiBase: API }); + await expect(inspector.fileVersions!(input, 'apps/web/package.json')).resolves.toEqual({ before: null, after: '{"dependencies":{"react":"^19"}}' }); + expect(urls).toHaveLength(3); + }); + + it('GitHub가 실패하면 던진다 — 호출부는 dep:add로 본다(fail closed)', async () => { + const { input } = await setup(); + const inspector = githubInspector({ db: pool, fetchImpl: async () => new Response('', { status: 502 }), apiBase: API }); + await expect(inspector.fileVersions!(input, 'package.json')).rejects.toThrow('502'); + }); +}); diff --git a/tests/github-repo-create.test.ts b/tests/github-repo-create.test.ts index 96b91dd..d40ad48 100644 --- a/tests/github-repo-create.test.ts +++ b/tests/github-repo-create.test.ts @@ -43,6 +43,19 @@ function fakeApi(overrides: Partial = {}): GithubRepApi { calls.push({ method: 'inviteCollaborator', args }); return 'invited'; }, + async listRepos(...args) { + calls.push({ method: 'listRepos', args }); + return [{ fullName: 'acme-gh/existing', githubRepoId: 4242, defaultBranch: 'develop' }]; + }, + async getRepo(...args) { + calls.push({ method: 'getRepo', args }); + const [, fullName] = args; + return fullName.toLowerCase() === 'acme-gh/existing' ? { fullName: 'acme-gh/existing', githubRepoId: 4242, defaultBranch: 'develop' } : null; + }, + async isCollaborator(...args) { + calls.push({ method: 'isCollaborator', args }); + return args[2] === 'octo-be'; + }, ...overrides, }; } @@ -216,6 +229,10 @@ describe('역할 배정 시 GitHub 협업자 자동 초대', () => { expect(res.status).toBe(201); expect(res.body.data!.githubInvites).toEqual([expect.objectContaining({ status: 'skipped' })]); expect(calls).toEqual([]); + const before = await http('GET', `/projects/${projectId}`, w.rep.token); + expect((before.body.data!.members as { githubInvites: { status: string; reason?: string }[] }[])[0]!.githubInvites).toEqual([ + expect.objectContaining({ status: 'skipped', reason: expect.stringContaining('GitHub를 연결하지') }), + ]); await linkGithub(w.be.userId, 2, 'octo-be'); const retry = await http('POST', `/projects/${projectId}/members/${w.beAgentId}/github-invite`, w.rep.token); @@ -223,6 +240,12 @@ describe('역할 배정 시 GitHub 협업자 자동 초대', () => { expect(retry.body.data!.githubInvites).toEqual([expect.objectContaining({ status: 'invited' })]); const triggers = await testPool.query(`SELECT payload->>'trigger' AS t FROM events WHERE type = 'GITHUB_COLLABORATORS_INVITED' ORDER BY ts`); expect(triggers.rows.map((r) => r.t)).toEqual(['assign', 'retry']); + + // 새로고침해도 남는다 — 프로젝트 조회의 멤버 정보에 레포별 마지막 결과(재초대 뒤라 invited). + const detail = await http('GET', `/projects/${projectId}`, w.rep.token); + const member = (detail.body.data!.members as { agentId: string; githubInvites: { status: string; fullName: string; at: string }[] }[])[0]!; + expect(member.agentId).toBe(w.beAgentId); + expect(member.githubInvites).toEqual([{ repoId: expect.any(String), fullName: 'acme-gh/shop-api', status: 'invited', at: expect.any(String) }]); }); it('GitHub가 실패해도 배정은 유효하고 실패 사유가 결과에 남는다', async () => { @@ -311,3 +334,39 @@ describe('GitHub 호출 모양 (rep-api)', () => { await expect(recorder([[204, null]]).api.inviteCollaborator('tok', 'acme-gh/shop', 'octo')).resolves.toBe('already_collaborator'); }); }); + +describe('GitHub 읽기는 그 조직 대표의 토큰으로 — 서버 공용 PAT를 쓰지 않는다', () => { + it('연결 드롭다운: 대표가 GitHub를 연결했으면 대표 토큰으로 읽고, 안 했으면 빈 배열', async () => { + const w = await world(); + expect((await http('GET', `/orgs/${w.orgId}/github/repos`, w.be.token)).body.data).toEqual({ repos: [] }); + expect(calls).toEqual([]); + await linkGithub(w.rep.userId, 1, 'octo-rep'); + const listed = await http('GET', `/orgs/${w.orgId}/github/repos`, w.be.token); + expect(listed.body.data).toEqual({ repos: [{ fullName: 'acme-gh/existing', githubRepoId: 4242, defaultBranch: 'develop' }] }); + expect(calls).toEqual([{ method: 'listRepos', args: ['gho_octo-rep'] }]); + }); + + it('직접 입력으로 연결해도 github_repo_id·기본 브랜치·clone_url이 채워지고 이름은 GitHub 정본으로 — 못 찾으면 예전처럼 빈 채로', async () => { + const w = await world(); + await linkGithub(w.rep.userId, 1, 'octo-rep'); + const res = await http('POST', `/orgs/${w.orgId}/repos`, w.be.token, { repos: [{ fullName: 'ACME-GH/existing' }, { fullName: 'acme-gh/unknown' }] }); + expect(res.status).toBe(201); + const { rows } = await testPool.query(`SELECT full_name, github_repo_id, default_branch, clone_url FROM repos ORDER BY full_name`); + expect(rows.map((r) => ({ ...r, github_repo_id: r.github_repo_id === null ? null : Number(r.github_repo_id) }))).toEqual([ + { full_name: 'acme-gh/existing', github_repo_id: 4242, default_branch: 'develop', clone_url: 'https://github.com/acme-gh/existing' }, + { full_name: 'acme-gh/unknown', github_repo_id: null, default_branch: 'main', clone_url: null }, + ]); + }); + + it('멤버 목록의 협업자 표시도 대표 토큰으로 — 대표가 연결하지 않았으면 생략', async () => { + const w = await world(); + await linkGithub(w.be.userId, 2, 'octo-be'); + await connectRepos({ orgId: w.orgId, actorUserId: w.rep.userId, repos: [{ fullName: 'acme-gh/existing', githubRepoId: 4242 }] }); + const before = (await http('GET', `/orgs/${w.orgId}/members`, w.rep.token)).body.data!.members as { githubLogin: string | null; isCollaborator?: boolean }[]; + expect(before.find((m) => m.githubLogin === 'octo-be')).not.toHaveProperty('isCollaborator'); + await linkGithub(w.rep.userId, 1, 'octo-rep'); + const after = (await http('GET', `/orgs/${w.orgId}/members`, w.rep.token)).body.data!.members as { githubLogin: string | null; isCollaborator?: boolean }[]; + expect(after.find((m) => m.githubLogin === 'octo-be')).toMatchObject({ isCollaborator: true }); + expect(after.find((m) => m.githubLogin === 'octo-rep')).toMatchObject({ isCollaborator: false }); + }); +}); diff --git a/tests/openapi-contract.test.ts b/tests/openapi-contract.test.ts index 2eee505..57360d3 100644 --- a/tests/openapi-contract.test.ts +++ b/tests/openapi-contract.test.ts @@ -98,6 +98,15 @@ const fakeGithubRep: GithubRepApi = { async inviteCollaborator() { return 'invited'; }, + async listRepos() { + return [{ fullName: 'acme/study-api', githubRepoId: 987654321, defaultBranch: 'main' }]; + }, + async getRepo() { + return null; + }, + async isCollaborator() { + return true; + }, }; const fakeGithub: GithubDeviceApi = { @@ -252,6 +261,9 @@ describe('모든 성공 응답을 실제로 받아 문서와 대조한다', () = await call('POST', `/projects/${projectId}/start`, rep); await call('GET', '/agents/me/tasks', agent); await call('POST', `/tasks/${taskId}/claim`, agent); + // 룸: Executor의 실행 시작·도구 사용 보고 + await call('POST', `/tasks/${taskId}/runs/start`, agent); + await call('POST', `/tasks/${taskId}/activity`, agent, { items: [{ kind: 'read', target: 'src/api/join.ts' }, { kind: 'run', target: 'npm test' }] }); // 제출 — 커밋 diff는 가짜 검사기로. 신고와 같게 두면 V3는 PASS다. setCommitInspector({ kind: 'fake', async changedPaths() { return ['src/api/join.ts']; } }); @@ -259,6 +271,16 @@ describe('모든 성공 응답을 실제로 받아 문서와 대조한다', () = commitSha: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0', changedPaths: ['src/api/join.ts'], }); const artifactId = submitted.data.id as string; + await call('POST', `/tasks/${taskId}/runs/end`, agent, { outcome: 'completed', committed: true, durationMs: 1200, exitCode: 0 }); + await call('GET', `/projects/${projectId}/rooms`, rep); + await call('GET', `/projects/${projectId}/rooms/BACKEND/feed?limit=20`, rep); + // 제출 없이 끝난 실행 → 멈춤(BLOCKED) → 대표 재개 + const stuck = await call('POST', `/projects/${projectId}/tasks`, rep, { title: 'T-9 막히는 태스크', teamRole: 'BACKEND', kind: 'INTEGRATION', repoId }); + const stuckId = stuck.data.id as string; + await call('POST', `/tasks/${stuckId}/runs/start`, agent); + await call('POST', `/tasks/${stuckId}/claim`, agent); + await call('POST', `/tasks/${stuckId}/runs/end`, agent, { outcome: 'completed', committed: false, durationMs: 1000, exitCode: 0, lastMessage: '권한이 없다', deniedCommands: ['git fetch'] }); + await call('POST', `/tasks/${stuckId}/resume`, rep, { note: '허용함' }); await call('GET', `/tasks/${taskId}/artifacts`, agent); await call('GET', `/tasks/${taskId}/artifacts`, rep); await call('POST', `/artifacts/${artifactId}/verifications`, agent, { stage: 'V4', result: 'PASS', durationMs: 1200 }); diff --git a/tests/pm-relay.test.ts b/tests/pm-relay.test.ts index ed4f183..e343815 100644 --- a/tests/pm-relay.test.ts +++ b/tests/pm-relay.test.ts @@ -164,7 +164,7 @@ describe('중계 모드 — 서버 → 대표 노트북 → 서버', () => { const plan = await getPlan(w, planId); expect(plan).toMatchObject({ status: 'ready', error: null }); - expect(plan.draft).toEqual(DRAFT); // PM이 낸 JSON 그대로 + expect(plan.draft).toEqual({ ...DRAFT, integrationChecks: [] }); // PM이 낸 JSON 그대로(없는 확인 항목은 빈 목록) const calls = await pool.query(`SELECT on_behalf_of, payload FROM events WHERE type = 'PM_CALL'`); expect(calls.rows).toHaveLength(1); diff --git a/tests/pm.test.ts b/tests/pm.test.ts index 3467f6d..6cca602 100644 --- a/tests/pm.test.ts +++ b/tests/pm.test.ts @@ -146,7 +146,8 @@ describe('계획 요청 → 초안 → 적용', () => { const plan = await requestAndWait(w); expect(plan).toMatchObject({ status: 'ready', error: null }); - expect(plan.draft).toEqual(DRAFT); + // 확인 항목(integrationChecks)이 없으면 빈 목록으로 저장된다. + expect(plan.draft).toEqual({ ...DRAFT, integrationChecks: [] }); expect(plan.costUsd).toBeCloseTo(costOfAttempts(USAGE), 6); // 지시와 연결된 레포가 PM에게 간다. 지침(system)은 바뀌지 않는 부분이다. expect(calls[0]!.user).toContain('참여 신청 기능'); diff --git a/tests/presence-events.test.ts b/tests/presence-events.test.ts index 93fb417..063f11d 100644 --- a/tests/presence-events.test.ts +++ b/tests/presence-events.test.ts @@ -9,7 +9,7 @@ import { connectAgent, refreshAgentToken } from '../src/domain/agent/service.js' import { login, signup } from '../src/domain/auth/service.js'; import { acceptInvite, createInvite } from '../src/domain/invite/service.js'; import { createOrganization } from '../src/domain/org/service.js'; -import { assignMember, createProject } from '../src/domain/project/service.js'; +import { assignMember, unassignMember, createProject } from '../src/domain/project/service.js'; import { connectRepos } from '../src/domain/repo/service.js'; import { attachAgentStream, type AgentStream } from '../src/realtime/agent-stream.js'; import { resetSchema, testPool, truncateAll } from './test-db.js'; @@ -113,8 +113,18 @@ describe('이벤트 로그 조회', () => { it('최신순으로 주고, nextBefore로 이어서 읽으면 빠짐·겹침 없이 전부다', async () => { const w = await world(); - const all = await pool.query(`SELECT id::text AS id FROM events WHERE project_id = $1 ORDER BY id DESC`, [w.projectId]); + // 기대값을 같은 별칭 정렬로 만들면 버그를 못 잡는다(실제로 그랬다) — 숫자 열로 정렬하고, id가 10을 넘게 이벤트를 더 만든다. + // id가 두 자리 → 세 자리로 넘어가게 한다(시퀀스를 98로 옮긴 뒤 이벤트를 더 만든다). 문자열 정렬이면 "99" > "100"이 된다. + await pool.query(`SELECT setval('events_id_seq', GREATEST((SELECT max(id) FROM events), 98))`); + const actor = { userId: w.rep.userId, orgId: w.orgId, orgRole: 'REPRESENTATIVE' }; + for (let i = 0; i < 2; i += 1) { + await unassignMember(actor, w.projectId, w.agentId); + await assignMember(actor, w.projectId, w.agentId, 'BACKEND'); + } + const all = await pool.query(`SELECT e.id::text AS id FROM events e WHERE e.project_id = $1 ORDER BY e.id DESC`, [w.projectId]); expect(all.rows.length).toBeGreaterThan(1); + const ids = all.rows.map((r) => Number(r.id)); + expect(ids.some((id) => id >= 100) && ids.some((id) => id < 100)).toBe(true); const seen: string[] = []; let before = ''; diff --git a/tests/projects.test.ts b/tests/projects.test.ts index 26934ce..9e89f27 100644 --- a/tests/projects.test.ts +++ b/tests/projects.test.ts @@ -249,7 +249,7 @@ describe('멤버 배정', () => { const members = await assignMember(ctx.actor, project.id, be.agentId, 'BACKEND'); expect(members).toEqual([ - { agentId: be.agentId, agentName: 'be-laptop', teamRole: 'BACKEND', userId: be.userId }, + { agentId: be.agentId, agentName: 'be-laptop', teamRole: 'BACKEND', userId: be.userId, githubInvites: [] }, // GitHub 레포가 없는 프로젝트라 초대 결과는 비어 있다 ]); const { rows } = await pool.query( `SELECT actor_agent_id, on_behalf_of, payload FROM events WHERE type = 'MEMBER_ASSIGNED'`, diff --git a/tests/release.test.ts b/tests/release.test.ts new file mode 100644 index 0000000..bf3fec3 --- /dev/null +++ b/tests/release.test.ts @@ -0,0 +1,197 @@ +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { createApp } from '../src/app.js'; +import { pool } from '../src/config/db.js'; +import { connectAgent } from '../src/domain/agent/service.js'; +import { login, signup } from '../src/domain/auth/service.js'; +import { drainTasksChanged } from '../src/domain/dispatch/tasks-changed.js'; +import { acceptInvite, createInvite } from '../src/domain/invite/service.js'; +import { createOrganization } from '../src/domain/org/service.js'; +import { pmOnlyProblems, type PlanDraft } from '../src/domain/pm/draft.js'; +import { setPmModel, type PmModelResponse } from '../src/domain/pm/model.js'; +import { drainPmJobs } from '../src/domain/pm/service.js'; +import { assignMember, createProject } from '../src/domain/project/service.js'; +import { connectRepos } from '../src/domain/repo/service.js'; +import { gitMirrorInspector, setCommitInspector } from '../src/domain/verification/commit-inspector.js'; +import { assignRootOwner } from './fixtures.js'; +import { resetSchema, testPool, truncateAll } from './test-db.js'; + +// 통합 확인은 에이전트가 아니라 대표가 한다(운영 테스트 4-3): PM은 INTEGRATION 태스크 대신 확인 항목(integrationChecks)을 쓰고, +// 모든 태스크가 DONE이 되면 대표에게 G3(통합 확인·완료 승인) 카드가 생긴다. 승인하면 프로젝트 완료. + +let server: Server; +let baseUrl: string; + +beforeAll(async () => { + await resetSchema(); + server = createApp().listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +beforeEach(async () => { + await truncateAll(); + setCommitInspector({ kind: 'fake', async changedPaths() { return ['src/a.ts']; } }); +}); + +afterEach(async () => { + await drainPmJobs(); + setPmModel(null); +}); + +afterAll(async () => { + setCommitInspector(gitMirrorInspector()); + await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))); + await pool.end(); + await testPool.end(); +}); + +type Res = { status: number; data: Record; error?: { code: string } }; + +async function http(method: string, path: string, token: string | null, body?: unknown): Promise { + const res = await fetch(`${baseUrl}/api${path}`, { + method, + headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + const json = (await res.json()) as { data?: Record; error?: { code: string } }; + return { status: res.status, data: json.data ?? {}, ...(json.error ? { error: json.error } : {}) }; +} + +async function account(loginId: string) { + const { userId, connectKey } = await signup({ loginId, password: 'correct-horse-battery', nickname: loginId }); + const { accessToken } = await login({ loginId, password: 'correct-horse-battery' }); + return { userId, token: accessToken, connectKey }; +} + +const CHECKS = ['목록 화면이 GET /api/todos 응답을 보여 준다', '추가 입력 → POST /api/todos 201 → 목록에 보인다']; + +function draft(extra: Partial = {}): PlanDraft { + return { + mode: 'SEQUENTIAL', + rationale: '작다', + estimate: { workingDays: 1, notes: '' }, + specs: [{ featureKey: 'F-01', title: 'Todo', content: 'WHEN 추가하면 THEN 201' }], + tasks: [ + { ref: 'a', title: 'T-1 목록 API', repo: 'acme/api', teamRole: 'BACKEND', kind: 'IMPLEMENT', spec: 'F-01', dependsOn: [] }, + { ref: 'b', title: 'T-2 추가 API', repo: 'acme/api', teamRole: 'BACKEND', kind: 'IMPLEMENT', spec: 'F-01', dependsOn: [] }, + ], + integrationChecks: CHECKS, + ...extra, + }; +} + +function respond(d: unknown): PmModelResponse { + return { stopReason: 'end_turn', servedModel: 'claude-sonnet-5-5', text: JSON.stringify(d), attempts: [] }; +} + +// 대표·BE 팀원(에이전트), PM 계획 적용(확인 항목 포함), 배정, 시작. +async function world() { + const rep = await account('rep'); + const { orgId } = await createOrganization(rep.userId, 'Acme'); + const be = await account('be-dev'); + const { token } = await createInvite(orgId, rep.userId); + await acceptInvite(token, be.userId); + const agent = await connectAgent({ connectKey: be.connectKey, agentName: 'be-laptop', harness: 'claude-code', skills: [], maxConcurrent: 1 }); + const [repo] = await connectRepos({ orgId, actorUserId: rep.userId, repos: [{ fullName: 'acme/api' }] }); + await assignRootOwner(orgId, rep.userId, repo!.id, 'BACKEND'); + const actor = { userId: rep.userId, orgId, orgRole: 'REPRESENTATIVE' }; + const { project } = await createProject(orgId, rep.userId, { name: 'p', autonomyPreset: 'L2', pmBudgetUsd: 10, repoIds: [repo!.id] }); + await assignMember(actor, project.id, agent.agentId, 'BACKEND'); + + setPmModel({ kind: 'fake', async generate() { return respond(draft()); } }); + const requested = await http('POST', `/projects/${project.id}/pm/plans`, rep.token, { instruction: 'Todo' }); + await drainPmJobs(); + await http('POST', `/projects/${project.id}/pm/plans/${requested.data.id as string}/apply`, rep.token); + await http('POST', `/projects/${project.id}/start`, rep.token); + await drainTasksChanged(); + + const refreshed = await fetch(`${baseUrl}/api/agents/token/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken: agent.refreshToken }), + }); + const agentToken = ((await refreshed.json()) as { data: { accessToken: string } }).data.accessToken; + const tasks = (await http('GET', `/projects/${project.id}/tasks`, rep.token)).data.tasks as { id: string; title: string }[]; + return { rep, orgId, projectId: project.id, repoId: repo!.id, agentToken, tasks }; +} + +let sha = 0; +// 에이전트가 태스크 하나를 끝까지: 수령 → 제출(V3 PASS) → V2·V4 보고 → DONE(L2·자기 경로는 AUTO). +async function finish(agentToken: string, taskId: string): Promise { + expect((await http('POST', `/tasks/${taskId}/claim`, agentToken)).status).toBe(200); + sha += 1; + const submitted = await http('POST', `/tasks/${taskId}/artifacts`, agentToken, { commitSha: sha.toString(16).padStart(40, 'a'), changedPaths: ['src/a.ts'] }); + expect(submitted.status).toBe(201); + for (const stage of ['V2', 'V4']) { + await http('POST', `/artifacts/${submitted.data.id as string}/verifications`, agentToken, { stage, result: 'SKIPPED', detail: { reason: '없음' } }); + } + const { rows } = await testPool.query(`SELECT state FROM tasks WHERE id = $1`, [taskId]); + expect(rows[0].state).toBe('DONE'); +} + +async function releases(projectId: string) { + const { rows } = await testPool.query(`SELECT id, decision, payload FROM approvals WHERE project_id = $1 AND gate = 'G3' ORDER BY requested_at`, [projectId]); + return rows as { id: string; decision: string | null; payload: { integrationChecks: string[]; tasks: { title: string; repo: string }[] } }[]; +} + +describe('PM은 통합 태스크 대신 확인 항목을 쓴다', () => { + it('INTEGRATION 태스크는 위반으로 돌려준다(교정 1회에 싣는다)', () => { + const bad = draft({ tasks: [{ ref: 'i', title: '통합', repo: 'acme/api', teamRole: 'FRONTEND', kind: 'INTEGRATION', spec: null, dependsOn: [] }] }); + expect(pmOnlyProblems(bad)).toEqual([expect.stringContaining('INTEGRATION 태스크를 만들지 않는다')]); + expect(pmOnlyProblems(draft())).toEqual([]); + }); +}); + +describe('G3 — 통합 확인·완료 승인', () => { + it('마지막 태스크가 DONE이 되는 순간 카드 하나 — 확인 항목과 태스크별 레포·브랜치·커밋이 담긴다', async () => { + const w = await world(); + await finish(w.agentToken, w.tasks[0]!.id); + expect(await releases(w.projectId)).toEqual([]); + await finish(w.agentToken, w.tasks[1]!.id); + + const [card, ...rest] = await releases(w.projectId); + expect(rest).toEqual([]); + expect(card!.decision).toBeNull(); + expect(card!.payload.integrationChecks).toEqual(CHECKS); + expect(card!.payload.tasks.map((t) => t.repo)).toEqual(['acme/api', 'acme/api']); + + // 대기열 API: gate G3, taskId null(프로젝트 전체). + const queue = await http('GET', `/orgs/${w.orgId}/approvals?status=pending`, w.rep.token); + expect(queue.data.approvals).toEqual([expect.objectContaining({ gate: 'G3', taskId: null, projectId: w.projectId })]); + + // 룸(모든 룸)에 PM 줄. + await drainTasksChanged(); + const feed = await http('GET', `/projects/${w.projectId}/rooms/FRONTEND/feed`, w.rep.token); + expect((feed.data.messages as { text: string }[])[0]!.text).toBe('모든 태스크가 완료되었습니다 → 대표의 통합 확인을 기다립니다 (확인 항목 2개)'); + }); + + it('반려하면 진행 중 그대로 → 고칠 태스크가 끝나면 새 카드 → 승인하면 프로젝트 완료', async () => { + const w = await world(); + for (const t of w.tasks) await finish(w.agentToken, t.id); + const [first] = await releases(w.projectId); + + expect((await http('POST', `/approvals/${first!.id}/reject`, w.rep.token, { reason: '추가 후 목록이 갱신되지 않는다' })).status).toBe(200); + const project = await testPool.query(`SELECT status FROM projects WHERE id = $1`, [w.projectId]); + expect(project.rows[0].status).toBe('active'); + expect((await http('POST', `/approvals/${first!.id}/approve`, w.rep.token)).error?.code).toBe('APPROVAL_ALREADY_DECIDED'); + + const fix = await http('POST', `/projects/${w.projectId}/tasks`, w.rep.token, { title: 'T-3 목록 갱신 고치기', teamRole: 'BACKEND', kind: 'REWORK', repoId: w.repoId }); + await drainTasksChanged(); + await finish(w.agentToken, fix.data.id as string); + const cards = await releases(w.projectId); + expect(cards.map((c) => c.decision)).toEqual(['REJECT', null]); + + expect((await http('POST', `/approvals/${cards[1]!.id}/approve`, w.rep.token)).status).toBe(200); + const done = await testPool.query(`SELECT status FROM projects WHERE id = $1`, [w.projectId]); + expect(done.rows[0].status).toBe('completed'); + const events = await testPool.query(`SELECT type, on_behalf_of FROM events WHERE type IN ('RELEASE_REQUESTED', 'RELEASE_DECIDED') ORDER BY id`); + expect(events.rows.map((r) => `${r.type}:${r.on_behalf_of === 'system:pm' ? 'pm' : 'rep'}`)).toEqual([ + 'RELEASE_REQUESTED:pm', + 'RELEASE_DECIDED:rep', + 'RELEASE_REQUESTED:pm', + 'RELEASE_DECIDED:rep', + ]); + }); +}); diff --git a/tests/repo-paths.test.ts b/tests/repo-paths.test.ts index 94736cf..9a9069d 100644 --- a/tests/repo-paths.test.ts +++ b/tests/repo-paths.test.ts @@ -182,7 +182,7 @@ describe('레포 연결 권한 (HTTP)', () => { expect(connected.status).toBe(201); // 드롭다운을 채우는 목록도 함께 열려 있어야 연결 화면이 성립한다. - // GITHUB_TOKEN이 없는 테스트 환경에서는 빈 배열이 정상이다(500이 아니다). + // 대표가 GitHub를 연결하지 않았으면 빈 배열이 정상이다(500이 아니다). const listed = await send('GET', `/api/orgs/${orgId}/github/repos`, member.token); expect(listed.status).toBe(200); expect(listed.body).toMatchObject({ data: { repos: [] } }); diff --git a/tests/rooms.test.ts b/tests/rooms.test.ts new file mode 100644 index 0000000..4031642 --- /dev/null +++ b/tests/rooms.test.ts @@ -0,0 +1,271 @@ +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { createApp } from '../src/app.js'; +import { pool } from '../src/config/db.js'; +import { connectAgent } from '../src/domain/agent/service.js'; +import { login, signup } from '../src/domain/auth/service.js'; +import { createTask } from '../src/domain/authoring/service.js'; +import { drainTasksChanged } from '../src/domain/dispatch/tasks-changed.js'; +import { acceptInvite, createInvite } from '../src/domain/invite/service.js'; +import { createOrganization } from '../src/domain/org/service.js'; +import { assignMember, createProject, startProject } from '../src/domain/project/service.js'; +import { connectRepos } from '../src/domain/repo/service.js'; +import { gitMirrorInspector, setCommitInspector } from '../src/domain/verification/commit-inspector.js'; +import { sweepUnresponsiveClaims } from '../src/domain/task/stall.js'; +import { assignRootOwner } from './fixtures.js'; +import { resetSchema, testPool, truncateAll } from './test-db.js'; + +// 룸(프로젝트 × 역할) 피드: 서버의 "실행해 주세요" → 수령 → 실행 시작 → 도구 사용 → 제출 → 검증 결과가 한 줄씩. +// 팀원은 자기 역할 룸만, 대표는 전부 본다. + +let server: Server; +let baseUrl: string; + +beforeAll(async () => { + await resetSchema(); + server = createApp().listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +beforeEach(async () => { + await truncateAll(); + // V3는 가짜 검사기 — 제출과 같은 경로를 바꿨다고 답한다. + setCommitInspector({ kind: 'fake', async changedPaths() { return ['src/api/join.ts']; } }); +}); + +afterAll(async () => { + setCommitInspector(gitMirrorInspector()); + await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))); + await pool.end(); + await testPool.end(); +}); + +type Res = { status: number; data: Record; error?: { code: string } }; + +async function http(method: string, path: string, token: string, body?: unknown): Promise { + const res = await fetch(`${baseUrl}/api${path}`, { + method, + headers: { Authorization: `Bearer ${token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + const json = (await res.json()) as { data?: Record; error?: { code: string } }; + return { status: res.status, data: json.data ?? {}, ...(json.error ? { error: json.error } : {}) }; +} + +async function account(loginId: string) { + const { userId, connectKey } = await signup({ loginId, password: 'correct-horse-battery', nickname: loginId }); + const { accessToken } = await login({ loginId, password: 'correct-horse-battery' }); + return { userId, token: accessToken, connectKey }; +} + +// 대표, BE·FE 팀원(각자 에이전트), BE·FE 태스크 하나씩. 시작까지 해 둔다. +async function world() { + const rep = await account('rep'); + const { orgId } = await createOrganization(rep.userId, 'Acme'); + const be = await account('be-dev'); + const fe = await account('fe-dev'); + for (const m of [be, fe]) { + const { token } = await createInvite(orgId, rep.userId); + await acceptInvite(token, m.userId); + } + const beAgent = await connectAgent({ connectKey: be.connectKey, agentName: 'be-laptop', harness: 'claude-code', skills: [], maxConcurrent: 1 }); + const feAgent = await connectAgent({ connectKey: fe.connectKey, agentName: 'fe-laptop', harness: 'claude-code', skills: [], maxConcurrent: 1 }); + const [repo] = await connectRepos({ orgId, actorUserId: rep.userId, repos: [{ fullName: 'acme/app' }] }); + await assignRootOwner(orgId, rep.userId, repo!.id, 'BACKEND'); + const actor = { userId: rep.userId, orgId, orgRole: 'REPRESENTATIVE' }; + const project = await createProject(orgId, rep.userId, { name: 'p', autonomyPreset: 'L2', pmBudgetUsd: 1, repoIds: [repo!.id] }); + const projectId = project.project.id; + await assignMember(actor, projectId, beAgent.agentId, 'BACKEND'); + await assignMember(actor, projectId, feAgent.agentId, 'FRONTEND'); + const beTask = await createTask(rep.userId, projectId, { title: 'T-1 가입 API', teamRole: 'BACKEND', kind: 'INTEGRATION', repoId: repo!.id, specId: null, dependsOn: [] }); + const feTask = await createTask(rep.userId, projectId, { title: 'T-2 가입 화면', teamRole: 'FRONTEND', kind: 'INTEGRATION', repoId: repo!.id, specId: null, dependsOn: [] }); + await startProject(actor, projectId); + await drainTasksChanged(); + // 배정 뒤 재발급해야 토큰에 project_id가 담긴다. + const refresh = async (refreshToken: string) => + ((await (await fetch(`${baseUrl}/api/agents/token/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken }) })).json()) as { data: { accessToken: string } }).data.accessToken; + return { + rep, be, fe, orgId, projectId, + beTaskId: beTask.id, feTaskId: feTask.id, + beAgentToken: await refresh(beAgent.refreshToken), + feAgentToken: await refresh(feAgent.refreshToken), + }; +} + +type Msg = { speaker: string; type: string; text: string; taskId: string | null }; + +async function feed(token: string, projectId: string, role: string, query = ''): Promise<{ messages: Msg[]; nextBefore: string | null; status: number }> { + await drainTasksChanged(); + const res = await http('GET', `/projects/${projectId}/rooms/${role}/feed${query}`, token); + return { status: res.status, messages: (res.data.messages ?? []) as Msg[], nextBefore: (res.data.nextBefore ?? null) as string | null }; +} + +describe('룸 피드 — 실행해 주세요부터 검증 결과까지', () => { + it('BE 룸에 서버 지시·수령·실행·도구 사용·제출·검증이 순서대로 쌓인다(최신부터)', async () => { + const w = await world(); + // 실제 순서: Executor가 실행을 시작하고 → 모델이 실행 안에서 태스크를 잡는다. + expect((await http('POST', `/tasks/${w.beTaskId}/runs/start`, w.beAgentToken)).status).toBe(201); + await http('POST', `/tasks/${w.beTaskId}/claim`, w.beAgentToken); + expect( + (await http('POST', `/tasks/${w.beTaskId}/activity`, w.beAgentToken, { + items: [ + { kind: 'read', target: 'src/api/join.ts' }, + { kind: 'edit', target: 'src/api/join.ts' }, + { kind: 'run', target: 'npm test' }, + ], + })).status, + ).toBe(201); + const submitted = await http('POST', `/tasks/${w.beTaskId}/artifacts`, w.beAgentToken, { + commitSha: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0', + changedPaths: ['src/api/join.ts'], + }); + expect(submitted.status).toBe(201); + // Executor가 V2·V4를 보고해야 검증 결론이 난다(그 전의 '검증 중'은 룸에 줄을 만들지 않는다). + const artifactId = submitted.data.id as string; + for (const stage of ['V2', 'V4']) { + await http('POST', `/artifacts/${artifactId}/verifications`, w.beAgentToken, { stage, result: 'SKIPPED', detail: { reason: '없음' } }); + } + const ended = await http('POST', `/tasks/${w.beTaskId}/runs/end`, w.beAgentToken, { outcome: 'completed', committed: true, durationMs: 42_000, exitCode: 0 }); + expect(ended.data).toMatchObject({ submitted: true }); + + const { messages } = await feed(w.rep.token, w.projectId, 'BACKEND'); + const lines = [...messages].reverse().map((m) => `[${m.speaker}] ${m.text}`); + expect(lines.slice(0, 8)).toEqual([ + '[pm] 프로젝트를 시작합니다', + '[pm] T-1 가입 API 실행해 주세요', + '[nomos] 에이전트를 실행합니다', + '[nomos] 태스크 수령 — T-1 가입 API · 구현을 시작합니다', + '[agent] src/api/join.ts 읽는 중', + '[agent] src/api/join.ts 수정', + '[agent] npm test 실행', + '[nomos] 제출했습니다 (커밋 a1b2c3d, 파일 1개)', + ]); + // 검증 결론은 한 줄만(이 태스크는 정책상 AUTO라 완료), 그 뒤에 실행 종료. + expect(lines.slice(8)).toEqual([ + '[pm] 검증 완료 — V3 통과, V1A·V1B·V2·V4 건너뜀 → 완료되었습니다', + '[nomos] 실행을 마쳤습니다 (42초)', + ]); + // 다른 역할의 태스크는 BE 룸에 나오지 않는다. + expect(messages.some((m) => m.taskId === w.feTaskId)).toBe(false); + + // 서버의 지시는 system:dispatcher 명의로 한 번만. + const dispatched = await testPool.query(`SELECT on_behalf_of FROM events WHERE type = 'TASK_DISPATCHED' AND payload->>'taskId' = $1`, [w.beTaskId]); + expect(dispatched.rows).toEqual([{ on_behalf_of: 'system:dispatcher' }]); + }); + + it('제출 없이 끝나면 BLOCKED(AGENT_STOPPED)로 멈추고 사유·거부 명령이 룸에 남는다 — 재시도 횟수는 그대로, 대표가 재개하면 다시 지시', async () => { + const w = await world(); + await http('POST', `/tasks/${w.beTaskId}/runs/start`, w.beAgentToken); + await http('POST', `/tasks/${w.beTaskId}/claim`, w.beAgentToken); + const ended = await http('POST', `/tasks/${w.beTaskId}/runs/end`, w.beAgentToken, { + outcome: 'completed', committed: false, durationMs: 48_000, exitCode: 0, + lastMessage: 'git merge 권한이 없어 통합 확인을 할 수 없습니다', deniedCommands: ['git fetch --all', 'npm --version'], + }); + expect(ended.data).toEqual({ submitted: false, taskState: 'BLOCKED', blocked: true }); + const { rows } = await testPool.query(`SELECT state, blocked_reason, retry_count FROM tasks WHERE id = $1`, [w.beTaskId]); + expect(rows[0]).toEqual({ state: 'BLOCKED', blocked_reason: 'AGENT_STOPPED', retry_count: 0 }); + + let { messages } = await feed(w.rep.token, w.projectId, 'BACKEND'); + expect(messages.slice(0, 2).map((m) => m.text).reverse()).toEqual([ + '실행을 마쳤습니다 (48초, 제출 없음)', + '멈췄습니다 — 제출하지 않고 끝났습니다 / 에이전트: "git merge 권한이 없어 통합 확인을 할 수 없습니다" / 거부된 명령: git fetch --all, npm --version → 대표 확인 후 재개가 필요합니다', + ]); + + // 팀원은 재개할 수 없고, 대표가 재개하면 READY + 담당 비움 + "다시 실행해 주세요 (재개)". + expect((await http('POST', `/tasks/${w.beTaskId}/resume`, w.be.token, {})).status).toBe(403); + const resumed = await http('POST', `/tasks/${w.beTaskId}/resume`, w.rep.token, { note: '허용 명령을 늘렸습니다' }); + expect(resumed.data).toMatchObject({ state: 'READY', assigneeAgentId: null, blockedReason: null, retryCount: 0 }); + ({ messages } = await feed(w.rep.token, w.projectId, 'BACKEND')); + expect(messages.slice(0, 2).map((m) => m.text).reverse()).toEqual(['대표가 재개했습니다: 허용 명령을 늘렸습니다', 'T-1 가입 API 다시 실행해 주세요 (재개)']); + expect(await http('POST', `/tasks/${w.beTaskId}/resume`, w.rep.token, {})).toMatchObject({ status: 409, error: { code: 'TASK_NOT_STOPPED' } }); + // 다시 가져갈 수 있다. + expect((await http('POST', `/tasks/${w.beTaskId}/claim`, w.beAgentToken)).status).toBe(200); + }); + + it('감시: 응답이 끊긴 수령은 멈춤으로 바뀐다(system:watchdog)', async () => { + const w = await world(); + await http('POST', `/tasks/${w.beTaskId}/claim`, w.beAgentToken); + await testPool.query(`UPDATE tasks SET claimed_at = now() - interval '2 hours' WHERE id = $1`, [w.beTaskId]); // 시간 경과를 흉내 낸다(서비스 함수 없음) + await testPool.query(`UPDATE events SET ts = now() - interval '2 hours' WHERE payload->>'taskId' = $1`, [w.beTaskId]); + expect(await sweepUnresponsiveClaims(60 * 60 * 1000)).toBe(1); + const { rows } = await testPool.query(`SELECT on_behalf_of, payload->>'cause' AS cause FROM events WHERE type = 'TASK_BLOCKED'`); + expect(rows).toEqual([{ on_behalf_of: 'system:watchdog', cause: 'unresponsive' }]); + expect(await sweepUnresponsiveClaims(60 * 60 * 1000)).toBe(0); + }); +}); + +describe('룸 권한', () => { + it('팀원은 자기 역할 룸만, 대표는 전부', async () => { + const w = await world(); + expect((await feed(w.fe.token, w.projectId, 'FRONTEND')).status).toBe(200); + const denied = await http('GET', `/projects/${w.projectId}/rooms/BACKEND/feed`, w.fe.token); + expect(denied).toMatchObject({ status: 403, error: { code: 'ROOM_NOT_VISIBLE' } }); + expect((await feed(w.rep.token, w.projectId, 'FRONTEND')).status).toBe(200); + + const feRooms = await http('GET', `/projects/${w.projectId}/rooms`, w.fe.token); + expect((feRooms.data.rooms as { role: string }[]).map((r) => r.role)).toEqual(['FRONTEND']); + const repRooms = await http('GET', `/projects/${w.projectId}/rooms`, w.rep.token); + expect((repRooms.data.rooms as { role: string; agent: { agentName: string } }[]).map((r) => [r.role, r.agent.agentName])).toEqual([ + ['FRONTEND', 'fe-laptop'], + ['BACKEND', 'be-laptop'], + ]); + }); + + it('PM의 프로젝트 단위 알림은 모든 룸에 나온다', async () => { + const w = await world(); + for (const role of ['FRONTEND', 'BACKEND']) { + const { messages } = await feed(w.rep.token, w.projectId, role); + expect(messages.some((m) => m.text === '프로젝트를 시작합니다')).toBe(true); + } + }); +}); + +describe('Executor 보고 규칙', () => { + it('실행 시작은 그 태스크를 잡았거나 지금 잡을 수 있는 에이전트만, 활동·종료는 열린 실행에만', async () => { + const w = await world(); + // 다른 역할의 태스크는 시작할 수 없다. + expect(await http('POST', `/tasks/${w.beTaskId}/runs/start`, w.feAgentToken)).toMatchObject({ status: 409, error: { code: 'RUN_NOT_ALLOWED' } }); + await http('POST', `/tasks/${w.beTaskId}/claim`, w.beAgentToken); + expect(await http('POST', `/tasks/${w.beTaskId}/activity`, w.beAgentToken, { items: [{ kind: 'read', target: 'a.ts' }] })).toMatchObject({ + status: 409, + error: { code: 'RUN_NOT_OPEN' }, + }); + await http('POST', `/tasks/${w.beTaskId}/runs/start`, w.beAgentToken); + await http('POST', `/tasks/${w.beTaskId}/runs/end`, w.beAgentToken, { outcome: 'timeout', committed: false, durationMs: 1, exitCode: null }); + expect(await http('POST', `/tasks/${w.beTaskId}/runs/end`, w.beAgentToken, { outcome: 'timeout', committed: false, durationMs: 1, exitCode: null })).toMatchObject({ + status: 409, + error: { code: 'RUN_NOT_OPEN' }, + }); + }); + + it('활동은 도구 종류와 300자 이하 대상만 받는다', async () => { + const w = await world(); + await http('POST', `/tasks/${w.beTaskId}/claim`, w.beAgentToken); + await http('POST', `/tasks/${w.beTaskId}/runs/start`, w.beAgentToken); + expect((await http('POST', `/tasks/${w.beTaskId}/activity`, w.beAgentToken, { items: [{ kind: 'think', target: 'x' }] })).status).toBe(400); + expect((await http('POST', `/tasks/${w.beTaskId}/activity`, w.beAgentToken, { items: [{ kind: 'read', target: 'x'.repeat(301) }] })).status).toBe(400); + }); +}); + +describe('피드 페이지 넘기기', () => { + it('nextBefore로 이어 읽으면 빠지거나 겹치는 줄이 없다', async () => { + const w = await world(); + await http('POST', `/tasks/${w.beTaskId}/claim`, w.beAgentToken); + await http('POST', `/tasks/${w.beTaskId}/runs/start`, w.beAgentToken); + await http('POST', `/tasks/${w.beTaskId}/activity`, w.beAgentToken, { + items: Array.from({ length: 7 }, (_, i) => ({ kind: 'read', target: `src/f${i}.ts` })), + }); + const all = (await feed(w.rep.token, w.projectId, 'BACKEND', '?limit=200')).messages.map((m) => m.text); + const paged: string[] = []; + let cursor: string | null = null; + do { + const page = await feed(w.rep.token, w.projectId, 'BACKEND', `?limit=3${cursor ? `&before=${encodeURIComponent(cursor)}` : ''}`); + paged.push(...page.messages.map((m) => m.text)); + cursor = page.nextBefore; + } while (cursor); + expect(paged).toEqual(all); + expect((await http('GET', `/projects/${w.projectId}/rooms/BACKEND/feed?before=nope`, w.rep.token)).status).toBe(400); + }); +}); diff --git a/tests/test-db.ts b/tests/test-db.ts index bbf007b..f220630 100644 --- a/tests/test-db.ts +++ b/tests/test-db.ts @@ -2,6 +2,7 @@ import { readFileSync, readdirSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import pg from 'pg'; +import { drainTasksChanged } from '../src/domain/dispatch/tasks-changed.js'; const { Pool } = pg; @@ -29,8 +30,10 @@ export async function runMigrations(): Promise { } export async function truncateAll(): Promise { + // 커밋 뒤 비동기로 도는 tasksChanged 리스너(배정 기록 등)가 TRUNCATE와 겹치면 교착하거나 지운 행을 참조한다 — 끝나길 기다린다. + await drainTasksChanged(); await testPool.query( - `TRUNCATE TABLE approvals, notes, verifications, artifacts, task_deps, tasks, plans, spec_tests, specs, project_policies, oauth_sessions, project_members, project_repos, projects, + `TRUNCATE TABLE agent_activity, task_dispatches, approvals, notes, verifications, artifacts, task_deps, tasks, plans, spec_tests, specs, project_policies, oauth_sessions, project_members, project_repos, projects, agent_device_requests, agent_tokens, agents, events, invites, repo_paths, repos, users, organizations RESTART IDENTITY CASCADE`, ); @@ -45,6 +48,8 @@ export async function resetSchema(): Promise { export async function dropSchema(): Promise { await testPool.query(` + DROP TABLE IF EXISTS agent_activity; + DROP TABLE IF EXISTS task_dispatches; DROP TABLE IF EXISTS approvals; DROP TABLE IF EXISTS agent_device_requests; DROP TABLE IF EXISTS events;