diff --git a/CLAUDE.md b/CLAUDE.md index 091bea4..3281c8a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -143,7 +143,8 @@ await withTransaction(async (tx) => { `organizations.created_by → users.id`와 `users.org_id → organizations.id`가 서로를 참조한다. 해법은 두 UUID를 `randomUUID()`로 **애플리케이션에서 미리 생성**하고, `fk_org_created_by`를 `DEFERRABLE INITIALLY DEFERRED`로 선언해 커밋 시점에 검사되게 하는 것이다(`migrations/001_init.sql`). -003 이후에는 사용자가 가입으로 먼저 생기고, 조직을 만들거나 초대를 수락할 때 `users.org_id`를 NULL→값으로 채운다(`assignUserToOrg`). `org_id`는 **한 번만** 바뀐다 — 단일 조직 규칙은 `UPDATE ... WHERE org_id IS NULL` 조건 하나로 지켜지므로 이걸 빼면 안 된다. 같은 순간에 `agents.org_id`도 채운다(`assignAgentsToOrg`, 불변식: `agents.org_id = users.org_id`). +003 이후에는 사용자가 가입으로 먼저 생기고, 조직을 만들거나 초대를 수락할 때 `users.org_id`를 NULL→값으로 채운다(`assignUserToOrg`). 단일 조직 규칙은 `UPDATE ... WHERE org_id IS NULL` 조건 하나로 지켜지므로 이걸 빼면 안 된다. 같은 순간에 `agents.org_id`도 채운다(`assignAgentsToOrg`, 불변식: `agents.org_id = users.org_id`). +값→NULL로 되돌리는 경로는 **조직에서 빠질 때 하나뿐이다**(`removeUserFromOrg`, 021 — 계정은 남고 다른 조직에 다시 들어갈 수 있다). 그때 에이전트의 `org_id`도 함께 비운다(008의 FK가 커밋 시점에 본다). ### GitHub 레포 만들기·협업자 초대 (`domain/github/rep-api.ts`) @@ -539,7 +540,7 @@ PM_REVIEW 반려·피드백 분류·이의 설명·보고서는 아직 없다. 두 곳에 배정되면 먼저 배정된 쪽은 조용히 못 쓰게 된다. "진행 중"의 정의는 `findAgentMembership`과 같아야 한다. - **프로젝트 시작(G1) = `POST /projects/:id/start`**(대표 전용, `startProject`). 이 순간부터 실행이 시작된다. - 시작 조건(위반은 전부 모아 422 `PROJECT_START_INVALID`): 태스크 1개 이상, 끝나지 않은 태스크가 요구하는 **역할마다 배정된 에이전트**. - 시작 뒤에는 멤버를 바꿀 수 없으므로 역할 공백을 여기서 막는다. 적용(계획 → 태스크)과 시작은 따로다 — 적용은 여러 번, 시작은 한 번. + 시작하는 순간 역할 공백이 없게 여기서 막는다(시작 뒤 교체는 아래 021 규칙). 적용(계획 → 태스크)과 시작은 따로다 — 적용은 여러 번, 시작은 한 번. - 시작하면 `planning → active`·`started_at`, 그 시점의 명세·적용된 계획에 `approved_at`(잠김), `PROJECT_STARTED`(멤버·태스크 수 — 지표의 기준점). - **시작 전에는 태스크를 가져갈 수 없다**(claim 409 `PROJECT_NOT_STARTED`). 정책 거부가 아니라 아직 때가 아닌 것이라 `TOOL_DENIED`로 남기지 않는다(M5′ 분모 아님). - 시작과 멤버 변경은 프로젝트 행 잠금으로 직렬화한다(시작 `FOR NO KEY UPDATE`, 배정·해제 `FOR SHARE`) — 시작과 동시에 들어온 배정이 고정을 빠져나가지 않게. @@ -567,8 +568,23 @@ PM_REVIEW 반려·피드백 분류·이의 설명·보고서는 아직 없다. - 인증은 첫 메시지(사람 JWT, `resolveUserToken` — HTTP `authenticate`와 한 벌). Origin은 CORS 허용 목록 + 서버 자신만 받는다(Origin 없음 = 브라우저 아님 → 통과). - 에이전트 접속 상태(`presence.ts`)는 이벤트가 아니라서 `onPresenceChanged`로 따로 받는다. 바뀐 순간에만 알리고, 60초가 지나 offline이 되는 것은 10초마다 훑는다. - 웹소켓 경로는 `realtime/upgrade-router.ts`가 서버당 하나의 upgrade 리스너로 나눈다. 스트림마다 `server.on('upgrade')`를 걸면 서로의 소켓을 끊는다. -- **G1(`projects.started_at`) 이후에는 멤버를 바꿀 수 없다**(403). 역할 교체는 해제 후 재배정이다 — - UPDATE 경로를 두면 한 역할에 둘이 잠깐 겹친다. +- **멤버는 시작(G1) 뒤에도 바꿀 수 있다**(021, 대표 결정 — 사람이 빠지거나 교체해야 할 때). 단 그 에이전트가 진행 중 태스크(CLAIMED·IN_PROGRESS·VERIFYING)를 + 붙잡고 있으면 409 `ACTIVE_TASKS_HELD`(details에 태스크) — 빼 버리면 그 태스크는 아무도 끝내지 못한다. 끝난 프로젝트(completed·aborted)의 멤버는 기록이라 409 `PROJECT_NOT_OPEN`. + 빠진 역할의 태스크는 새 사람을 배정할 때까지 READY로 기다린다. 역할 교체는 해제 후 재배정이다 — UPDATE 경로를 두면 한 역할에 둘이 잠깐 겹친다. + 멤버를 빼는 경로는 **`removeMemberInTx` 한 벌**이다(직접 해제·에이전트 연결 해제·조직 탈퇴가 같은 규칙을 탄다, `MEMBER_UNASSIGNED.payload.cause`로 가른다). +- **해제·삭제(021)는 행을 지우지 않는다** — events·태스크·노트가 가리키고(P5), 다시 연결할 수 있어야 한다. + - 에이전트 연결 해제(`DELETE /agents/:agentId`, 주인·대표): `agents.disconnected_at` + refresh token 폐기 + 진행 중 프로젝트에서 빠짐. 남은 access token은 `agent-auth`가 + 401 `AGENT_DISCONNECTED`로 거부하고, 태스크 스트림은 `dispatch/agent-revoked` 신호로 바로 끊는다. 같은 사용자·이름으로 다시 connect하면 `upsertAgent`가 되살린다. + - 조직에서 내보내기·탈퇴(`DELETE /orgs/:orgId/members/:userId`): 대표가 남을, 멤버는 자기를. 대표는 못 나간다(`REPRESENTATIVE_CANNOT_LEAVE`). 그 사람의 에이전트는 위와 같이 해제된다. + 화면 스트림은 `ORG_MEMBER_REMOVED`를 보고 조직 연결을 다시 확인해 끊는다(4403 `NOT_IN_ORG`). + - 레포 연결 해제(`DELETE /repos/:repoId`, 대표): 진행 중 프로젝트가 쓰면 409 `REPO_IN_ACTIVE_PROJECT`(`findRepoUsage` 한 벌). `repos.disconnected_at` — 해제된 레포는 + 목록·프로젝트 생성·경로 API에서 없는 것으로 본다(`findConnectedRepoById`). 같은 레포를 다시 연결하면 행·경로 규칙·소유권이 되살아난다(`REPO_CONNECTED.payload.revived`). + - 초대 목록·취소(`GET`·`DELETE /orgs/:orgId/invites`, 대표): `invites.revoked_at`. 쓰인 초대는 취소 대상이 아니다(들어온 사람은 멤버 내보내기로 뺀다). + - **GitHub 접근 회수**(`revokeGithubAccess`): 사람이 빠지는 모든 경로(직접 해제·에이전트 연결 해제 → 그 프로젝트 레포, 조직 탈퇴 → 조직 GitHub 레포 전부)에서 + 배정 때 초대의 반대로 대표 토큰으로 회수한다 — 수락 안 한 초대는 취소, 아니면 협업자에서 뺀다. 해제를 먼저 커밋하고 그 뒤에 부른다(**회수 실패가 해제를 막지 않는다**, + 결과는 응답 `githubRevocations`·이벤트 `GITHUB_COLLABORATORS_REMOVED`). 대표 본인은 회수하지 않고, 같은 사람의 다른 에이전트가 그 프로젝트에 남아 있으면 건너뛴다. + - **프로젝트 중단**(`POST /projects/:id/abort`, 대표): 지우지 않고 `aborted`. 에이전트·레포가 풀리고(진행 중 정의에서 빠진다), 대기 질문은 만료로 닫고(`PROJECT_ABORTED.payload.expiredQuestionIds`), + 대기 승인 카드는 대기열에서 빠지며 결정할 수 없다(409). GitHub 권한은 회수하지 않는다(레포·코드는 팀의 것). 실행 중인 CLI는 `NOT_PROJECT_MEMBER`를 보고 다시 배정을 기다린다. - **멤버 배정 뒤 그 에이전트는 토큰을 재발급해야 한다.** 배정 전 토큰에는 `project_id`가 없다. 배정 응답의 `notice`와 openapi 설명에 그 안내가 들어 있다. - 허용 레벨 어휘 위반은 zod(400)가 아니라 서비스가 **422**로 답한다 — 무엇이 허용되는지 함께 알려주기 위해서다. diff --git a/docs/erd.dbml b/docs/erd.dbml index 5647711..2a59753 100644 --- a/docs/erd.dbml +++ b/docs/erd.dbml @@ -57,6 +57,7 @@ Table invites { team_role text [note: '★ 003: FRONTEND | BACKEND. NULL이면 미지정'] used_by uuid [note: 'NULL이면 미사용'] used_at timestamptz + revoked_at timestamptz [note: '★ 021: 대표가 취소. 취소한 링크로는 가입할 수 없다'] created_at timestamptz [not null, default: `now()`] indexes { @@ -86,6 +87,7 @@ Table agents { org_key text [not null, note: '★ 008: GENERATED. (user_id, org_key)로 users를 참조해 org 불변식을 DB가 강제'] settings_hash text [note: '.claude/settings.json 해시 — 서버 기대값과 대조. policy_hash와 다른 질문에 답한다(아래)'] last_seen_at timestamptz + disconnected_at timestamptz [note: '★ 021: 연결 해제(주인·대표). 토큰 폐기·인증 거부. 같은 사용자·이름으로 다시 connect하면 NULL로 되살아난다'] created_at timestamptz [not null, default: `now()`] indexes { @@ -126,12 +128,13 @@ Table repos { dev_base_url text [note: '★ dev 배포 주소. V1B·통합 테스트가 호출. NULL이면 V1B는 SKIPPED'] clone_url text [note: '012. 서버가 커밋 diff를 읽을 곳(V3). NULL이면 V3는 SKIPPED'] default_branch text [not null, default: 'main'] + disconnected_at timestamptz [note: '★ 021: 조직에서 연결 해제(대표, 진행 중 프로젝트가 쓰지 않을 때만). 다시 연결하면 되살아난다'] created_at timestamptz [not null, default: `now()`] indexes { (org_id, full_name) [unique] } - Note: '삭제·연결해제 API 없음 — repo_paths가 태스크·검증에서 참조됨' + Note: '행은 지우지 않는다(021) — repo_paths·태스크·검증이 참조한다. 연결 해제는 disconnected_at' } Table repo_paths { diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 9da8a11..b37b14f 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -281,8 +281,8 @@ components: expiresAt: { type: string, format: date-time, nullable: true, description: '없는 토큰이면 null' } reason: type: string - enum: [expired, used, not_found] - description: 'valid=false일 때만 있다' + enum: [expired, used, revoked, not_found] + description: 'valid=false일 때만 있다. revoked = 대표가 취소한 초대' SignupResult: type: object required: [userId, connectKey] @@ -395,6 +395,22 @@ components: githubRepoId: { type: integer } defaultBranch: { type: string, description: 'GitHub 조직의 기본 브랜치 설정을 따른다(보통 main)' } cloneUrl: { type: string, description: 'V3가 커밋을 읽을 곳. 자동으로 채워진다' } + GithubRevokeResult: + type: object + required: [repoId, fullName, status] + properties: + repoId: { type: string, format: uuid } + fullName: { type: string } + status: + type: string + enum: [removed, invitation_cancelled, not_collaborator, skipped, failed] + description: | + - removed: 협업자에서 뺐다 + - invitation_cancelled: 아직 수락하지 않은 초대를 취소했다 + - not_collaborator: 원래 협업자가 아니었다 + - skipped: 멤버나 대표가 GitHub를 연결하지 않았다(reason) — GitHub에서 직접 정리 + - failed: GitHub 호출 실패(reason). 해제 자체는 이미 끝났다 + reason: { type: string } GithubInviteResult: type: object required: [repoId, fullName, status] @@ -496,12 +512,23 @@ components: createdAt: { type: string, format: date-time } InviteCreated: type: object - required: [token, url, expiresAt, teamRole] + required: [inviteId, token, url, expiresAt, teamRole] properties: + inviteId: { type: string, format: uuid, description: '취소(DELETE /orgs/{orgId}/invites/{inviteId})할 때 쓴다' } token: { type: string } url: { type: string, description: '{FRONTEND_BASE_URL}/invites/{token}' } expiresAt: { type: string, format: date-time } teamRole: { type: string, enum: [FRONTEND, BACKEND], nullable: true } + PendingInvite: + type: object + required: [inviteId, url, teamRole, expiresAt, expired, createdAt] + properties: + inviteId: { type: string, format: uuid } + url: { type: string, description: '{FRONTEND_BASE_URL}/invites/{token} — 다시 복사할 수 있다' } + teamRole: { type: string, enum: [FRONTEND, BACKEND], nullable: true } + expiresAt: { type: string, format: date-time } + expired: { type: boolean, description: '만료됐으면 true(수락 불가). 취소는 된다' } + createdAt: { type: string, format: date-time } InviteAccepted: type: object required: [userId, orgId] @@ -1542,6 +1569,49 @@ paths: example: error: { code: CROSS_ORG_ACCESS, message: cannot access another organization } + /orgs/{orgId}/members/{userId}: + delete: + tags: [조직] + summary: 조직에서 멤버 내보내기 · 본인 탈퇴 + description: | + 대표가 다른 멤버를 내보내거나, `userId`가 나 자신이면 탈퇴다(021). **계정은 남고 조직만 빠진다** — 다른 조직의 초대를 받을 수 있고, 지난 기록(이벤트·노트·제출)은 그대로 남는다. + 그 사람의 에이전트는 맡은 진행 중 프로젝트에서 빠지고 연결이 해제된다. 진행 중 태스크를 붙잡고 있으면 409 `ACTIVE_TASKS_HELD`. + 대표는 나갈 수도 내보낼 수도 없다(409 `REPRESENTATIVE_CANNOT_LEAVE`). 대표가 아닌 사람이 남을 내보내면 403 `NOT_REPRESENTATIVE`. + 빠진 사람의 화면 스트림(`/api/stream`)은 4403 `NOT_IN_ORG`로 끊긴다. + security: [{ userToken: [] }] + parameters: + - $ref: '#/components/parameters/OrgId' + - { name: userId, in: path, required: true, schema: { type: string, format: uuid } } + responses: + '200': + description: 빠짐. 함께 해제된 에이전트와 빠진 프로젝트 + content: + application/json: + schema: + type: object + required: [data] + properties: + data: + type: object + required: [userId, agentIds, unassignedFrom, githubRevocations] + properties: + userId: { type: string, format: uuid } + agentIds: { type: array, items: { type: string, format: uuid }, description: '연결 해제된 에이전트' } + unassignedFrom: { type: array, items: { type: string, format: uuid }, description: '배정이 풀린 프로젝트' } + githubRevocations: { type: array, items: { $ref: '#/components/schemas/GithubRevokeResult' }, description: '조직의 GitHub 레포 전부에서 접근 회수 결과' } + '404': + description: 이 조직의 멤버가 아님 + content: + application/json: + example: + error: { code: ORG_MEMBER_NOT_FOUND, message: user is not a member of this organization } + '409': + description: 대표 · 진행 중 태스크를 붙잡고 있음 + content: + application/json: + example: + error: { code: REPRESENTATIVE_CANNOT_LEAVE, message: the representative cannot leave or be removed from the organization } + /orgs/{orgId}/agents: get: tags: [조직] @@ -1706,6 +1776,29 @@ paths: example: error: { code: REPO_ALREADY_CONNECTED, message: 'github_repo_id 987654321 is already used by acme/study-web (...)' } + delete: + tags: [레포·경로] + summary: 레포 연결 해제 (대표 전용) + description: | + 조직에서 레포 연결을 끊는다(021). 진행 중(planning·active·halted) 프로젝트가 쓰고 있으면 409 `REPO_IN_ACTIVE_PROJECT`(details에 프로젝트). + GitHub의 레포는 그대로다. 행과 경로 규칙은 지우지 않는다(끝난 프로젝트의 태스크·검증이 가리킨다) — 같은 레포를 다시 연결하면 경로 규칙·소유권과 함께 되살아난다. + 해제된 레포는 목록·프로젝트 생성·경로 API에서 없는 것(404 `REPO_NOT_FOUND`)으로 본다. + security: [{ userToken: [] }] + parameters: + - { name: repoId, in: path, required: true, schema: { type: string, format: uuid } } + responses: + '200': + description: 해제됨 + content: + application/json: + schema: { type: object, required: [data], properties: { data: { type: object, required: [repoId, fullName], properties: { repoId: { type: string, format: uuid }, fullName: { type: string } } } } } + '409': + description: 진행 중 프로젝트가 사용 중 + content: + application/json: + example: + error: { code: REPO_IN_ACTIVE_PROJECT, message: acme/app is used by an active project; finish or abort it first } + /repos/{repoId}/paths: get: tags: [레포·경로] @@ -1848,13 +1941,49 @@ paths: application/json: schema: { type: object, required: [data], properties: { data: { $ref: '#/components/schemas/InviteCreated' } } } example: - data: { token: '{{INVITE_TOKEN}}', url: 'http://localhost:3001/invites/{{INVITE_TOKEN}}', expiresAt: '2026-09-25T00:00:00.000Z', teamRole: BACKEND } + data: { inviteId: 4b0c7c2e-9a3f-4d1e-8c55-1f2a3b4c5d6e, token: '{{INVITE_TOKEN}}', url: 'http://localhost:3001/invites/{{INVITE_TOKEN}}', expiresAt: '2026-09-25T00:00:00.000Z', teamRole: BACKEND } '403': description: 대표가 아님 content: application/json: example: error: { code: NOT_REPRESENTATIVE, message: this action requires the organization representative } + get: + tags: [초대] + summary: 보낸 초대 목록 (대표 전용) + description: '아직 쓰이지도 취소되지도 않은 초대. 만료된 것도 expired=true로 함께 나온다(화면이 만료 표시·취소). 링크를 다시 복사할 때도 쓴다.' + security: [{ userToken: [] }] + parameters: [{ $ref: '#/components/parameters/OrgId' }] + responses: + '200': + description: 초대 목록(최신순) + content: + application/json: + schema: { type: object, required: [data], properties: { data: { type: object, required: [invites], properties: { invites: { type: array, items: { $ref: '#/components/schemas/PendingInvite' } } } } } } + + /orgs/{orgId}/invites/{inviteId}: + delete: + tags: [초대] + summary: 초대 취소 (대표 전용) + description: | + 취소한 링크로는 가입할 수 없다(수락 410 `INVITE_REVOKED`, 미리보기 `reason: revoked`). 이미 쓰인 초대는 취소할 수 없다(404) — + 들어온 사람을 빼려면 멤버 내보내기(`DELETE /orgs/{orgId}/members/{userId}`)를 쓴다. + security: [{ userToken: [] }] + parameters: + - $ref: '#/components/parameters/OrgId' + - { name: inviteId, in: path, required: true, schema: { type: string, format: uuid } } + responses: + '200': + description: 취소됨 + content: + application/json: + schema: { type: object, required: [data], properties: { data: { type: object, required: [inviteId], properties: { inviteId: { type: string, format: uuid } } } } } + '404': + description: 대기 중인 초대가 아님(없음·이미 쓰임·이미 취소·다른 조직) + content: + application/json: + example: + error: { code: INVITE_NOT_FOUND, message: no pending invite } /invites/{token}: get: @@ -3044,7 +3173,7 @@ paths: 응답의 `notice`에도 같은 안내가 들어 있다. 프로젝트당 역할은 **하나씩만** 배정된다(`uq_project_members_role`). 실험 통제를 위해서다. - **G1(`started_at`) 이후에는 멤버를 바꿀 수 없다** — 도중에 바뀌면 통제가 흔들린다. + 시작(G1) 뒤에도 배정할 수 있다(021) — 빠진 역할을 채울 때 쓴다. 끝난 프로젝트(completed·aborted)는 409 `PROJECT_NOT_OPEN`. 연결 해제된 에이전트는 배정할 수 없다(403 `AGENT_NOT_IN_ORG`). **GitHub 협업자 자동 초대**: 배정과 함께 그 에이전트 주인을 프로젝트의 GitHub 레포(`githubRepoId`가 있는 것)마다 push 권한 협업자로 초대한다(대표의 GitHub 토큰). 결과는 `githubInvites`(레포마다 한 줄). 초대가 실패해도 배정은 유효하다 — @@ -3079,14 +3208,12 @@ paths: - { repoId: '{{REPO_API}}', fullName: acme/study-api, status: invited } notice: 배정된 에이전트는 POST /api/agents/token/refresh로 토큰을 재발급해야 태스크·노트 API를 쓸 수 있습니다. '403': - description: 대표가 아님 · 다른 조직의 에이전트 · G1 이후 + description: 대표가 아님 · 다른 조직(또는 연결 해제된) 에이전트 content: application/json: examples: 남의조직에이전트: value: { error: { code: AGENT_NOT_IN_ORG, message: agent does not belong to this organization } } - 시작된프로젝트: - value: { error: { code: PROJECT_STARTED, message: project has started; members are frozen } } '409': description: 역할이 이미 찼거나, 그 에이전트가 이미 배정됐거나, 다른 진행 중 프로젝트를 맡고 있음 content: @@ -3137,13 +3264,49 @@ paths: example: error: { code: MEMBER_NOT_FOUND, message: agent ... is not a member of this project } + /projects/{projectId}/abort: + post: + tags: [프로젝트] + summary: 프로젝트 중단 (대표 전용) + description: | + 진행 중(planning·active·halted)인 프로젝트를 중단한다(021). **지우지 않고 `aborted`로 닫는다** — 이벤트·태스크·노트가 가리킨다. + - 에이전트가 풀린다(다른 프로젝트에 배정 가능). 실행 중인 CLI는 배정이 풀린 것을 보고 다시 배정을 기다린다. + - 레포를 다른 프로젝트에 쓸 수 있게 된다. 대기 질문은 만료로 닫히고, 대기 승인 카드는 대기열에서 빠지며 결정할 수 없다(409 `PROJECT_NOT_OPEN`). + - 룸에 "프로젝트가 중단되었습니다"가 모든 역할에 나온다. GitHub 협업자 권한은 회수하지 않는다(레포와 코드는 팀의 것). + 이미 끝난 프로젝트(completed·aborted)는 409 `PROJECT_NOT_OPEN`. + security: [{ userToken: [] }] + parameters: [{ $ref: '#/components/parameters/ProjectId' }] + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + reason: { type: string, maxLength: 500, nullable: true, description: '룸·기록에 남는 사유(선택)' } + example: + reason: 범위가 바뀌어 다시 계획한다 + responses: + '200': + description: 중단됨 + content: + application/json: + schema: { type: object, required: [data], properties: { data: { type: object, required: [project], properties: { project: { $ref: '#/components/schemas/Project' } } } } } + '409': + description: 이미 끝난 프로젝트 + content: + application/json: + example: + error: { code: PROJECT_NOT_OPEN, message: project is completed; only an ongoing project can be aborted } + /projects/{projectId}/members/{agentId}: delete: tags: [프로젝트] summary: 배정 해제 (대표 전용) description: | **역할 교체는 해제 후 재배정으로 한다.** UPDATE 경로를 두면 한 역할에 둘이 잠깐 겹칠 수 있다. - G1(`started_at`) 이후에는 해제도 막힌다. + 시작(G1) 뒤에도 된다(021) — 단 그 에이전트가 진행 중 태스크(CLAIMED·IN_PROGRESS·VERIFYING)를 붙잡고 있으면 409 `ACTIVE_TASKS_HELD`(details에 태스크 목록). + 빠진 역할의 태스크는 새 사람을 배정할 때까지 READY로 기다린다. 끝난 프로젝트(completed·aborted)는 409 `PROJECT_NOT_OPEN`. security: [{ userToken: [] }] parameters: - $ref: '#/components/parameters/ProjectId' @@ -3154,18 +3317,24 @@ paths: example: '{{AGENT_BE}}' responses: '200': - description: 해제됨. 남은 멤버 목록을 준다. + description: 해제됨. 남은 멤버 목록과, 빠진 사람의 GitHub 레포 접근 회수 결과 content: application/json: - schema: { type: object, required: [data], properties: { data: { type: object, required: [members], properties: { members: { type: array, items: { $ref: '#/components/schemas/ProjectMember' } } } } } } + schema: { type: object, required: [data], properties: { data: { type: object, required: [members, githubRevocations], properties: { members: { type: array, items: { $ref: '#/components/schemas/ProjectMember' } }, githubRevocations: { type: array, items: { $ref: '#/components/schemas/GithubRevokeResult' }, description: '빠진 사람의 GitHub 레포 접근 회수 결과(대표 토큰). 대표 본인·GitHub 레포가 없으면 빈 배열' } } } } } example: - data: { members: [] } + data: { members: [], githubRevocations: [] } '403': - description: 대표가 아니거나 G1 이후 + description: 대표가 아님 + content: + application/json: + example: + error: { code: NOT_REPRESENTATIVE, message: this action requires the organization representative } + '409': + description: 진행 중 태스크를 붙잡고 있음 · 끝난 프로젝트 content: application/json: example: - error: { code: PROJECT_STARTED, message: project has started; members are frozen } + error: { code: ACTIVE_TASKS_HELD, message: agent still holds 1 task(s) in progress; finish or stop them first } '404': description: 배정되지 않은 에이전트 content: @@ -3173,6 +3342,48 @@ paths: example: error: { code: MEMBER_NOT_FOUND, message: agent is not a member of this project } + /agents/{agentId}: + delete: + tags: [CLI 연결] + summary: 에이전트 연결 해제 + description: | + 노트북 CLI 연결을 끊는다(021) — 그 에이전트의 주인이나 같은 조직의 대표가 한다(아니면 403 `NOT_AGENT_OWNER`). + 맡은 진행 중 프로젝트에서 빠지고(진행 중 태스크가 있으면 409 `ACTIVE_TASKS_HELD`), refresh token이 폐기되며, 남은 access token도 401 `AGENT_DISCONNECTED`로 거부된다. + 실행 중인 CLI의 태스크 스트림도 끊긴다. 목록(`GET /orgs/{orgId}/agents`)에서 빠진다. + 같은 노트북에서 같은 이름으로 다시 `connect`하면 그 에이전트가 되살아난다. + security: [{ userToken: [] }] + parameters: + - { name: agentId, in: path, required: true, schema: { type: string, format: uuid }, example: '{{AGENT_BE}}' } + responses: + '200': + description: 해제됨 + content: + application/json: + schema: + type: object + required: [data] + properties: + data: + type: object + required: [agentId, disconnectedAt, unassignedFrom, githubRevocations] + properties: + agentId: { type: string, format: uuid } + disconnectedAt: { type: string, format: date-time } + unassignedFrom: { type: string, format: uuid, nullable: true, description: '배정이 풀린 프로젝트(없으면 null)' } + githubRevocations: { type: array, items: { $ref: '#/components/schemas/GithubRevokeResult' }, description: '빠진 사람의 GitHub 레포 접근 회수 결과(대표 토큰). 대표 본인·GitHub 레포가 없으면 빈 배열' } + '403': + description: 주인도 대표도 아님 + content: + application/json: + example: + error: { code: NOT_AGENT_OWNER, message: only the agent owner or the representative can disconnect it } + '404': + description: 없거나 이미 해제된 에이전트 + content: + application/json: + example: + error: { code: AGENT_NOT_FOUND, message: agent not found } + /agents/me: get: tags: [CLI 연결] diff --git a/migrations/021_removals.sql b/migrations/021_removals.sql new file mode 100644 index 0000000..1d084cf --- /dev/null +++ b/migrations/021_removals.sql @@ -0,0 +1,18 @@ +-- Up Migration + +-- 해제·삭제(대표 결정 2026-10-14). 행은 지우지 않는다 — events·tasks·노트가 이 행들을 가리키고(P5 기록이 끊긴다), 다시 연결할 수 있어야 한다. +-- agents.disconnected_at — 에이전트 연결 해제(주인·대표). refresh token을 모두 폐기하고, 남은 access token은 인증에서 거부한다. +-- 같은 노트북(같은 사용자·같은 이름)으로 다시 connect하면 그 행이 되살아난다(upsert가 비운다). +-- repos.disconnected_at — 조직에서 레포 연결 해제(대표, 진행 중 프로젝트가 쓰지 않을 때만). 경로 규칙은 남는다 — 같은 레포를 다시 연결하면 되살아난다. +-- invites.revoked_at — 초대 취소(대표). 취소한 링크로는 가입할 수 없다. +-- 조직에서 빠진 사람은 users.org_id를 NULL로 되돌린다(계정은 유지, 다른 조직에 다시 들어갈 수 있다) — 새 컬럼이 필요 없다. + +ALTER TABLE agents ADD COLUMN disconnected_at timestamptz; +ALTER TABLE repos ADD COLUMN disconnected_at timestamptz; +ALTER TABLE invites ADD COLUMN revoked_at timestamptz; + +-- Down Migration + +ALTER TABLE invites DROP COLUMN revoked_at; +ALTER TABLE repos DROP COLUMN disconnected_at; +ALTER TABLE agents DROP COLUMN disconnected_at; diff --git a/packages/cli/package.json b/packages/cli/package.json index fe9ac84..c27012b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@capstone-nomos/cli", - "version": "0.1.9", + "version": "0.1.10", "description": "NOMOS 에이전트 CLI — 노트북의 Claude Code를 NOMOS 서버에 연결하고 배정된 태스크를 실행한다", "type": "module", "bin": { diff --git a/src/domain/agent/repository.ts b/src/domain/agent/repository.ts index e111c72..efea4c2 100644 --- a/src/domain/agent/repository.ts +++ b/src/domain/agent/repository.ts @@ -14,6 +14,8 @@ export type Agent = { maxConcurrent: number; status: AgentStatus; lastSeenAt: string | null; + // 연결 해제 시각(021). 있으면 토큰이 통하지 않고 목록에 나오지 않는다. 같은 이름으로 다시 connect하면 NULL로 되살아난다. + disconnectedAt: string | null; createdAt: string; }; @@ -28,6 +30,7 @@ function toAgent(row: QueryResultRow): Agent { maxConcurrent: row.max_concurrent, status: row.status, lastSeenAt: row.last_seen_at, + disconnectedAt: row.disconnected_at ?? null, createdAt: row.created_at, }; } @@ -53,7 +56,8 @@ export async function upsertAgent( SET org_id = EXCLUDED.org_id, harness = EXCLUDED.harness, skills = EXCLUDED.skills, - max_concurrent = EXCLUDED.max_concurrent + max_concurrent = EXCLUDED.max_concurrent, + disconnected_at = NULL RETURNING *, (xmax = 0) AS inserted`, [input.userId, input.orgId, input.name, input.harness, input.skills, input.maxConcurrent], ); @@ -80,7 +84,8 @@ export async function findAgentByActiveRefreshToken(db: Queryable, tokenHash: st WHERE t.token_hash = $1 AND t.kind = 'refresh' AND t.revoked_at IS NULL - AND t.expires_at > now()`, + AND t.expires_at > now() + AND a.disconnected_at IS NULL`, [tokenHash], ); const row = rows[0]; @@ -130,7 +135,7 @@ export async function listAgentsByOrg(db: Queryable, orgId: string): Promise { + await db.query(`UPDATE agent_tokens SET revoked_at = now() WHERE agent_id = $1 AND revoked_at IS NULL`, [agentId]); +} + +// 이미 해제된 에이전트면 null — 두 번 눌러도 이벤트가 한 번만 남는다. +export async function markAgentDisconnected(db: Queryable, agentId: string): Promise { + const { rows } = await db.query( + `UPDATE agents SET disconnected_at = now() WHERE id = $1 AND disconnected_at IS NULL RETURNING *`, + [agentId], + ); + return rows[0] ? toAgent(rows[0]) : null; +} + +// 조직에서 빠지는 사용자의 에이전트(연결된 것만). +export async function listConnectedAgentsOfUser(db: Queryable, userId: string): Promise { + const { rows } = await db.query(`SELECT * FROM agents WHERE user_id = $1 AND disconnected_at IS NULL ORDER BY created_at`, [userId]); + return rows.map(toAgent); +} + +// 조직에서 빠질 때 에이전트도 조직 밖으로(불변식 agents.org_id = users.org_id, 008의 FK가 커밋 시점에 본다). +export async function detachUserAgentsFromOrg(db: Queryable, userId: string): Promise { + await db.query(`UPDATE agents SET org_id = NULL WHERE user_id = $1`, [userId]); +} diff --git a/src/domain/agent/service.ts b/src/domain/agent/service.ts index e417801..3e66d2b 100644 --- a/src/domain/agent/service.ts +++ b/src/domain/agent/service.ts @@ -8,11 +8,17 @@ import { generateSecret, hashSecret, signJwt } from '../../utils/tokens.js'; import { findAgentMembership, findProjectAuthRow } from '../policy/repository.js'; import { appendEvent } from '../events/append.js'; import { findUserByConnectKeyHash } from '../org/repository.js'; +import { agentRevoked } from '../dispatch/agent-revoked.js'; +import { tasksChanged } from '../dispatch/tasks-changed.js'; +import type { GithubRevokeResult } from '../events/types.js'; +import { detachAgentFromActiveProject, revokeGithubAccess } from '../project/service.js'; import { findAgentByActiveRefreshToken, findAgentById, insertRefreshToken, listAgentsByOrg, + markAgentDisconnected, + revokeAgentTokens, upsertAgent, type Agent, } from './repository.js'; @@ -176,3 +182,52 @@ export async function listOrgAgents(orgId: string): Promise { export async function agentOrgId(agentId: string): Promise { return (await findAgentById(pool, agentId))?.orgId ?? null; } + +// ── 연결 해제(021) ─────────────────────────────────────────────────────── + +// 에이전트 연결 해제 — 그 에이전트의 주인이나 같은 조직의 대표가 한다(대표 결정). +// - 맡은 진행 중 프로젝트에서 빠진다. 진행 중 태스크를 붙잡고 있으면 409 ACTIVE_TASKS_HELD(멤버 해제와 같은 규칙). +// - refresh token을 전부 폐기하고 disconnected_at을 찍는다. 남은 access token은 인증이 거부한다(AGENT_DISCONNECTED). +// - 행은 지우지 않는다(이벤트·태스크가 가리킨다). 같은 노트북에서 같은 이름으로 다시 connect하면 되살아난다. +export async function disconnectAgent( + actor: { userId: string; orgId: string | null; orgRole: string | null }, + agentId: string, +): Promise<{ agentId: string; disconnectedAt: string; unassignedFrom: string | null; githubRevocations: GithubRevokeResult[] }> { + const agent = await findAgentById(pool, agentId); + if (!agent || agent.disconnectedAt !== null) throw new AppError('AGENT_NOT_FOUND', `agent ${agentId} not found`); + const byOwner = agent.userId === actor.userId; + if (!byOwner && !(actor.orgRole === 'REPRESENTATIVE' && actor.orgId !== null && agent.orgId === actor.orgId)) { + throw new AppError('NOT_AGENT_OWNER', 'only the agent owner or the representative can disconnect it'); + } + const result = await withTransaction(async (tx) => { + const unassignedFrom = await detachAgentFromActiveProject(tx, agentId, actor.userId, 'agent_disconnected'); + const marked = await markAgentDisconnected(tx, agentId); + if (!marked) throw new AppError('AGENT_NOT_FOUND', `agent ${agentId} not found`); + await revokeAgentTokens(tx, agentId); + await appendEvent(tx, { + orgId: agent.orgId, + type: 'AGENT_DISCONNECTED', + actorAgentId: agentId, + onBehalfOf: actor.userId, + payload: { agentId, userId: agent.userId, name: agent.name, byOwner, unassignedFrom }, + }); + return { agentId, disconnectedAt: marked.disconnectedAt!, unassignedFrom }; + }); + // 커밋 뒤 — 빠진 프로젝트의 태스크 스냅샷을 다시 계산하고, 그 에이전트의 스트림을 끊는다. + if (result.unassignedFrom) tasksChanged(result.unassignedFrom); + agentRevoked(agentId); + // 배정이 풀렸으면 그 프로젝트의 GitHub 레포 접근도 회수한다(커밋 뒤 — 실패해도 해제는 그대로). + const githubRevocations = + result.unassignedFrom && agent.orgId + ? await revokeGithubAccess({ + orgId: agent.orgId, + onBehalfOf: actor.userId, + projectId: result.unassignedFrom, + agentId, + userId: agent.userId, + scope: { kind: 'project', projectId: result.unassignedFrom }, + trigger: 'agent_disconnected', + }) + : []; + return { ...result, githubRevocations }; +} diff --git a/src/domain/approval/repository.ts b/src/domain/approval/repository.ts index 350787f..3d4e1ad 100644 --- a/src/domain/approval/repository.ts +++ b/src/domain/approval/repository.ts @@ -116,7 +116,7 @@ export async function listApprovals( LEFT JOIN tasks t ON t.id = a.subject_id WHERE ($1::uuid IS NULL OR p.org_id = $1) AND ($2::uuid IS NULL OR a.project_id = $2) - AND ($3 = 'all' OR ($3 = 'pending' AND a.decision IS NULL) OR ($3 = 'decided' AND a.decision IS NOT NULL)) + AND ($3 = 'all' OR ($3 = 'pending' AND a.decision IS NULL AND p.status <> 'aborted') OR ($3 = 'decided' AND a.decision IS NOT NULL)) ORDER BY a.requested_at DESC, a.id LIMIT $4`, [filter.orgId ?? null, filter.projectId ?? null, filter.status, filter.limit], diff --git a/src/domain/approval/service.ts b/src/domain/approval/service.ts index b726261..56c5a6e 100644 --- a/src/domain/approval/service.ts +++ b/src/domain/approval/service.ts @@ -135,6 +135,9 @@ export async function decide( if (approval.decision !== null) { throw new AppError('APPROVAL_ALREADY_DECIDED', `approval was already decided (${approval.decision})`); } + // 중단된 프로젝트(021)의 카드는 결정하지 않는다 — 대기열에서도 빠진다(listApprovals). + const owning = await findProjectById(tx, approval.projectId); + if (owning?.status === 'aborted') throw new AppError('PROJECT_NOT_OPEN', 'the project was aborted'); if (approval.gate === 'G3') { // 승인 = 통합 확인 끝 → 프로젝트 완료. 반려 = 진행 중 그대로(고칠 태스크는 대표가 만든다 — 다시 전부 DONE이 되면 새 카드). if (!(await decideApproval(tx, approval.id, decision, actor.userId, reason))) { diff --git a/src/domain/dispatch/agent-revoked.ts b/src/domain/dispatch/agent-revoked.ts new file mode 100644 index 0000000..4dc7f76 --- /dev/null +++ b/src/domain/dispatch/agent-revoked.ts @@ -0,0 +1,22 @@ +// "이 에이전트는 더 이상 쓸 수 없다"는 신호(연결 해제·조직 탈퇴, 021). +// 도메인은 웹소켓을 모른다 — 여기에 알리기만 하고, realtime/agent-stream.ts가 구독해 그 에이전트의 스트림을 끊는다. +// **커밋한 뒤에** 부른다. 놓쳐도 남은 연결은 다음 요청에서 인증이 거부한다(AGENT_DISCONNECTED). + +type Listener = (agentId: string) => void; + +const listeners = new Set(); + +export function onAgentRevoked(listener: Listener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function agentRevoked(agentId: string): void { + for (const listener of listeners) { + try { + listener(agentId); + } catch { + // 알림 실패가 해제를 되돌리면 안 된다 — 이미 커밋됐다. + } + } +} diff --git a/src/domain/events/types.ts b/src/domain/events/types.ts index 73ddb0d..eb78c82 100644 --- a/src/domain/events/types.ts +++ b/src/domain/events/types.ts @@ -1,4 +1,10 @@ export type EventType = + | 'AGENT_DISCONNECTED' + | 'GITHUB_COLLABORATORS_REMOVED' + | 'PROJECT_ABORTED' + | 'ORG_MEMBER_REMOVED' + | 'REPO_DISCONNECTED' + | 'INVITE_REVOKED' | 'USER_SIGNED_UP' | 'CONNECT_KEY_ROTATED' | 'AGENT_CONNECTED' @@ -83,6 +89,8 @@ export type RepoConnectedPayload = { seededPathCount: number; // NOMOS가 GitHub 조직에 직접 만든 레포일 때만 있다(POST /orgs/:orgId/github/repos). 이미 있던 레포를 연결하면 없다. createdOnGithub?: { githubOrg: string; githubRepoId: number }; + // 해제했던 레포를 다시 연결해 예전 행·경로 규칙을 되살렸다(021). 이때 seededPathCount는 0이다. + revived?: boolean; }; export type RepoPathUpdatedPayload = { @@ -313,8 +321,34 @@ export type MemberAssignedPayload = { export type MemberUnassignedPayload = { agentId: string; teamRole: string; + // 무엇 때문에 빠졌나(021) — 없으면 대표의 직접 해제. + cause?: 'agent_disconnected' | 'org_member_removed'; }; +// 021 해제·삭제. 행은 지우지 않고 표시만 한다(되살릴 수 있고, 기록이 가리킨다). +export type AgentDisconnectedPayload = { agentId: string; userId: string; name: string; byOwner: boolean; unassignedFrom: string | null }; +export type OrgMemberRemovedPayload = { userId: string; self: boolean; agentIds: string[]; unassignedFrom: string[] }; +export type RepoDisconnectedPayload = { repoId: string; fullName: string }; +export type InviteRevokedPayload = { inviteId: string }; + +// 빠진 사람의 GitHub 레포 접근 회수(021) — 대표의 GitHub 토큰으로. 실패해도 해제는 그대로다(결과만 남긴다). +export type GithubRevokeResult = { + repoId: string; + fullName: string; + status: 'removed' | 'invitation_cancelled' | 'not_collaborator' | 'skipped' | 'failed'; + reason?: string; +}; +export type GithubCollaboratorsRemovedPayload = { + agentId: string | null; + userId: string; + githubLogin: string | null; + trigger: 'unassign' | 'agent_disconnected' | 'org_member_removed'; + results: GithubRevokeResult[]; +}; + +// 프로젝트 중단(021). 지우지 않고 aborted로 닫는다 — 이벤트·태스크·노트가 가리킨다. +export type ProjectAbortedPayload = { previousStatus: string; reason: string | null; openTaskCount: number; expiredQuestionIds: string[] }; + // 역할 배정(또는 재초대) 때 에이전트 주인을 프로젝트의 GitHub 레포에 협업자로 초대한 결과. 레포마다 한 줄. // 실패해도 배정은 그대로다 — GitHub 사정이 우리 기능을 멈추지 않는다. 토큰은 넣지 않는다. export type GithubCollaboratorsInvitedPayload = { @@ -526,6 +560,12 @@ export type EventPayloadMap = { QUESTION_DRAFTED: QuestionDraftedPayload; TASK_BLOCKED_ON_QUESTION: TaskBlockedOnQuestionPayload; MEMBER_UNASSIGNED: MemberUnassignedPayload; + AGENT_DISCONNECTED: AgentDisconnectedPayload; + ORG_MEMBER_REMOVED: OrgMemberRemovedPayload; + REPO_DISCONNECTED: RepoDisconnectedPayload; + INVITE_REVOKED: InviteRevokedPayload; + GITHUB_COLLABORATORS_REMOVED: GithubCollaboratorsRemovedPayload; + PROJECT_ABORTED: ProjectAbortedPayload; GITHUB_COLLABORATORS_INVITED: GithubCollaboratorsInvitedPayload; TASK_DISPATCHED: TaskDispatchedPayload; AGENT_RUN_STARTED: AgentRunStartedPayload; diff --git a/src/domain/github/rep-api.ts b/src/domain/github/rep-api.ts index abe3bf2..05cba9e 100644 --- a/src/domain/github/rep-api.ts +++ b/src/domain/github/rep-api.ts @@ -16,6 +16,8 @@ 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'; +// 회수 결과(021): 협업자에서 뺐다 · 아직 수락 안 한 초대를 취소했다 · 원래 협업자가 아니었다. +export type RevokeOutcome = 'removed' | 'invitation_cancelled' | 'not_collaborator'; export type GithubRepApi = { listOrgs(token: string): Promise; @@ -26,6 +28,8 @@ export type GithubRepApi = { 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; + // 프로젝트·조직에서 빠진 사람의 접근을 회수한다(021) — 대기 중 초대가 있으면 그걸 취소하고, 아니면 협업자에서 뺀다. + revokeCollaborator(token: string, fullName: string, githubLogin: string): Promise; }; type FetchLike = (url: string, init?: RequestInit) => Promise; @@ -154,6 +158,23 @@ export function createGithubRepApi(options: { fetchImpl?: FetchLike; apiBase?: s if (res.status === 204) return 'already_collaborator'; throw await failure(res, `협업자 초대(${fullName} ← ${githubLogin})`); }, + + async revokeCollaborator(token, fullName, githubLogin) { + // 초대를 수락하지 않았으면 아직 협업자가 아니다 — 초대를 취소해야 나중에 수락해도 들어오지 못한다. + const list = await call(token, 'GET', `/repos/${fullName}/invitations?per_page=100`); + if (!list.ok) throw await failure(list, `초대 목록(${fullName})`); + const invitations = await json>(list); + const pending = invitations.find((i) => i.invitee?.login.toLowerCase() === githubLogin.toLowerCase()); + if (pending) { + const cancelled = await call(token, 'DELETE', `/repos/${fullName}/invitations/${pending.id}`); + if (cancelled.status === 204) return 'invitation_cancelled'; + throw await failure(cancelled, `초대 취소(${fullName} ← ${githubLogin})`); + } + const res = await call(token, 'DELETE', `/repos/${fullName}/collaborators/${encodeURIComponent(githubLogin)}`); + if (res.status === 204) return 'removed'; + if (res.status === 404) return 'not_collaborator'; + throw await failure(res, `협업자 회수(${fullName} ← ${githubLogin})`); + }, }; } diff --git a/src/domain/invite/repository.ts b/src/domain/invite/repository.ts index a374398..c4f5f16 100644 --- a/src/domain/invite/repository.ts +++ b/src/domain/invite/repository.ts @@ -12,6 +12,8 @@ export type Invite = { expiresAt: string; usedBy: UserId | null; usedAt: string | null; + // 대표가 취소한 시각(021). 있으면 가입할 수 없다. + revokedAt: string | null; createdAt: string; }; @@ -25,6 +27,7 @@ function toInvite(row: QueryResultRow): Invite { expiresAt: row.expires_at, usedBy: row.used_by === null ? null : asUserId(row.used_by), usedAt: row.used_at, + revokedAt: row.revoked_at ?? null, createdAt: row.created_at, }; } @@ -66,3 +69,21 @@ export async function markInviteUsed(db: Queryable, inviteId: string, usedBy: st const row = rows[0]; return row ? toInvite(row) : null; } + +// 아직 쓰이지도 취소되지도 않은 초대(021) — 대표의 "보낸 초대" 목록. 만료된 것도 포함한다(화면이 만료로 표시하고 취소할 수 있게). +export async function listPendingInvites(db: Queryable, orgId: string): Promise { + const { rows } = await db.query( + `SELECT * FROM invites WHERE org_id = $1 AND used_at IS NULL AND revoked_at IS NULL ORDER BY created_at DESC`, + [orgId], + ); + return rows.map(toInvite); +} + +// 쓰이지 않은 초대만 취소된다 — 이미 수락한 초대를 취소해도 가입은 되돌리지 않는다(조직에서 빼는 건 멤버 내보내기). +export async function revokePendingInvite(db: Queryable, orgId: string, inviteId: string): Promise { + const { rowCount } = await db.query( + `UPDATE invites SET revoked_at = now() WHERE id = $1 AND org_id = $2 AND used_at IS NULL AND revoked_at IS NULL`, + [inviteId, orgId], + ); + return (rowCount ?? 0) > 0; +} diff --git a/src/domain/invite/service.ts b/src/domain/invite/service.ts index 247727b..b8acef7 100644 --- a/src/domain/invite/service.ts +++ b/src/domain/invite/service.ts @@ -6,11 +6,11 @@ import { assignAgentsToOrg } from '../agent/repository.js'; import { appendEvent } from '../events/append.js'; import { assignUserToOrg, findOrganizationById, findUserById } from '../org/repository.js'; import type { TeamRole } from '../roles.js'; -import { findInviteByToken, insertInvite, markInviteUsed } from './repository.js'; +import { findInviteByToken, insertInvite, listPendingInvites, markInviteUsed, revokePendingInvite } from './repository.js'; const DEFAULT_EXPIRES_IN_DAYS = 7; -export type CreateInviteResult = { token: string; url: string; expiresAt: string; teamRole: TeamRole | null }; +export type CreateInviteResult = { inviteId: string; token: string; url: string; expiresAt: string; teamRole: TeamRole | null }; export async function createInvite( orgId: string, @@ -34,6 +34,7 @@ export async function createInvite( }); return { + inviteId, token, // 사람이 여는 링크이므로 프론트 주소다. 프론트의 /invites/:token 화면이 GET /api/invites/:token으로 // 미리보기를 그리고, 로그인 후 POST .../accept를 부른다. @@ -49,7 +50,7 @@ export type InvitePreview = { teamRole: TeamRole | null; // 없는 토큰이면 null(존재 여부를 숨긴다). 만료·사용된 초대도 원래 만료 시각을 준다. expiresAt: string | null; - reason?: 'expired' | 'used' | 'not_found'; + reason?: 'expired' | 'used' | 'revoked' | 'not_found'; }; // 존재하지 않는 토큰도 404가 아니라 valid:false로 응답해 토큰 존재 여부를 숨긴다. @@ -67,6 +68,9 @@ export async function previewInvite(token: string): Promise { if (invite.usedBy !== null) { return { orgName, valid: false, teamRole, expiresAt, reason: 'used' }; } + if (invite.revokedAt !== null) { + return { orgName, valid: false, teamRole, expiresAt, reason: 'revoked' }; + } if (new Date(invite.expiresAt).getTime() < Date.now()) { return { orgName, valid: false, teamRole, expiresAt, reason: 'expired' }; } @@ -96,6 +100,9 @@ export async function acceptInvite(token: string, userId: string): Promise { + const invites = await listPendingInvites(pool, orgId); + const now = Date.now(); + return invites.map((i) => ({ + inviteId: i.id, + url: `${env.FRONTEND_BASE_URL}/invites/${i.token}`, + teamRole: i.teamRole, + expiresAt: new Date(i.expiresAt).toISOString(), + expired: new Date(i.expiresAt).getTime() < now, + createdAt: new Date(i.createdAt).toISOString(), + })); +} + +// 초대 취소(대표, 021). 쓰인 초대·이미 취소한 초대·다른 조직 초대는 404 — 가입을 되돌리는 건 멤버 내보내기다. +export async function revokeInvite(orgId: string, actorUserId: string, inviteId: string): Promise<{ inviteId: string }> { + return withTransaction(async (tx) => { + if (!(await revokePendingInvite(tx, orgId, inviteId))) { + throw new AppError('INVITE_NOT_FOUND', `no pending invite ${inviteId}`); + } + await appendEvent(tx, { orgId, type: 'INVITE_REVOKED', onBehalfOf: actorUserId, payload: { inviteId } }); + return { inviteId }; + }); +} diff --git a/src/domain/org/repository.ts b/src/domain/org/repository.ts index 1c41db1..4505bf0 100644 --- a/src/domain/org/repository.ts +++ b/src/domain/org/repository.ts @@ -178,3 +178,13 @@ export async function listUsersByOrg(db: Queryable, orgId: string): Promise { + const { rowCount } = await db.query( + `UPDATE users SET org_id = NULL, org_role = 'MEMBER' WHERE id = $1 AND org_id = $2`, + [userId, orgId], + ); + return (rowCount ?? 0) > 0; +} diff --git a/src/domain/org/service.ts b/src/domain/org/service.ts index d3e49c9..91eacd6 100644 --- a/src/domain/org/service.ts +++ b/src/domain/org/service.ts @@ -1,7 +1,17 @@ import { randomUUID } from 'node:crypto'; import { pool, withTransaction } from '../../config/db.js'; import { AppError } from '../../errors.js'; -import { assignAgentsToOrg } from '../agent/repository.js'; +import { + assignAgentsToOrg, + detachUserAgentsFromOrg, + listConnectedAgentsOfUser, + markAgentDisconnected, + revokeAgentTokens, +} from '../agent/repository.js'; +import { agentRevoked } from '../dispatch/agent-revoked.js'; +import { tasksChanged } from '../dispatch/tasks-changed.js'; +import type { GithubRevokeResult } from '../events/types.js'; +import { detachAgentFromActiveProject, revokeGithubAccess } from '../project/service.js'; import { appendEvent } from '../events/append.js'; import { logger } from '../../config/logger.js'; import { githubRepApi, type GithubRepoInfo } from '../github/rep-api.js'; @@ -13,6 +23,7 @@ import { findUserById, insertOrganization, listUsersByOrg, + removeUserFromOrg, } from './repository.js'; export type CreateOrgResult = { @@ -140,3 +151,58 @@ export async function getMe(userId: string): Promise { orgRole: user.orgId === null ? null : user.orgRole, }; } + +// 조직에서 멤버를 뺀다(021). 대표가 다른 멤버를 내보내거나, 멤버 본인이 나간다. 대표는 나갈 수 없다(조직에 대표가 없어진다). +// - 계정은 남고 조직만 비운다(대표 결정) — 다른 조직의 초대를 받을 수 있다. 지난 기록(이벤트·노트·제출)은 그대로 남는다. +// - 그 사람의 에이전트는 맡은 진행 중 프로젝트에서 빠지고 연결이 해제된다. 진행 중 태스크를 붙잡고 있으면 409 ACTIVE_TASKS_HELD. +export async function removeOrgMember( + actor: { userId: string; orgId: string; orgRole: string }, + orgId: string, + userId: string, +): Promise<{ userId: string; agentIds: string[]; unassignedFrom: string[]; githubRevocations: GithubRevokeResult[] }> { + const self = actor.userId === userId; + if (!self && actor.orgRole !== 'REPRESENTATIVE') { + throw new AppError('NOT_REPRESENTATIVE', 'only the representative can remove other members'); + } + const target = await findUserById(pool, userId); + if (!target || target.orgId !== orgId) throw new AppError('ORG_MEMBER_NOT_FOUND', `user ${userId} is not a member of this organization`); + if (target.orgRole === 'REPRESENTATIVE') { + throw new AppError('REPRESENTATIVE_CANNOT_LEAVE', 'the representative cannot leave or be removed from the organization'); + } + + const result = await withTransaction(async (tx) => { + const agents = await listConnectedAgentsOfUser(tx, userId); + const unassignedFrom: string[] = []; + for (const agent of agents) { + const projectId = await detachAgentFromActiveProject(tx, agent.id, actor.userId, 'org_member_removed'); + if (projectId) unassignedFrom.push(projectId); + await markAgentDisconnected(tx, agent.id); + await revokeAgentTokens(tx, agent.id); + } + await detachUserAgentsFromOrg(tx, userId); + if (!(await removeUserFromOrg(tx, userId, orgId))) { + throw new AppError('ORG_MEMBER_NOT_FOUND', `user ${userId} is not a member of this organization`); + } + const agentIds = agents.map((a) => a.id as string); + await appendEvent(tx, { + orgId, + type: 'ORG_MEMBER_REMOVED', + onBehalfOf: actor.userId, + payload: { userId, self, agentIds, unassignedFrom }, + }); + return { userId, agentIds, unassignedFrom }; + }); + for (const projectId of result.unassignedFrom) tasksChanged(projectId); + for (const agentId of result.agentIds) agentRevoked(agentId); + // 조직의 GitHub 레포 전부에서 접근을 회수한다(커밋 뒤 — 실패해도 탈퇴는 그대로). + const githubRevocations = await revokeGithubAccess({ + orgId, + onBehalfOf: actor.userId, + projectId: null, + agentId: null, + userId, + scope: { kind: 'org' }, + trigger: 'org_member_removed', + }); + return { ...result, githubRevocations }; +} diff --git a/src/domain/project/repository.ts b/src/domain/project/repository.ts index 80156fd..9cb77e8 100644 --- a/src/domain/project/repository.ts +++ b/src/domain/project/repository.ts @@ -182,7 +182,7 @@ export type RepoRow = { id: string; orgId: string; fullName: string }; export async function findReposByIds(db: Queryable, repoIds: string[]): Promise { const { rows } = await db.query( - `SELECT id, org_id, full_name FROM repos WHERE id = ANY($1::uuid[])`, + `SELECT id, org_id, full_name FROM repos WHERE id = ANY($1::uuid[]) AND disconnected_at IS NULL`, [repoIds], ); return rows.map((r) => ({ id: r.id, orgId: r.org_id, fullName: r.full_name })); @@ -282,8 +282,9 @@ export async function deleteProjectMember( return rows[0]?.team_role ?? null; } +// 연결 해제된 에이전트(021)는 없는 것으로 본다 — 배정할 수 없다. export async function findAgentOrg(db: Queryable, agentId: string): Promise { - const { rows } = await db.query(`SELECT org_id FROM agents WHERE id = $1`, [agentId]); + const { rows } = await db.query(`SELECT org_id FROM agents WHERE id = $1 AND disconnected_at IS NULL`, [agentId]); return rows.length === 0 ? undefined : (rows[0]!.org_id as string | null); } @@ -397,3 +398,70 @@ export async function updateQuestionRelay(db: Queryable, projectId: string, enab const { rows } = await db.query(`UPDATE projects SET question_relay = $2 WHERE id = $1 RETURNING *`, [projectId, enabled]); return toProject(rows[0]!); } + +// 그 에이전트가 이 프로젝트에서 붙잡고 있는 진행 중 태스크(021) — 있으면 멤버에서 뺄 수 없다(대표 결정). +// VERIFYING도 넣는다: 제출 뒤 V2·V4는 그 에이전트의 Executor가 보고해야 결론이 난다. +export async function listActiveTasksOfAgent( + db: Queryable, + projectId: string, + agentId: string, +): Promise<{ taskId: string; title: string; state: string }[]> { + const { rows } = await db.query( + `SELECT id, title, state FROM tasks + WHERE project_id = $1 AND assignee_agent_id = $2 AND state IN ('CLAIMED', 'IN_PROGRESS', 'VERIFYING') + ORDER BY created_at`, + [projectId, agentId], + ); + return rows.map((r) => ({ taskId: r.id as string, title: r.title as string, state: r.state as string })); +} + +// ── GitHub 접근 회수·프로젝트 중단(021) ───────────────────────────────── + +// 조직의 GitHub 레포(연결된 것만) — 조직에서 빠진 사람의 접근을 전부 회수할 때. +export async function listOrgGithubRepos(db: Queryable, orgId: string): Promise { + const { rows } = await db.query( + `SELECT id, org_id, full_name FROM repos + WHERE org_id = $1 AND github_repo_id IS NOT NULL AND disconnected_at IS NULL + ORDER BY full_name`, + [orgId], + ); + return rows.map((r) => ({ id: r.id, orgId: r.org_id, fullName: r.full_name })); +} + +export async function findUserGithubLogin(db: Queryable, userId: string): Promise { + const { rows } = await db.query(`SELECT github_login FROM users WHERE id = $1`, [userId]); + return (rows[0]?.github_login as string | null | undefined) ?? null; +} + +// 그 사람의 다른 에이전트가 아직 이 프로젝트 멤버인가 — 그러면 레포 접근을 회수하지 않는다(한 사람이 두 역할을 맡은 경우). +export async function userStillInProject(db: Queryable, projectId: string, userId: string): Promise { + const { rows } = await db.query( + `SELECT 1 FROM project_members m JOIN agents a ON a.id = m.agent_id WHERE m.project_id = $1 AND a.user_id = $2 LIMIT 1`, + [projectId, userId], + ); + return rows.length > 0; +} + +// 진행 중(planning·active·halted)인 프로젝트만 중단된다. 아니면 null. +export async function markProjectAborted(db: Queryable, projectId: string): Promise { + const { rows } = await db.query( + `UPDATE projects SET status = 'aborted' WHERE id = $1 AND status IN ('planning', 'active', 'halted') RETURNING *`, + [projectId], + ); + return rows[0] ? toProject(rows[0]) : null; +} + +export async function countOpenTasks(db: Queryable, projectId: string): Promise { + const { rows } = await db.query(`SELECT count(*)::int AS n FROM tasks WHERE project_id = $1 AND state <> 'DONE'`, [projectId]); + return Number(rows[0]?.n ?? 0); +} + +// 중단한 프로젝트의 대기 질문은 만료로 닫는다 — 남겨 두면 3일 뒤 만료 정리가 끝난 프로젝트의 태스크를 ESCALATED로 올린다. +// 이 변화는 PROJECT_ABORTED.payload.expiredQuestionIds로 같은 트랜잭션에 남는다. +export async function expirePendingQuestions(db: Queryable, projectId: string): Promise { + const { rows } = await db.query( + `UPDATE agent_questions SET status = 'expired' WHERE project_id = $1 AND status = 'pending' RETURNING id`, + [projectId], + ); + return rows.map((r) => r.id as string); +} diff --git a/src/domain/project/service.ts b/src/domain/project/service.ts index 494597d..53ea59a 100644 --- a/src/domain/project/service.ts +++ b/src/domain/project/service.ts @@ -1,14 +1,17 @@ +import type { PoolClient } from 'pg'; import { pool, withTransaction, type Queryable } from '../../config/db.js'; import { AppError } from '../../errors.js'; import { hashObject } from '../../utils/canonical-json.js'; import { tasksChanged } from '../dispatch/tasks-changed.js'; import { appendEvent } from '../events/append.js'; -import type { GithubInviteResult } from '../events/types.js'; +import type { GithubInviteResult, GithubRevokeResult } from '../events/types.js'; import { githubRepApi } from '../github/rep-api.js'; import { findRepresentativeGithubToken } from '../oauth/repository.js'; import { decryptSecret } from '../../utils/secret-box.js'; import { violatedConstraint } from '../pg-errors.js'; import { recomputeProjectPolicyHash } from '../policy/policy-hash.js'; +import { findUserById } from '../org/repository.js'; +import { findAgentMembership } from '../policy/repository.js'; import type { TeamRole } from '../roles.js'; import { approveAppliedPlans, @@ -27,7 +30,14 @@ import { insertProjectMember, isProjectMember, linkProjectRepos, + countOpenTasks, + expirePendingQuestions, + findUserGithubLogin, + listActiveTasksOfAgent, listLatestInvites, + listOrgGithubRepos, + markProjectAborted, + userStillInProject, listProjectGithubRepos, listProjectMembers, listProjectRepos, @@ -93,13 +103,59 @@ async function assertProjectInOrg( return project; } -// G1(started_at) 이후에는 멤버 구성을 바꿀 수 없다. 도중에 바뀌면 실험 통제가 흔들린다. -function assertNotStarted(project: Project): void { - if (project.startedAt !== null) { - throw new AppError('PROJECT_STARTED', 'project has started; members are frozen'); +// 멤버를 바꿀 수 있는 프로젝트 — 진행 중(planning·active·halted)이면 시작 뒤에도 된다(021, 대표 결정: 사람이 빠지거나 교체해야 할 때). +// 끝난 프로젝트(completed·aborted)의 멤버는 기록이라 바꾸지 않는다. +function assertMembersEditable(project: Project): void { + if (project.status === 'completed' || project.status === 'aborted') { + throw new AppError('PROJECT_NOT_OPEN', `project is ${project.status}; members are a record now`); } } +// 멤버 한 명을 뺀다(트랜잭션 안, 프로젝트 행은 호출부가 잠근다). 그 에이전트가 붙잡은 진행 중 태스크가 있으면 거부한다(대표 결정) — +// 빼 버리면 그 태스크는 아무도 끝내지 못한다. 잡고 있던 것을 끝내거나 멈춘(BLOCKED) 뒤에 뺀다. +async function removeMemberInTx( + tx: PoolClient, + project: Project, + agentId: string, + onBehalfOf: string, + cause?: 'agent_disconnected' | 'org_member_removed', +): Promise { + const active = await listActiveTasksOfAgent(tx, project.id, agentId); + if (active.length > 0) { + throw new AppError('ACTIVE_TASKS_HELD', `agent ${agentId} still holds ${active.length} task(s) in progress; finish or stop them first`, active); + } + // 역할 교체는 삭제 후 재배정으로 한다 — UPDATE 경로를 두면 한 역할에 둘이 잠깐 겹칠 수 있다. + const teamRole = await deleteProjectMember(tx, project.id, agentId); + if (teamRole === null) { + throw new AppError('MEMBER_NOT_FOUND', `agent ${agentId} is not a member of this project`); + } + await appendEvent(tx, { + orgId: project.orgId, + projectId: project.id, + type: 'MEMBER_UNASSIGNED', + actorAgentId: agentId, + onBehalfOf, + policyHash: project.policyHash, + payload: { agentId, teamRole, ...(cause ? { cause } : {}) }, + }); +} + +// 에이전트를 지금 맡은 진행 중 프로젝트에서 뺀다(연결 해제·조직 탈퇴가 부른다). 맡은 곳이 없으면 null. +// 호출부의 트랜잭션 안에서 프로젝트 행을 FOR SHARE로 잠근다 — 시작·배정과 같은 잠금이다. +export async function detachAgentFromActiveProject( + tx: PoolClient, + agentId: string, + onBehalfOf: string, + cause: 'agent_disconnected' | 'org_member_removed', +): Promise { + const membership = await findAgentMembership(tx, agentId); + if (membership === null) return null; + const project = await findProjectById(tx, membership.projectId, { lock: 'share' }); + if (!project) return null; + await removeMemberInTx(tx, project, agentId, onBehalfOf, cause); + return project.id; +} + export async function createProject( orgId: string, actorUserId: string, @@ -218,7 +274,7 @@ export async function assignMember( return withTransaction(async (tx) => { // 시작(G1)과 겹치지 않게 잠근다. 시작이 먼저 잡았으면 끝날 때까지 기다린 뒤 started_at을 본다. const project = await assertProjectInOrg(tx, actor.orgId, projectId, 'share'); - assertNotStarted(project); + assertMembersEditable(project); // 에이전트도 같은 조직이어야 한다. 없는 id도 여기서 걸린다. const agentOrg = await findAgentOrg(tx, agentId); @@ -336,33 +392,22 @@ async function inviteToProjectGithubRepos( return results; } +// 배정 해제(대표). 시작 뒤에도 되지만(021) 그 에이전트가 진행 중 태스크를 붙잡고 있으면 409 ACTIVE_TASKS_HELD. +// 빠진 역할의 태스크는 새 사람을 배정할 때까지 READY로 기다린다. export async function unassignMember( actor: Actor, projectId: string, agentId: string, ): Promise { - return withTransaction(async (tx) => { + const members = await withTransaction(async (tx) => { const project = await assertProjectInOrg(tx, actor.orgId, projectId, 'share'); - assertNotStarted(project); - - // 역할 교체는 삭제 후 재배정으로 한다 — UPDATE 경로를 두면 한 역할에 둘이 잠깐 겹칠 수 있다. - const teamRole = await deleteProjectMember(tx, projectId, agentId); - if (teamRole === null) { - throw new AppError('MEMBER_NOT_FOUND', `agent ${agentId} is not a member of this project`); - } - - await appendEvent(tx, { - orgId: actor.orgId, - projectId, - type: 'MEMBER_UNASSIGNED', - actorAgentId: agentId, - onBehalfOf: actor.userId, - policyHash: project.policyHash, - payload: { agentId, teamRole }, - }); - + assertMembersEditable(project); + await removeMemberInTx(tx, project, agentId, actor.userId); return membersWithInvites(tx, projectId); }); + // 커밋 뒤 — 빠진 에이전트의 스트림은 다음 스냅샷에서 NOT_PROJECT_MEMBER로 닫힌다. + tasksChanged(projectId); + return members; } // 프로젝트 시작(G1). 이 순간부터 실행이 시작된다 — 에이전트는 시작 전에는 태스크를 가져갈 수 없고(PROJECT_NOT_STARTED), @@ -370,7 +415,7 @@ export async function unassignMember( // // - 시작할 수 있는가: 태스크가 하나 이상, 끝나지 않은 태스크가 요구하는 역할마다 배정된 에이전트가 있어야 한다. // 역할이 비면 그 태스크를 아무도 가져가지 않는다 — 시작 뒤에는 멤버를 바꿀 수 없으므로 여기서 막는다. 위반은 전부 모아 422. -// - 시작하면: planning → active, started_at. 시작 시점의 명세·적용된 계획에 approved_at(G1 승인 = 잠김). 멤버 고정(assertNotStarted). +// - 시작하면: planning → active, started_at. 시작 시점의 명세·적용된 계획에 approved_at(G1 승인 = 잠김). 멤버는 시작 뒤에도 바꿀 수 있다(021, 진행 중 태스크가 없을 때). // - 헌법은 프로젝트 생성 때 이미 사본을 떴다. 수정 경로가 생기면 "시작 뒤에는 고칠 수 없다"를 그쪽에 건다. export async function startProject(actor: Actor, projectId: string): Promise { const detail = await withTransaction(async (tx) => { @@ -451,3 +496,106 @@ export async function listProjects(actor: Actor): Promise { listProjectsByOrg(tx, actor.orgId, actor.orgRole === 'REPRESENTATIVE' ? null : actor.userId), ); } + +// ── GitHub 접근 회수(021) ──────────────────────────────────────────────── + +// 빠진 사람의 GitHub 레포 접근을 회수한다 — 배정 때 초대(inviteToProjectGithubRepos)의 반대. 대표의 GitHub 토큰을 쓴다. +// 해제는 먼저 커밋하고 그 뒤에 부른다: GitHub 호출은 트랜잭션에 넣을 수 없고, 회수가 실패해도 해제는 유효하다(결과는 이벤트로 남긴다). +// 대표 본인은 회수하지 않는다(그 토큰의 주인이고, 조직 레포 관리자다). GitHub 레포가 없으면(로컬 데모) 아무것도 하지 않는다. +export async function revokeGithubAccess(input: { + orgId: string; + onBehalfOf: string; + projectId: string | null; + agentId: string | null; + userId: string; + scope: { kind: 'project'; projectId: string } | { kind: 'org' }; + trigger: 'unassign' | 'agent_disconnected' | 'org_member_removed'; +}): Promise { + // 한 사람이 같은 프로젝트에 다른 에이전트로 남아 있으면 그 프로젝트 레포는 계속 필요하다. + if (input.scope.kind === 'project' && (await userStillInProject(pool, input.scope.projectId, input.userId))) return []; + const repos = input.scope.kind === 'project' ? await listProjectGithubRepos(pool, input.scope.projectId) : await listOrgGithubRepos(pool, input.orgId); + if (repos.length === 0) return []; + const target = await findUserById(pool, input.userId); + if (target?.orgRole === 'REPRESENTATIVE') return []; + + const githubLogin = await findUserGithubLogin(pool, input.userId); + const sealed = await findRepresentativeGithubToken(pool, input.orgId); + const skipAll = (reason: string): GithubRevokeResult[] => repos.map((r) => ({ repoId: r.id, fullName: r.fullName, status: 'skipped', reason })); + let results: GithubRevokeResult[]; + if (githubLogin === null) { + results = skipAll('멤버가 GitHub를 연결하지 않았다 — 초대된 적이 없다'); + } else if (sealed === null) { + results = skipAll('대표가 GitHub를 연결하지 않았다 — GitHub에서 직접 정리'); + } else { + const token = await decryptSecret(sealed); + results = []; + for (const repo of repos) { + try { + const status = await githubRepApi().revokeCollaborator(token, repo.fullName, githubLogin); + results.push({ repoId: repo.id, fullName: repo.fullName, status }); + } catch (err) { + results.push({ repoId: repo.id, fullName: repo.fullName, status: 'failed', reason: err instanceof Error ? err.message : String(err) }); + } + } + } + await withTransaction(async (tx) => { + await appendEvent(tx, { + orgId: input.orgId, + ...(input.projectId ? { projectId: input.projectId } : {}), + type: 'GITHUB_COLLABORATORS_REMOVED', + ...(input.agentId ? { actorAgentId: input.agentId } : {}), + onBehalfOf: input.onBehalfOf, + payload: { agentId: input.agentId, userId: input.userId, githubLogin, trigger: input.trigger, results }, + }); + }); + return results; +} + +// 배정 해제 + 그 사람의 프로젝트 GitHub 레포 접근 회수(라우트가 쓴다). +export async function unassignMemberWithRevocations( + actor: Actor, + projectId: string, + agentId: string, +): Promise<{ members: ProjectMemberView[]; githubRevocations: GithubRevokeResult[] }> { + const owner = await findAgentOwnerGithub(pool, agentId); + const members = await unassignMember(actor, projectId, agentId); + const githubRevocations = owner + ? await revokeGithubAccess({ + orgId: actor.orgId, + onBehalfOf: actor.userId, + projectId, + agentId, + userId: owner.userId, + scope: { kind: 'project', projectId }, + trigger: 'unassign', + }) + : []; + return { members: await membersWithInvites(pool, projectId), githubRevocations }; +} + +// ── 프로젝트 중단(021) ─────────────────────────────────────────────────── + +// 진행 중(planning·active·halted)인 프로젝트를 중단한다(대표). 지우지 않고 aborted로 닫는다 — 이벤트·태스크·노트가 가리킨다. +// - 에이전트 배정은 그대로 기록으로 남지만 "진행 중 프로젝트"가 아니게 되어 에이전트가 풀린다(토큰 재발급 뒤 다른 프로젝트에 배정 가능). +// - 레포는 다른 프로젝트에 쓸 수 있게 된다(findRepoUsage가 aborted를 세지 않는다). 대기 질문은 만료로 닫는다. 대기 승인 카드는 결정할 수 없다(PROJECT_NOT_OPEN). +// - GitHub 협업자 권한은 회수하지 않는다 — 레포와 코드는 팀의 것으로 남는다. +export async function abortProject(actor: Actor, projectId: string, reason: string | null): Promise { + const aborted = await withTransaction(async (tx) => { + const project = await assertProjectInOrg(tx, actor.orgId, projectId, 'update'); + const updated = await markProjectAborted(tx, projectId); + if (!updated) throw new AppError('PROJECT_NOT_OPEN', `project is ${project.status}; only an ongoing project can be aborted`); + const expiredQuestionIds = await expirePendingQuestions(tx, projectId); + await appendEvent(tx, { + orgId: actor.orgId, + projectId, + type: 'PROJECT_ABORTED', + onBehalfOf: actor.userId, + policyHash: project.policyHash, + payload: { previousStatus: project.status, reason, openTaskCount: await countOpenTasks(tx, projectId), expiredQuestionIds }, + }); + return updated; + }); + // 커밋 뒤 — 에이전트 스트림은 다음 스냅샷에서 NOT_PROJECT_MEMBER로 닫힌다. + tasksChanged(projectId); + return aborted; +} diff --git a/src/domain/repo/repository.ts b/src/domain/repo/repository.ts index 0cc1463..b8fcb60 100644 --- a/src/domain/repo/repository.ts +++ b/src/domain/repo/repository.ts @@ -90,8 +90,9 @@ export async function findRepoByIdForOrg(db: Queryable, orgId: string, repoId: s } // 조직에 연결된 모든 레포를 조회한다. members의 GitHub collaborator 확인에 쓰인다. +// 연결 해제된 레포(021)는 빼고. export async function listReposByOrg(db: Queryable, orgId: string): Promise { - const { rows } = await db.query(`SELECT * FROM repos WHERE org_id = $1 ORDER BY created_at ASC`, [orgId]); + const { rows } = await db.query(`SELECT * FROM repos WHERE org_id = $1 AND disconnected_at IS NULL ORDER BY created_at ASC`, [orgId]); return rows.map(toRepo); } @@ -282,3 +283,35 @@ export async function updateRepoPathOwnership( if (!row) throw new Error('updateRepoPathOwnership: update returned no row'); return toRepoPath(row); } + +// ── 연결 해제(021) ─────────────────────────────────────────────────────── + +// 연결된(해제되지 않은) 레포만. 대표·멤버가 레포를 다루는 API는 이걸로 찾는다 — 해제된 레포는 없는 것으로 본다. +export async function findConnectedRepoById(db: Queryable, repoId: string): Promise { + const { rows } = await db.query(`SELECT * FROM repos WHERE id = $1 AND disconnected_at IS NULL`, [repoId]); + return rows[0] ? toRepo(rows[0]) : null; +} + +export async function markRepoDisconnected(db: Queryable, repoId: string): Promise { + const { rowCount } = await db.query(`UPDATE repos SET disconnected_at = now() WHERE id = $1 AND disconnected_at IS NULL`, [repoId]); + return (rowCount ?? 0) > 0; +} + +// 해제했던 같은 레포를 다시 연결하면 그 행을 되살린다 — 경로 규칙(소유권 포함)이 그대로 돌아온다. +// 새로 받은 GitHub 정보가 있으면 덮고, 없으면 예전 값을 둔다. +export async function reviveDisconnectedRepo( + db: Queryable, + input: { orgId: string; fullName: string; githubRepoId?: number; defaultBranch?: string; cloneUrl?: string }, +): Promise { + const { rows } = await db.query( + `UPDATE repos + SET disconnected_at = NULL, + github_repo_id = COALESCE($3, github_repo_id), + default_branch = COALESCE($4, default_branch), + clone_url = COALESCE($5, clone_url) + WHERE org_id = $1 AND full_name = $2 AND disconnected_at IS NOT NULL + RETURNING *`, + [input.orgId, input.fullName, input.githubRepoId ?? null, input.defaultBranch ?? null, input.cloneUrl ?? null], + ); + return rows[0] ? toRepo(rows[0]) : null; +} diff --git a/src/domain/repo/service.ts b/src/domain/repo/service.ts index 2233b5a..9a733ee 100644 --- a/src/domain/repo/service.ts +++ b/src/domain/repo/service.ts @@ -13,6 +13,7 @@ import { logger } from '../../config/logger.js'; import { validateCloneUrl } from './clone-url.js'; import { validatePattern } from './glob.js'; import { + findConnectedRepoById, findOtherRepoByGithubId, findRepoById, findRepoPathById, @@ -22,7 +23,9 @@ import { listRepoIdsWithOwnership, listRepoPaths, listReposByOrg, + markRepoDisconnected, maxPriorityInBand, + reviveDisconnectedRepo, updateRepoPathOwnership, updateRepoSettings as updateRepoSettingsRow, type Repo, @@ -33,7 +36,8 @@ import type { OwnerRole, RepoPath } from './types.js'; // repoId가 orgId 소속인지 확인한다. 존재하지 않으면 404, 다른 조직 소유면 403(CROSS_ORG_ACCESS). // 모든 라우트가 이 검사를 거치도록 강제해 다른 조직의 리소스 접근을 막는다. async function assertRepoInOrg(db: Queryable, orgId: string, repoId: string): Promise { - const repo = await findRepoById(db, repoId); + // 연결 해제된 레포(021)는 없는 것으로 본다. + const repo = await findConnectedRepoById(db, repoId); if (!repo) { throw new AppError('REPO_NOT_FOUND', `repo ${repoId} not found`); } @@ -114,14 +118,11 @@ async function connectOneRepo( actorUserId: string, r: ConnectRepoInput & { cloneUrl?: string; createdOnGithub?: { githubOrg: string; githubRepoId: number } }, ): Promise { - const repo = await insertRepo(tx, { - orgId: orgId, - fullName: r.fullName, - githubRepoId: r.githubRepoId, - defaultBranch: r.defaultBranch, - cloneUrl: r.cloneUrl, - }); - const seeded = await insertSeedRepoPaths(tx, repo.id, SEED_PATH_RULES); + // 해제했던 같은 레포면 그 행을 되살린다(021) — 경로 규칙·소유권이 그대로 돌아온다. 아니면 새로 넣고 시드 규칙을 깐다. + const repoInput = { orgId, fullName: r.fullName, githubRepoId: r.githubRepoId, defaultBranch: r.defaultBranch, cloneUrl: r.cloneUrl }; + const revived = await reviveDisconnectedRepo(tx, repoInput); + const repo = revived ?? (await insertRepo(tx, repoInput)); + const seeded = revived ? [] : await insertSeedRepoPaths(tx, repo.id, SEED_PATH_RULES); await appendEvent(tx, { orgId: orgId, type: 'REPO_CONNECTED', @@ -131,11 +132,13 @@ async function connectOneRepo( fullName: repo.fullName, seededPathCount: seeded.length, ...(r.createdOnGithub === undefined ? {} : { createdOnGithub: r.createdOnGithub }), + ...(revived ? { revived: true } : {}), }, }); let rootOwnerRole: OwnerRole | null = null; if (r.ownerRole !== undefined) { - const root = seeded.find((p) => p.pathPattern === '**'); + const paths = revived ? await listRepoPaths(tx, repo.id) : seeded; + const root = paths.find((p) => p.pathPattern === '**' && p.source === 'seed'); if (!root) throw new Error('seed rules must include "**"'); const updated = await updateRepoPathOwnership(tx, root.id, { ownerRole: r.ownerRole }); await appendEvent(tx, { @@ -424,3 +427,24 @@ export async function listRepos(orgId: string): Promise { }; }); } + +// 레포 연결 해제(대표, 021). 진행 중(planning·active·halted) 프로젝트가 쓰고 있으면 409 REPO_IN_ACTIVE_PROJECT — +// 그 프로젝트의 판정·검증이 이 레포의 경로 규칙을 읽는다. 행과 경로 규칙은 지우지 않는다(끝난 프로젝트의 태스크·검증이 가리킨다). +// GitHub의 레포는 건드리지 않는다. 같은 레포를 다시 연결하면 되살아난다. +export async function disconnectRepo(orgId: string, actorUserId: string, repoId: string): Promise<{ repoId: string; fullName: string }> { + return withTransaction(async (tx) => { + const repo = await assertRepoInOrg(tx, orgId, repoId); + const usage = await findRepoUsage(tx, [repo.id]); + if (usage.length > 0) { + throw new AppError('REPO_IN_ACTIVE_PROJECT', `${repo.fullName} is used by an active project; finish or abort it first`, usage); + } + if (!(await markRepoDisconnected(tx, repo.id))) throw new AppError('REPO_NOT_FOUND', `repo ${repoId} not found`); + await appendEvent(tx, { + orgId, + type: 'REPO_DISCONNECTED', + onBehalfOf: actorUserId, + payload: { repoId: repo.id, fullName: repo.fullName }, + }); + return { repoId: repo.id, fullName: repo.fullName }; + }); +} diff --git a/src/domain/room/render.ts b/src/domain/room/render.ts index b000849..b9d8477 100644 --- a/src/domain/room/render.ts +++ b/src/domain/room/render.ts @@ -75,6 +75,8 @@ function eventText(row: FeedRow): { speaker: RoomMessage['speaker']; text: strin switch (row.type) { case 'PROJECT_STARTED': return { speaker: 'pm', text: '프로젝트를 시작합니다' }; + case 'PROJECT_ABORTED': + return { speaker: 'pm', text: `프로젝트가 중단되었습니다${str(p.reason) ? `: ${str(p.reason)}` : ''}` }; case 'PLAN_APPLIED': return { speaker: 'pm', text: '계획을 적용했습니다' }; case 'RELEASE_REQUESTED': { diff --git a/src/domain/room/repository.ts b/src/domain/room/repository.ts index a1557ba..a3a59c5 100644 --- a/src/domain/room/repository.ts +++ b/src/domain/room/repository.ts @@ -125,7 +125,7 @@ export const TASK_FEED_EVENTS = [ 'APPROVAL_RESULT', 'NOTE_PUBLISHED', ] as const; -export const PROJECT_FEED_EVENTS = ['PROJECT_STARTED', 'PLAN_APPLIED', 'RELEASE_REQUESTED', 'RELEASE_DECIDED'] as const; +export const PROJECT_FEED_EVENTS = ['PROJECT_STARTED', 'PLAN_APPLIED', 'RELEASE_REQUESTED', 'RELEASE_DECIDED', 'PROJECT_ABORTED'] as const; export type FeedRow = { source: 'e' | 'a'; diff --git a/src/errors.ts b/src/errors.ts index df22833..8bf5cbd 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -80,10 +80,25 @@ export type ErrorCode = | 'PROJECT_NOT_STARTED' | 'NOTES_UNACKNOWLEDGED' | 'MEMBER_NOT_FOUND' - | 'REPO_OWNERSHIP_NOT_SET'; + | 'REPO_OWNERSHIP_NOT_SET' + // 해제·삭제(021) + | 'ACTIVE_TASKS_HELD' + | 'REPRESENTATIVE_CANNOT_LEAVE' + | 'ORG_MEMBER_NOT_FOUND' + | 'AGENT_NOT_FOUND' + | 'NOT_AGENT_OWNER' + | 'AGENT_DISCONNECTED' + | 'INVITE_REVOKED'; // 에러 코드 -> HTTP 상태 코드 매핑. 도메인 코드는 이 테이블에 없는 코드를 쓸 수 없다. const STATUS_BY_CODE: Record = { + ACTIVE_TASKS_HELD: 409, + REPRESENTATIVE_CANNOT_LEAVE: 409, + ORG_MEMBER_NOT_FOUND: 404, + AGENT_NOT_FOUND: 404, + NOT_AGENT_OWNER: 403, + AGENT_DISCONNECTED: 401, + INVITE_REVOKED: 410, ORG_NAME_REQUIRED: 400, INVALID_GLOB_PATTERN: 400, VALIDATION_ERROR: 400, diff --git a/src/executor/cli.ts b/src/executor/cli.ts index aff16b3..7bb8422 100644 --- a/src/executor/cli.ts +++ b/src/executor/cli.ts @@ -262,7 +262,8 @@ async function once(): Promise { await handleTask(client, self.projectId, task); } -async function start(): Promise { +// 멈춘 이유를 돌려준다 — connect는 배정이 풀린 경우(해제·프로젝트 중단) 다시 배정을 기다린다. +async function start(): Promise<'disconnected' | 'unassigned'> { const client = createClient(); const self = await client.describeSelf(); // 서버 푸시(웹소켓)로 받는다. 끊겨 있는 동안은 같은 목록을 HTTP로 읽는다. @@ -300,6 +301,18 @@ async function start(): Promise { } } } catch (err) { + // 대표나 본인이 웹에서 이 에이전트의 연결을 해제했다(서버 021) — 계속 물어도 같은 답이니 이유를 알리고 멈춘다. + if (err instanceof NomosApiError && err.code === 'AGENT_DISCONNECTED') { + log('이 에이전트는 연결이 해제됐다 — 다시 쓰려면 connect를 실행하라'); + source.close(); + return 'disconnected'; + } + // 배정이 풀렸다(대표의 해제·프로젝트 중단, 서버 021). 같은 목록을 계속 물어도 거부된다. + if (err instanceof NomosApiError && err.code === 'NOT_PROJECT_MEMBER' && inFlight.size === 0) { + log('프로젝트 배정이 풀렸다(해제 또는 프로젝트 중단)'); + source.close(); + return 'unassigned'; + } log(`태스크 목록 실패: ${err instanceof Error ? err.message : String(err)}`); } // 다른 역할이 이 역할에 물은 질문 — 질문이 생기면 서버가 { type: 'questions' }로 깨운다. 신호가 없으면 1분마다만 확인한다. @@ -411,16 +424,20 @@ async function connectCommand(argv: string[]): Promise { throw new Error('필요한 도구가 없어 시작하지 않는다'); } - await connect({ - server, - agentName, - readCredentials, - login: () => loginTo(server, agentName, args.connectKey), - ...serverCalls({ read: readCredentials, updateAccessToken }), - sleep: (ms) => new Promise((r) => setTimeout(r, ms)), - log, - }); - await start(); + // 배정이 풀리면(해제·프로젝트 중단) 창을 닫지 않고 다시 배정을 기다린다. 연결 해제면 멈춘다. + for (;;) { + await connect({ + server, + agentName, + readCredentials, + login: () => loginTo(server, agentName, args.connectKey), + ...serverCalls({ read: readCredentials, updateAccessToken }), + sleep: (ms) => new Promise((r) => setTimeout(r, ms)), + log, + }); + if ((await start()) !== 'unassigned') return; + log('다시 배정을 기다린다'); + } } async function doctor(argv: string[]): Promise { @@ -492,7 +509,10 @@ async function main(): Promise { if (command === 'login') return login(process.argv.slice(3)); if (command === 'refresh') return refresh(); if (command === 'once') return once(); - if (command === 'start') return start(); + if (command === 'start') { + await start(); + return; + } if (command === 'pm-worker') return pmWorker(); if (command === 'clean') { const { pruned, removed } = cleanWorkspaces(); diff --git a/src/middleware/agent-auth.ts b/src/middleware/agent-auth.ts index 660db84..7d6c05d 100644 --- a/src/middleware/agent-auth.ts +++ b/src/middleware/agent-auth.ts @@ -79,6 +79,10 @@ export async function resolveAgentToken(token: string): Promise { if (!row) { throw new AppError('UNAUTHENTICATED', 'invalid or expired token'); } + // 연결 해제된 에이전트(021) — access token은 만료 전이라도 받지 않는다. 다시 쓰려면 connect부터. + if (row.disconnectedAt !== null) { + throw new AppError('AGENT_DISCONNECTED', 'this agent was disconnected; run connect again to reconnect'); + } // 책임 귀속(on_behalf_of)이 실제 소유자와 어긋난 토큰은 받지 않는다 — P3가 무너진다. if (claims.on_behalf_of !== row.userId) { throw new AppError('UNAUTHENTICATED', 'invalid or expired token'); diff --git a/src/middleware/error-handler.ts b/src/middleware/error-handler.ts index 7747fbb..38e1d75 100644 --- a/src/middleware/error-handler.ts +++ b/src/middleware/error-handler.ts @@ -8,7 +8,7 @@ import { AppError } from '../errors.js'; // NOTE_INVALID가 여기 있어야 하는 이유: 위반 목록(어느 필드의 몇 번째가 몇 자인지)이 빠지면 // 에이전트는 "note validation failed"만 보고 무엇을 줄여야 할지 모른 채 같은 요청을 반복한다. // 자르지 않고 전부 되돌려주는 것이 노트 검증의 설계 의도다(domain/note/validate.ts). -const PUBLIC_DETAIL_CODES = new Set(['POLICY_STALE', 'NOTE_INVALID', 'PLAN_INVALID', 'PROJECT_START_INVALID', 'NOTES_UNACKNOWLEDGED', 'REPO_IN_ACTIVE_PROJECT', 'PLAN_REVISION_LIMIT', 'QUESTION_INVALID']); +const PUBLIC_DETAIL_CODES = new Set(['POLICY_STALE', 'NOTE_INVALID', 'PLAN_INVALID', 'PROJECT_START_INVALID', 'NOTES_UNACKNOWLEDGED', 'REPO_IN_ACTIVE_PROJECT', 'PLAN_REVISION_LIMIT', 'QUESTION_INVALID', 'ACTIVE_TASKS_HELD']); // 상세는 details 키에 담는다(배열이든 객체든). 클라이언트는 error.details 한 곳만 보면 된다. // 예외는 POLICY_STALE 하나 — 이 코드만 예전부터 상세를 error 안으로 펼쳐(error.reason) 왔고, 배포된 브릿지가 그 모양을 읽는다. diff --git a/src/realtime/agent-stream.ts b/src/realtime/agent-stream.ts index fa1aa82..55e67a1 100644 --- a/src/realtime/agent-stream.ts +++ b/src/realtime/agent-stream.ts @@ -3,6 +3,7 @@ import type { Duplex } from 'node:stream'; import { WebSocketServer, type RawData, type WebSocket } from 'ws'; import { logger } from '../config/logger.js'; import { markAgentSeen, streamClosed, streamOpened } from '../domain/agent/presence.js'; +import { onAgentRevoked } from '../domain/dispatch/agent-revoked.js'; import { onQuestionsChanged } from '../domain/dispatch/questions-changed.js'; import { onTasksChanged } from '../domain/dispatch/tasks-changed.js'; import { listClaimableTasksForAgent, type AgentContext } from '../domain/task/service.js'; @@ -96,6 +97,15 @@ export function attachAgentStream(server: Server): AgentStream { await Promise.all([...set].map(pushSnapshot)); }); + // 연결 해제·조직 탈퇴(021) — 그 에이전트의 스트림을 바로 끊는다. 4401이면 클라이언트가 재발급을 시도하지만 토큰이 폐기돼 거기서 멈춘다. + const unsubscribeRevoked = onAgentRevoked((agentId) => { + for (const set of byProject.values()) { + for (const conn of set) { + if (conn.ctx.agentId === agentId) closeWith(conn.ws, 4401, 'AGENT_DISCONNECTED', 'this agent was disconnected; run connect again to reconnect'); + } + } + }); + const unsubscribeQuestions = onQuestionsChanged((projectId, targetRole) => { for (const conn of byProject.get(projectId) ?? []) send(conn.ws, { type: 'questions', projectId, targetRole }); }); @@ -179,6 +189,7 @@ export function attachAgentStream(server: Server): AgentStream { clearInterval(ping); unsubscribe(); unsubscribeQuestions(); + unsubscribeRevoked(); unroute(); for (const ws of wss.clients) ws.terminate(); await new Promise((resolve) => wss.close(() => resolve())); diff --git a/src/realtime/topics.ts b/src/realtime/topics.ts index 15f2ee3..10bcf92 100644 --- a/src/realtime/topics.ts +++ b/src/realtime/topics.ts @@ -37,6 +37,12 @@ export const TOPICS_BY_EVENT: Record = { PROJECT_STARTED: { project: ['project', 'tasks', 'specs', 'plans', 'room'], org: ['projects'] }, MEMBER_ASSIGNED: { project: ['members'], org: ['agents'] }, MEMBER_UNASSIGNED: { project: ['members'], org: ['agents'] }, + AGENT_DISCONNECTED: { org: ['agents'] }, + ORG_MEMBER_REMOVED: { org: ['members', 'agents'] }, + REPO_DISCONNECTED: { org: ['repos'] }, + INVITE_REVOKED: { org: ['members'] }, + GITHUB_COLLABORATORS_REMOVED: { project: ['members'], org: ['members'] }, + PROJECT_ABORTED: { project: ['project', 'tasks', 'members', 'room'], org: ['projects', 'repos', 'agents', 'approvals'] }, GITHUB_COLLABORATORS_INVITED: { project: ['members'], org: ['members'] }, SPEC_CREATED: { project: ['specs'] }, diff --git a/src/realtime/user-stream.ts b/src/realtime/user-stream.ts index 8d64ac3..b2fc73f 100644 --- a/src/realtime/user-stream.ts +++ b/src/realtime/user-stream.ts @@ -5,6 +5,7 @@ import { pool } from '../config/db.js'; import { logger } from '../config/logger.js'; import { onPresenceChanged, sweepPresence } from '../domain/agent/presence.js'; import { agentOrgId } from '../domain/agent/service.js'; +import { findUserById } from '../domain/org/repository.js'; import { assertProjectVisibleToUser, type UserContext } from '../domain/project/visibility.js'; import { AppError } from '../errors.js'; import { resolveUserToken } from '../middleware/auth.js'; @@ -109,6 +110,15 @@ export function attachUserStream(server: Server, options: UserStreamOptions): Us } }; + // 조직에서 빠진 사람(021)의 연결은 끊는다 — 남겨 두면 그 조직의 신호를 계속 받는다. 이벤트에는 누가 빠졌는지가 없어 연결마다 다시 읽는다. + const recheckOrg = async (orgId: string, conns: Connection[]) => { + for (const conn of conns) { + if (conn.ctx.orgRole === 'REPRESENTATIVE') continue; + const user = await findUserById(pool, conn.ctx.userId); + if (!user || user.orgId !== orgId) closeWith(conn.ws, 4403, 'NOT_IN_ORG', 'you are no longer a member of this organization'); + } + }; + const onEvent = (event: CommittedEvent) => { if (!event.orgId) return; const conns = byOrg.get(event.orgId); @@ -121,6 +131,9 @@ export function attachUserStream(server: Server, options: UserStreamOptions): Us if (orgTopics.length > 0) queue(conn, null, orgTopics, event.id); if (event.projectId && conn.projects.has(event.projectId)) queue(conn, event.projectId, project, event.id); } + if (event.type === 'ORG_MEMBER_REMOVED') { + void recheckOrg(event.orgId, list).catch((err: unknown) => logger.warn('user stream org recheck failed', { error: String(err) })); + } if (event.type === 'MEMBER_UNASSIGNED' && event.projectId) { void recheck(event.projectId, list).catch((err: unknown) => logger.warn('user stream recheck failed', { error: String(err) })); } diff --git a/src/routes/agents.ts b/src/routes/agents.ts index 7f87336..04617d0 100644 --- a/src/routes/agents.ts +++ b/src/routes/agents.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import { z } from 'zod'; -import { connectAgent, listOrgAgents, refreshAgentToken } from '../domain/agent/service.js'; +import { connectAgent, disconnectAgent, listOrgAgents, refreshAgentToken } from '../domain/agent/service.js'; import { decideDeviceRequestByUser, getDeviceRequest, @@ -59,6 +59,16 @@ agentsRouter.get( }, ); +const agentIdParamsSchema = z.object({ agentId: z.string().uuid() }); + +// DELETE /api/agents/:agentId — 에이전트 연결 해제(사람 토큰: 그 에이전트의 주인 또는 같은 조직의 대표, 021). +// 맡은 진행 중 프로젝트에서 빠지고(진행 중 태스크가 있으면 409 ACTIVE_TASKS_HELD) 토큰이 폐기된다. 같은 노트북에서 다시 connect하면 되살아난다. +agentsRouter.delete('/agents/:agentId', validate({ params: agentIdParamsSchema }), authenticate, async (req, res) => { + const { agentId } = req.params as z.infer; + const user = req.user!; + res.status(200).json({ data: await disconnectAgent({ userId: user.id, orgId: user.orgId, orgRole: user.orgRole }, agentId) }); +}); + // ── 브라우저 승인(device flow, RFC 8628) ───────────────────────────────── // CLI: start → 코드 출력·브라우저 열기 → poll. 웹: 로그인된 사람이 확인하고 승인·거부. 연결 키 경로는 SSH 등 브라우저가 없는 환경용으로 남는다. diff --git a/src/routes/invites.ts b/src/routes/invites.ts index 6edfc9f..e462b02 100644 --- a/src/routes/invites.ts +++ b/src/routes/invites.ts @@ -2,7 +2,7 @@ import { Router } from 'express'; import { z } from 'zod'; import { authenticate, requireRepresentative, requireSameOrg } from '../middleware/auth.js'; import { validate } from '../middleware/validate.js'; -import { acceptInvite, createInvite, previewInvite } from '../domain/invite/service.js'; +import { acceptInvite, createInvite, listInvites, previewInvite, revokeInvite } from '../domain/invite/service.js'; import { TEAM_ROLES } from '../domain/roles.js'; export const invitesRouter = Router(); @@ -27,6 +27,34 @@ invitesRouter.post( }, ); +// GET /api/orgs/:orgId/invites — 보낸 초대 중 아직 쓰이지도 취소되지도 않은 것(대표 전용, 021). 만료된 것도 expired로 함께 나온다. +invitesRouter.get( + '/orgs/:orgId/invites', + validate({ params: orgIdParamsSchema }), + authenticate, + requireSameOrg, + requireRepresentative, + async (req, res) => { + const { orgId } = req.params as z.infer; + res.status(200).json({ data: { invites: await listInvites(orgId) } }); + }, +); + +const inviteIdParamsSchema = z.object({ orgId: z.string().uuid(), inviteId: z.string().uuid() }); + +// DELETE /api/orgs/:orgId/invites/:inviteId — 초대 취소(대표 전용, 021). 취소한 링크로는 가입할 수 없다(410 INVITE_REVOKED). +invitesRouter.delete( + '/orgs/:orgId/invites/:inviteId', + validate({ params: inviteIdParamsSchema }), + authenticate, + requireSameOrg, + requireRepresentative, + async (req, res) => { + const { orgId, inviteId } = req.params as z.infer; + res.status(200).json({ data: await revokeInvite(orgId, req.user!.id, inviteId) }); + }, +); + const tokenParamsSchema = z.object({ token: z.string().min(1) }); // GET /api/invites/:token — 수락 전 미리보기. 인증 불필요. 토큰이 유효하지 않아도 항상 200 + diff --git a/src/routes/orgs.ts b/src/routes/orgs.ts index 8c9d3f6..404603d 100644 --- a/src/routes/orgs.ts +++ b/src/routes/orgs.ts @@ -2,7 +2,7 @@ import { Router } from 'express'; import { z } from 'zod'; import { authenticate, requireSameOrg } from '../middleware/auth.js'; import { validate } from '../middleware/validate.js'; -import { createOrganization, listAvailableGithubRepos, listMembers } from '../domain/org/service.js'; +import { createOrganization, listAvailableGithubRepos, listMembers, removeOrgMember } from '../domain/org/service.js'; export const orgsRouter = Router(); @@ -46,3 +46,20 @@ orgsRouter.get( res.status(200).json({ data: { members } }); }, ); + +const memberParamsSchema = z.object({ orgId: z.string().uuid(), userId: z.string().uuid() }); + +// DELETE /api/orgs/:orgId/members/:userId — 조직에서 멤버 내보내기(대표) 또는 본인 탈퇴(userId = 나, 021). +// 계정은 남고 조직만 빠진다. 그 사람의 에이전트는 진행 중 프로젝트에서 빠지고 연결이 해제된다(진행 중 태스크가 있으면 409 ACTIVE_TASKS_HELD). +// 대표는 나갈 수 없다(409 REPRESENTATIVE_CANNOT_LEAVE). +orgsRouter.delete( + '/orgs/:orgId/members/:userId', + validate({ params: memberParamsSchema }), + authenticate, + requireSameOrg, + async (req, res) => { + const { orgId, userId } = req.params as z.infer; + const user = req.user!; + res.status(200).json({ data: await removeOrgMember({ userId: user.id, orgId, orgRole: user.orgRole }, orgId, userId) }); + }, +); diff --git a/src/routes/projects.ts b/src/routes/projects.ts index 8108e37..727b74d 100644 --- a/src/routes/projects.ts +++ b/src/routes/projects.ts @@ -8,7 +8,8 @@ import { createProject, getProject, listProjects, - unassignMember, + unassignMemberWithRevocations, + abortProject, type Actor, } from '../domain/project/service.js'; import { listProjectEventsForUser } from '../domain/events/query.js'; @@ -114,8 +115,7 @@ projectsRouter.delete( requireRepresentative, async (req, res) => { const { projectId, agentId } = req.params as z.infer; - const members = await unassignMember(actorOf(req), projectId, agentId); - res.status(200).json({ data: { members } }); + res.status(200).json({ data: await unassignMemberWithRevocations(actorOf(req), projectId, agentId) }); }, ); @@ -180,3 +180,19 @@ projectsRouter.get( res.status(200).json({ data: await getProject(actorOf(req), projectId) }); }, ); + +const abortBodySchema = z.object({ reason: z.string().trim().max(500).nullable().optional() }); + +// POST /api/projects/:projectId/abort — 프로젝트 중단(대표 전용, 021). 지우지 않고 aborted로 닫는다. +// 에이전트·레포가 풀리고, 대기 질문은 만료로 닫히며, 대기 승인 카드는 결정할 수 없게 된다. 끝난 프로젝트는 409 PROJECT_NOT_OPEN. +projectsRouter.post( + '/projects/:projectId/abort', + validate({ params: projectIdParamsSchema, body: abortBodySchema }), + authenticate, + requireRepresentative, + async (req, res) => { + const { projectId } = req.params as z.infer; + const { reason } = req.body as z.infer; + res.status(200).json({ data: { project: await abortProject(actorOf(req), projectId, reason ?? null) } }); + }, +); diff --git a/src/routes/repos.ts b/src/routes/repos.ts index a47b0a3..410b01b 100644 --- a/src/routes/repos.ts +++ b/src/routes/repos.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { TEAM_ROLES } from '../domain/roles.js'; import { authenticate, orgIdOf, requireRepresentative, requireSameOrg } from '../middleware/auth.js'; import { validate } from '../middleware/validate.js'; -import { connectRepos, createGithubRepo, listGithubOrgs, listRepos, updateRepoSettings } from '../domain/repo/service.js'; +import { connectRepos, createGithubRepo, disconnectRepo, listGithubOrgs, listRepos, updateRepoSettings } from '../domain/repo/service.js'; export const reposRouter = Router(); @@ -128,3 +128,16 @@ reposRouter.patch( res.status(200).json({ data: { repo } }); }, ); + +// DELETE /api/repos/:repoId — 레포 연결 해제(대표, 021). 진행 중 프로젝트가 쓰면 409 REPO_IN_ACTIVE_PROJECT. +// GitHub의 레포는 그대로다. 같은 레포를 다시 연결하면 경로 규칙·소유권과 함께 되살아난다. +reposRouter.delete( + '/repos/:repoId', + validate({ params: repoIdParamsSchema }), + authenticate, + requireRepresentative, + async (req, res) => { + const { repoId } = req.params as z.infer; + res.status(200).json({ data: await disconnectRepo(orgIdOf(req), req.user!.id, repoId) }); + }, +); diff --git a/tests/github-repo-create.test.ts b/tests/github-repo-create.test.ts index d40ad48..2e248e1 100644 --- a/tests/github-repo-create.test.ts +++ b/tests/github-repo-create.test.ts @@ -43,6 +43,10 @@ function fakeApi(overrides: Partial = {}): GithubRepApi { calls.push({ method: 'inviteCollaborator', args }); return 'invited'; }, + async revokeCollaborator(...args) { + calls.push({ method: 'revokeCollaborator', args }); + return 'removed'; + }, async listRepos(...args) { calls.push({ method: 'listRepos', args }); return [{ fullName: 'acme-gh/existing', githubRepoId: 4242, defaultBranch: 'develop' }]; @@ -333,6 +337,65 @@ describe('GitHub 호출 모양 (rep-api)', () => { expect(invited.reqs[0]).toMatchObject({ method: 'PUT', url: 'https://gh.test/repos/acme-gh/shop/collaborators/octo', body: { permission: 'push' } }); await expect(recorder([[204, null]]).api.inviteCollaborator('tok', 'acme-gh/shop', 'octo')).resolves.toBe('already_collaborator'); }); + + it('회수: 수락 안 한 초대가 있으면 초대를 취소하고, 없으면 협업자에서 뺀다(404는 원래 아님)', async () => { + const pending = recorder([[200, [{ id: 9, invitee: { login: 'Octo' } }]], [204, null]]); + await expect(pending.api.revokeCollaborator('tok', 'acme-gh/shop', 'octo')).resolves.toBe('invitation_cancelled'); + expect(pending.reqs.map((r) => `${r.method} ${r.url}`)).toEqual([ + 'GET https://gh.test/repos/acme-gh/shop/invitations?per_page=100', + 'DELETE https://gh.test/repos/acme-gh/shop/invitations/9', + ]); + const member = recorder([[200, []], [204, null]]); + await expect(member.api.revokeCollaborator('tok', 'acme-gh/shop', 'octo')).resolves.toBe('removed'); + expect(member.reqs[1]).toMatchObject({ method: 'DELETE', url: 'https://gh.test/repos/acme-gh/shop/collaborators/octo' }); + await expect(recorder([[200, []], [404, { message: 'Not Found' }]]).api.revokeCollaborator('tok', 'acme-gh/shop', 'octo')).resolves.toBe('not_collaborator'); + }); +}); + +describe('빠진 사람의 GitHub 접근 회수 (021)', () => { + async function setupAssigned() { + const w = await world(); + await linkGithub(w.rep.userId, 1, 'octo-rep'); + await linkGithub(w.be.userId, 2, 'octo-be'); + const created = await http('POST', `/orgs/${w.orgId}/github/repos`, w.rep.token, { githubOrg: 'acme-gh', name: 'shop-api', ownerRole: 'BACKEND' }); + const repoId = (created.body.data!.repo as { id: string }).id; + const project = await createProject(w.orgId, w.rep.userId, { name: 'p', autonomyPreset: 'L2', pmBudgetUsd: 1, repoIds: [repoId] }); + await http('POST', `/projects/${project.project.id}/members`, w.rep.token, { agentId: w.beAgentId, teamRole: 'BACKEND' }); + calls = []; + return { w, repoId, projectId: project.project.id }; + } + + it('프로젝트에서 빼면 그 프로젝트의 GitHub 레포에서 협업자를 회수하고 결과를 응답·이벤트로 남긴다', async () => { + const { w, repoId, projectId } = await setupAssigned(); + const res = await http('DELETE', `/projects/${projectId}/members/${w.beAgentId}`, w.rep.token); + expect(res.status).toBe(200); + expect(res.body.data!.githubRevocations).toEqual([{ repoId, fullName: 'acme-gh/shop-api', status: 'removed' }]); + expect(calls).toEqual([{ method: 'revokeCollaborator', args: ['gho_octo-rep', 'acme-gh/shop-api', 'octo-be'] }]); + const { rows } = await testPool.query(`SELECT on_behalf_of, payload FROM events WHERE type = 'GITHUB_COLLABORATORS_REMOVED'`); + expect(rows[0].on_behalf_of).toBe(w.rep.userId); + expect(rows[0].payload).toMatchObject({ agentId: w.beAgentId, githubLogin: 'octo-be', trigger: 'unassign' }); + expect(JSON.stringify(rows[0].payload)).not.toContain('gho_'); + }); + + it('조직에서 내보내면 조직의 GitHub 레포 전부에서 회수한다 — GitHub가 실패해도 탈퇴는 그대로다', async () => { + const { w } = await setupAssigned(); + setGithubRepApi(fakeApi({ async revokeCollaborator() { throw new Error('GitHub 500'); } })); + const res = await http('DELETE', `/orgs/${w.orgId}/members/${w.be.userId}`, w.rep.token); + expect(res.status).toBe(200); + expect(res.body.data!.githubRevocations).toEqual([expect.objectContaining({ fullName: 'acme-gh/shop-api', status: 'failed', reason: 'GitHub 500' })]); + expect((await http('GET', '/me', w.be.token)).body.data).toMatchObject({ orgId: null }); + }); + + it('대표 본인의 에이전트를 빼도 대표의 접근은 회수하지 않는다', async () => { + const { w, projectId } = await setupAssigned(); + await http('DELETE', `/projects/${projectId}/members/${w.beAgentId}`, w.rep.token); + const repAgent = await connectAgent({ connectKey: w.rep.connectKey, agentName: 'rep-laptop', harness: 'claude-code', skills: [], maxConcurrent: 1 }); + await http('POST', `/projects/${projectId}/members`, w.rep.token, { agentId: repAgent.agentId, teamRole: 'BACKEND' }); + calls = []; + const res = await http('DELETE', `/projects/${projectId}/members/${repAgent.agentId}`, w.rep.token); + expect(res.body.data!.githubRevocations).toEqual([]); + expect(calls.filter((c) => c.method === 'revokeCollaborator')).toEqual([]); + }); }); describe('GitHub 읽기는 그 조직 대표의 토큰으로 — 서버 공용 PAT를 쓰지 않는다', () => { diff --git a/tests/openapi-contract.test.ts b/tests/openapi-contract.test.ts index 3b55d6e..274bf16 100644 --- a/tests/openapi-contract.test.ts +++ b/tests/openapi-contract.test.ts @@ -99,6 +99,9 @@ const fakeGithubRep: GithubRepApi = { async inviteCollaborator() { return 'invited'; }, + async revokeCollaborator() { + return 'removed'; + }, async listRepos() { return [{ fullName: 'acme/study-api', githubRepoId: 987654321, defaultBranch: 'main' }]; }, @@ -180,6 +183,16 @@ describe('모든 성공 응답을 실제로 받아 문서와 대조한다', () = await call('POST', `/invites/${inviteToken}/accept`, be); await call('GET', '/me', rep); await call('GET', `/orgs/${orgId}/members`, rep); + // 초대 목록·취소(021) + await call('POST', `/orgs/${orgId}/invites`, rep, {}); + const pending = (await call('GET', `/orgs/${orgId}/invites`, rep)).data.invites as { inviteId: string }[]; + await call('DELETE', `/orgs/${orgId}/invites/${pending[0]!.inviteId}`, rep); + // 조직에서 내보내기(021) — 들어왔다가 나가는 멤버 하나 + const temp = await call('POST', '/auth/signup', null, { loginId: 'temp-dev', password: pw, nickname: '잠깐' }); + const tempToken = (await call('POST', '/auth/login', null, { loginId: 'temp-dev', password: pw })).data.accessToken as string; + const tempInvite = await call('POST', `/orgs/${orgId}/invites`, rep, {}); + await call('POST', `/invites/${tempInvite.data.token as string}/accept`, tempToken); + await call('DELETE', `/orgs/${orgId}/members/${temp.data.userId as string}`, rep); // CLI 연결 — 대표 것은 배정 해제 확인용 const beConn = await call('POST', '/agents/connect', null, { @@ -198,7 +211,9 @@ describe('모든 성공 응답을 실제로 받아 문서와 대조한다', () = await call('GET', `/agents/device/requests/${userCode}`, rep); await call('POST', `/agents/device/requests/${userCode}/approve`, rep); await pool.query(`UPDATE agent_device_requests SET last_polled_at = NULL`); // poll 간격 검사를 건너뛴다 - await call('POST', '/agents/device/poll', null, { deviceCode }); + const approved = await call('POST', '/agents/device/poll', null, { deviceCode }); + // 에이전트 연결 해제(021) — 브라우저 승인으로 연결한 노트북 + await call('DELETE', `/agents/${approved.data.agentId as string}`, rep); const denied = await call('POST', '/agents/device/start', null, { agentName: 'other-laptop', harness: 'claude-code' }); await call('POST', `/agents/device/requests/${denied.data.userCode as string}/deny`, rep); @@ -219,7 +234,9 @@ describe('모든 성공 응답을 실제로 받아 문서와 대조한다', () = await call('GET', `/orgs/${orgId}/repos`, rep); // GitHub 조직에 레포 만들기(대표) — 프로젝트에는 위 레포만 넣는다. await call('GET', `/orgs/${orgId}/github/orgs`, rep); - await call('POST', `/orgs/${orgId}/github/repos`, rep, { githubOrg: 'acme', name: 'study-web', ownerRole: 'FRONTEND' }); + const ghRepo = await call('POST', `/orgs/${orgId}/github/repos`, rep, { githubOrg: 'acme', name: 'study-web', ownerRole: 'FRONTEND' }); + // 레포 연결 해제(021) — 프로젝트에 넣지 않은 레포 + await call('DELETE', `/repos/${(ghRepo.data.repo as { id: string }).id}`, rep); // 프로젝트·배정 const created = await call('POST', `/orgs/${orgId}/projects`, rep, { @@ -402,6 +419,9 @@ describe('모든 성공 응답을 실제로 받아 문서와 대조한다', () = } finally { mutableEnv.PM_PROVIDER = 'api'; } + + // 프로젝트 중단(021) — 흐름의 맨 끝 + await call('POST', `/projects/${projectId}/abort`, rep, { reason: '계약 테스트 끝' }); }); it('문서의 모든 성공 응답을 위 흐름에서 한 번 이상 받았다', () => { diff --git a/tests/project-start.test.ts b/tests/project-start.test.ts index 85ff230..04f26f0 100644 --- a/tests/project-start.test.ts +++ b/tests/project-start.test.ts @@ -112,7 +112,7 @@ describe('프로젝트 시작(G1)', () => { expect((await pool.query(`SELECT status, started_at FROM projects WHERE id = $1`, [w.projectId])).rows[0]).toEqual({ status: 'planning', started_at: null }); }); - it('시작하면 active·startedAt, 명세 승인 시각, PROJECT_STARTED가 남고 멤버가 고정된다', async () => { + it('시작하면 active·startedAt, 명세 승인 시각, PROJECT_STARTED가 남는다', async () => { const w = await world(); const agent = await backendAgent(w); await backendTask(w); @@ -134,9 +134,8 @@ describe('프로젝트 시작(G1)', () => { approvedPlanIds: [], }); - // 다시 시작 409, 멤버 변경 403, 팀원은 시작할 수 없다. + // 다시 시작 409, 팀원은 시작할 수 없다. 시작 뒤 멤버 교체는 된다(021 — tests/removals.test.ts). expect((await call('POST', `/projects/${w.projectId}/start`, w.rep.token)).body.error!.code).toBe('PROJECT_ALREADY_STARTED'); - expect((await call('DELETE', `/projects/${w.projectId}/members/${agent.agentId}`, w.rep.token)).status).toBe(403); expect((await call('POST', `/projects/${w.projectId}/start`, w.be.token)).status).toBe(403); }); }); diff --git a/tests/projects.test.ts b/tests/projects.test.ts index 9e89f27..73bbcba 100644 --- a/tests/projects.test.ts +++ b/tests/projects.test.ts @@ -290,19 +290,20 @@ describe('멤버 배정', () => { ).rejects.toMatchObject({ code: 'AGENT_NOT_IN_ORG' }); }); - it('G1(started_at) 이후에는 배정도 해제도 막힌다', async () => { + it('끝난 프로젝트(completed·aborted)는 배정도 해제도 막힌다 — 시작 뒤 교체는 된다(021, tests/removals.test.ts)', async () => { const ctx = await setup(); const { project } = await createProject(ctx.orgId, ctx.userId, input(ctx)); const be = await addMember(ctx, 'be', 'be-laptop'); await assignMember(ctx.actor, project.id, be.agentId, 'BACKEND'); - await pool.query(`UPDATE projects SET started_at = now(), status = 'active' WHERE id = $1`, [project.id]); + // 프로젝트를 끝내는 서비스 경로(G3 승인)는 태스크·검증까지 필요해 상태만 바꾼다. + await pool.query(`UPDATE projects SET started_at = now(), status = 'completed' WHERE id = $1`, [project.id]); await expect(assignMember(ctx.actor, project.id, be.agentId, 'FRONTEND')).rejects.toMatchObject({ - code: 'PROJECT_STARTED', - status: 403, + code: 'PROJECT_NOT_OPEN', + status: 409, }); await expect(unassignMember(ctx.actor, project.id, be.agentId)).rejects.toMatchObject({ - code: 'PROJECT_STARTED', + code: 'PROJECT_NOT_OPEN', }); }); diff --git a/tests/removals.test.ts b/tests/removals.test.ts new file mode 100644 index 0000000..f6e7227 --- /dev/null +++ b/tests/removals.test.ts @@ -0,0 +1,265 @@ +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, refreshAgentToken } from '../src/domain/agent/service.js'; +import { login, signup } from '../src/domain/auth/service.js'; +import { createTask } from '../src/domain/authoring/service.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 { resetSchema, testPool, truncateAll } from './test-db.js'; + +// 해제·삭제(021) — 에이전트 연결 해제, 조직에서 내보내기·탈퇴, 시작 뒤 멤버 교체, 레포 연결 해제, 초대 취소. +// 공통 규칙: 행은 지우지 않는다(기록이 가리킨다). 진행 중 태스크를 붙잡은 에이전트는 빼지 않는다(409 ACTIVE_TASKS_HELD). + +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(); +}); + +afterAll(async () => { + 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; details?: unknown } }; + +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; details?: unknown } }; + 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 spec = (name: string) => ({ agentName: name, harness: 'claude-code', skills: [], maxConcurrent: 1 }); + +// 대표·BE 팀원·FE 팀원, 레포 하나(** = BACKEND), 프로젝트 하나(BE 배정, 태스크 하나). +async function world(options: { started?: boolean } = {}) { + const rep = await account('rep'); + const { orgId } = await createOrganization(rep.userId, 'Acme'); + const be = await account('be'); + const fe = await account('fe'); + for (const u of [be, fe]) { + const { token } = await createInvite(orgId, rep.userId); + await acceptInvite(token, u.userId); + } + const actor = { userId: rep.userId, orgId, orgRole: 'REPRESENTATIVE' as const }; + const [repo] = await connectRepos({ orgId, actorUserId: rep.userId, actorOrgRole: 'REPRESENTATIVE', repos: [{ fullName: 'acme/api', ownerRole: 'BACKEND' }] }); + const { project } = await createProject(orgId, rep.userId, { name: 'p', autonomyPreset: 'L2', pmBudgetUsd: 1, repoIds: [repo!.id] }); + const beAgent = await connectAgent({ connectKey: be.connectKey, ...spec('be-laptop') }); + const feAgent = await connectAgent({ connectKey: fe.connectKey, ...spec('fe-laptop') }); + await assignMember(actor, project.id, beAgent.agentId, 'BACKEND'); + const task = await createTask(rep.userId, project.id, { title: 'T-1', teamRole: 'BACKEND', kind: 'INTEGRATION', repoId: repo!.id, specId: null, dependsOn: [] }); + if (options.started) await startProject(actor, project.id); + return { rep, be, fe, orgId, actor, repoId: repo!.id, projectId: project.id, taskId: task.id, beAgent, feAgent }; +} + +const agentToken = async (refreshToken: string) => (await refreshAgentToken(refreshToken)).accessToken; + +describe('에이전트 연결 해제 (DELETE /agents/:agentId)', () => { + it('주인이 끊으면 토큰이 거부되고 목록에서 빠진다 — 같은 노트북 이름으로 다시 connect하면 되살아난다', async () => { + const w = await world(); + const before = await agentToken(w.feAgent.refreshToken); + const res = await http('DELETE', `/agents/${w.feAgent.agentId}`, w.fe.token); + expect(res.status).toBe(200); + expect(res.data).toMatchObject({ agentId: w.feAgent.agentId, unassignedFrom: null }); + + // 남은 access token은 거부, refresh token은 폐기 + const me = await http('GET', '/agents/me', before); + expect(me).toMatchObject({ status: 401, error: { code: 'AGENT_DISCONNECTED' } }); + await expect(refreshAgentToken(w.feAgent.refreshToken)).rejects.toMatchObject({ code: 'INVALID_REFRESH_TOKEN' }); + const listed = (await http('GET', `/orgs/${w.orgId}/agents`, w.rep.token)).data.agents as { agentId: string }[]; + expect(listed.map((a) => a.agentId)).not.toContain(w.feAgent.agentId); + // 두 번 끊으면 404 + expect((await http('DELETE', `/agents/${w.feAgent.agentId}`, w.fe.token)).status).toBe(404); + + // 같은 이름으로 다시 연결 → 같은 에이전트가 되살아난다 + const again = await connectAgent({ connectKey: w.fe.connectKey, ...spec('fe-laptop') }); + expect(again.agentId).toBe(w.feAgent.agentId); + const relisted = (await http('GET', `/orgs/${w.orgId}/agents`, w.rep.token)).data.agents as { agentId: string }[]; + expect(relisted.map((a) => a.agentId)).toContain(w.feAgent.agentId); + + const { rows } = await testPool.query(`SELECT on_behalf_of, payload FROM events WHERE type = 'AGENT_DISCONNECTED'`); + expect(rows).toEqual([{ on_behalf_of: w.fe.userId, payload: expect.objectContaining({ agentId: w.feAgent.agentId, byOwner: true }) }]); + }); + + it('대표는 멤버의 에이전트를 끊을 수 있다 — 시작한 프로젝트 배정도 함께 풀린다. 다른 멤버는 403', async () => { + const w = await world({ started: true }); + expect((await http('DELETE', `/agents/${w.beAgent.agentId}`, w.fe.token))).toMatchObject({ status: 403, error: { code: 'NOT_AGENT_OWNER' } }); + + const res = await http('DELETE', `/agents/${w.beAgent.agentId}`, w.rep.token); + expect(res).toMatchObject({ status: 200, data: { unassignedFrom: w.projectId } }); + const members = (await http('GET', `/projects/${w.projectId}`, w.rep.token)).data.members as unknown[]; + expect(members).toEqual([]); + const { rows } = await testPool.query(`SELECT payload FROM events WHERE type = 'MEMBER_UNASSIGNED'`); + expect(rows[0]!.payload).toMatchObject({ agentId: w.beAgent.agentId, cause: 'agent_disconnected' }); + }); + + it('진행 중 태스크를 붙잡고 있으면 409 ACTIVE_TASKS_HELD(태스크 목록) — 연결은 그대로', async () => { + const w = await world({ started: true }); + const token = await agentToken(w.beAgent.refreshToken); + expect((await http('POST', `/tasks/${w.taskId}/claim`, token)).status).toBe(200); + const res = await http('DELETE', `/agents/${w.beAgent.agentId}`, w.be.token); + expect(res).toMatchObject({ status: 409, error: { code: 'ACTIVE_TASKS_HELD', details: [{ taskId: w.taskId, state: 'CLAIMED' }] } }); + expect((await http('GET', '/agents/me', token)).status).toBe(200); + }); +}); + +describe('시작 뒤 멤버 교체 (021)', () => { + it('진행 중 태스크가 없으면 시작 뒤에도 빼고, 다른 에이전트를 같은 역할에 배정할 수 있다', async () => { + const w = await world({ started: true }); + expect((await http('DELETE', `/projects/${w.projectId}/members/${w.beAgent.agentId}`, w.rep.token)).status).toBe(200); + const res = await http('POST', `/projects/${w.projectId}/members`, w.rep.token, { agentId: w.feAgent.agentId, teamRole: 'BACKEND' }); + expect(res.status).toBe(201); + // 새 담당이 그 역할 태스크를 가져간다(배정 뒤 재발급) + const token = await agentToken(w.feAgent.refreshToken); + expect((await http('POST', `/tasks/${w.taskId}/claim`, token)).status).toBe(200); + }); + + it('진행 중 태스크를 붙잡은 멤버는 뺄 수 없다', async () => { + const w = await world({ started: true }); + await http('POST', `/tasks/${w.taskId}/claim`, await agentToken(w.beAgent.refreshToken)); + const res = await http('DELETE', `/projects/${w.projectId}/members/${w.beAgent.agentId}`, w.rep.token); + expect(res).toMatchObject({ status: 409, error: { code: 'ACTIVE_TASKS_HELD' } }); + }); +}); + +describe('조직에서 내보내기·탈퇴 (DELETE /orgs/:orgId/members/:userId)', () => { + it('대표가 내보내면 계정은 남고 조직만 비며, 에이전트는 배정이 풀리고 연결이 끊긴다 — 다른 조직에 다시 들어갈 수 있다', async () => { + const w = await world({ started: true }); + const beToken = await agentToken(w.beAgent.refreshToken); + const res = await http('DELETE', `/orgs/${w.orgId}/members/${w.be.userId}`, w.rep.token); + expect(res).toMatchObject({ status: 200, data: { userId: w.be.userId, agentIds: [w.beAgent.agentId], unassignedFrom: [w.projectId] } }); + + expect((await http('GET', '/me', w.be.token)).data).toMatchObject({ orgId: null, orgRole: null }); + expect((await http('GET', '/agents/me', beToken)).error?.code).toBe('AGENT_DISCONNECTED'); + const members = (await http('GET', `/orgs/${w.orgId}/members`, w.rep.token)).data.members as { userId: string }[]; + expect(members.map((m) => m.userId)).not.toContain(w.be.userId); + + // 다른 조직의 초대를 받을 수 있다 + const other = await account('other-rep'); + const { orgId: otherOrg } = await createOrganization(other.userId, 'Other'); + const { token } = await createInvite(otherOrg, other.userId); + expect((await http('POST', `/invites/${token}/accept`, w.be.token)).status).toBe(200); + expect((await http('GET', '/me', w.be.token)).data).toMatchObject({ orgId: otherOrg, orgRole: 'MEMBER' }); + }); + + it('멤버는 스스로 나갈 수 있고, 남을 내보낼 수는 없다. 대표는 나갈 수 없다', async () => { + const w = await world(); + expect((await http('DELETE', `/orgs/${w.orgId}/members/${w.fe.userId}`, w.be.token))).toMatchObject({ status: 403, error: { code: 'NOT_REPRESENTATIVE' } }); + expect((await http('DELETE', `/orgs/${w.orgId}/members/${w.rep.userId}`, w.rep.token))).toMatchObject({ status: 409, error: { code: 'REPRESENTATIVE_CANNOT_LEAVE' } }); + expect((await http('DELETE', `/orgs/${w.orgId}/members/${w.fe.userId}`, w.fe.token)).status).toBe(200); + const { rows } = await testPool.query(`SELECT on_behalf_of, payload FROM events WHERE type = 'ORG_MEMBER_REMOVED'`); + expect(rows).toEqual([{ on_behalf_of: w.fe.userId, payload: expect.objectContaining({ userId: w.fe.userId, self: true }) }]); + }); + + it('진행 중 태스크를 붙잡은 멤버는 내보낼 수 없다 — 아무것도 바뀌지 않는다', async () => { + const w = await world({ started: true }); + await http('POST', `/tasks/${w.taskId}/claim`, await agentToken(w.beAgent.refreshToken)); + expect((await http('DELETE', `/orgs/${w.orgId}/members/${w.be.userId}`, w.rep.token)).error?.code).toBe('ACTIVE_TASKS_HELD'); + expect((await http('GET', '/me', w.be.token)).data).toMatchObject({ orgId: w.orgId }); + }); +}); + +describe('레포 연결 해제 (DELETE /repos/:repoId)', () => { + it('진행 중 프로젝트가 쓰면 409, 아니면 해제 — 다시 연결하면 같은 행이 경로 규칙·소유권과 함께 되살아난다', async () => { + const w = await world(); + expect((await http('DELETE', `/repos/${w.repoId}`, w.rep.token)).error?.code).toBe('REPO_IN_ACTIVE_PROJECT'); + + const [spare] = await connectRepos({ orgId: w.orgId, actorUserId: w.rep.userId, actorOrgRole: 'REPRESENTATIVE', repos: [{ fullName: 'acme/web', ownerRole: 'FRONTEND' }] }); + expect((await http('DELETE', `/repos/${spare!.id}`, w.be.token)).status).toBe(403); // 대표 전용 + expect((await http('DELETE', `/repos/${spare!.id}`, w.rep.token))).toMatchObject({ status: 200, data: { repoId: spare!.id, fullName: 'acme/web' } }); + const listed = (await http('GET', `/orgs/${w.orgId}/repos`, w.rep.token)).data.repos as { id: string }[]; + expect(listed.map((r) => r.id)).not.toContain(spare!.id); + expect((await http('GET', `/repos/${spare!.id}/paths`, w.rep.token)).error?.code).toBe('REPO_NOT_FOUND'); + + const again = await http('POST', `/orgs/${w.orgId}/repos`, w.rep.token, { repos: [{ fullName: 'acme/web' }] }); + expect(again.status).toBe(201); + expect((again.data.repos as { id: string; rootOwnerRole: string | null }[])[0]).toMatchObject({ id: spare!.id }); + const paths = (await http('GET', `/repos/${spare!.id}/paths`, w.rep.token)).data.paths as { pathPattern: string; ownerRole: string | null }[]; + expect(paths.find((p) => p.pathPattern === '**')?.ownerRole).toBe('FRONTEND'); + const { rows } = await testPool.query(`SELECT payload FROM events WHERE type = 'REPO_CONNECTED' ORDER BY id DESC LIMIT 1`); + expect(rows[0]!.payload).toMatchObject({ repoId: spare!.id, revived: true, seededPathCount: 0 }); + }); +}); + +describe('초대 목록·취소 (GET·DELETE /orgs/:orgId/invites)', () => { + it('대기 중인 초대만 보이고, 취소하면 미리보기는 revoked·수락은 410이다. 쓰인 초대는 취소할 수 없다', async () => { + const w = await world(); + const created = await http('POST', `/orgs/${w.orgId}/invites`, w.rep.token, { teamRole: 'FRONTEND' }); + const inviteId = created.data.inviteId as string; + const token = created.data.token as string; + const listed = (await http('GET', `/orgs/${w.orgId}/invites`, w.rep.token)).data.invites as { inviteId: string; expired: boolean }[]; + expect(listed.map((i) => i.inviteId)).toEqual([inviteId]); // be·fe가 쓴 초대는 빠진다 + expect((await http('GET', `/orgs/${w.orgId}/invites`, w.be.token)).status).toBe(403); + + expect((await http('DELETE', `/orgs/${w.orgId}/invites/${inviteId}`, w.rep.token)).status).toBe(200); + expect((await http('GET', `/invites/${token}`, null)).data).toMatchObject({ valid: false, reason: 'revoked' }); + const newcomer = await account('newcomer'); + expect((await http('POST', `/invites/${token}/accept`, newcomer.token))).toMatchObject({ status: 410, error: { code: 'INVITE_REVOKED' } }); + expect((await http('GET', `/orgs/${w.orgId}/invites`, w.rep.token)).data.invites).toEqual([]); + + // 이미 쓰인 초대(be가 수락한 것)는 404 + const { rows } = await testPool.query(`SELECT id FROM invites WHERE used_by = $1`, [w.be.userId]); + expect((await http('DELETE', `/orgs/${w.orgId}/invites/${rows[0]!.id as string}`, w.rep.token)).status).toBe(404); + }); +}); + +describe('프로젝트 중단 (POST /projects/:projectId/abort)', () => { + it('중단하면 aborted로 닫히고 에이전트·레포가 풀린다 — 룸에 사유가 남고, 다시 중단하면 409', async () => { + const w = await world({ started: true }); + const token = await agentToken(w.beAgent.refreshToken); + expect((await http('POST', `/projects/${w.projectId}/abort`, w.be.token, {})).status).toBe(403); // 대표 전용 + + const res = await http('POST', `/projects/${w.projectId}/abort`, w.rep.token, { reason: '범위가 바뀌었다' }); + expect(res).toMatchObject({ status: 200, data: { project: { id: w.projectId, status: 'aborted' } } }); + expect((await http('POST', `/projects/${w.projectId}/abort`, w.rep.token, {})).error?.code).toBe('PROJECT_NOT_OPEN'); + + // 에이전트는 이 프로젝트에서 풀린다(다른 프로젝트에 배정 가능) + expect((await http('GET', '/agents/me/tasks', token)).error?.code).toBe('NOT_PROJECT_MEMBER'); + // 레포를 새 프로젝트에 쓸 수 있다 + const next = await http('POST', `/orgs/${w.orgId}/projects`, w.rep.token, { name: 'p2', autonomyPreset: 'L2', pmBudgetUsd: 1, repoIds: [w.repoId] }); + expect(next.status).toBe(201); + const p2 = (next.data.project as { id: string }).id; + expect((await http('POST', `/projects/${p2}/members`, w.rep.token, { agentId: w.beAgent.agentId, teamRole: 'BACKEND' })).status).toBe(201); + + const feed = (await http('GET', `/projects/${w.projectId}/rooms/BACKEND/feed`, w.rep.token)).data.messages as { text: string }[]; + expect(feed.map((m) => m.text)).toContain('프로젝트가 중단되었습니다: 범위가 바뀌었다'); + const { rows } = await testPool.query(`SELECT payload FROM events WHERE type = 'PROJECT_ABORTED'`); + expect(rows[0]!.payload).toMatchObject({ previousStatus: 'active', reason: '범위가 바뀌었다', openTaskCount: 1, expiredQuestionIds: [] }); + }); + + it('중단한 프로젝트의 대기 질문은 만료로 닫힌다', async () => { + const w = await world({ started: true }); + const token = await agentToken(w.beAgent.refreshToken); + await http('POST', `/tasks/${w.taskId}/claim`, token); + const q = '응답 필드는?'; + const asked = await http('POST', `/tasks/${w.taskId}/questions`, token, { questions: [{ question: q, header: '필드', options: [{ label: 'a' }, { label: 'b' }], multiSelect: false }] }); + expect(asked.status).toBe(201); + await http('POST', `/projects/${w.projectId}/abort`, w.rep.token, {}); + const { rows } = await testPool.query(`SELECT status FROM agent_questions WHERE id = $1`, [asked.data.id]); + expect(rows[0]).toEqual({ status: 'expired' }); + }); +});