diff --git a/docs/conventions.md b/docs/conventions.md index c03abcf..a0034e4 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -40,7 +40,7 @@ src/ **`src/lib/`은 레이어가 아니다.** `store.tsx`와 `mock/seed.ts`만 남아 있고, 해체 예정이라(`architecture.md` §8) 이번 이행에서 일부러 옮기지 않았다. 여기에 새 파일을 추가하지 말 것. -엔티티 슬라이스 17개: `task` `agent` `user` `org` `invite` `project` `repo` `plan` `artifact` `note` `room` `message` `document` `event` `policy` `approval` `spec`. +엔티티 슬라이스 18개: `task` `agent` `user` `org` `invite` `project` `repo` `plan` `artifact` `note` `room` `message` `document` `event` `policy` `approval` `spec` `question`. 위젯 슬라이스 20개: `room` `pm-room` `dashboard` `inbox` `activity` `docs` `settings` `account-settings` `landing` `invite` `org` `repo-paths` `app-shell` `auth` `connect-agent` `connect-device` `onboarding` `project-list` `project-new` `task-detail`. **import는 `app → widgets → entities → shared` 단방향만.** 같은 레이어끼리 import 금지. diff --git a/src/app/p/[projectId]/rooms/[roomId]/page.tsx b/src/app/p/[projectId]/rooms/[roomId]/page.tsx index 9d5e927..8adbda2 100644 --- a/src/app/p/[projectId]/rooms/[roomId]/page.tsx +++ b/src/app/p/[projectId]/rooms/[roomId]/page.tsx @@ -1,6 +1,10 @@ -import { RoomView } from "@/widgets/room"; +import { RoleRoomView, RoomView } from "@/widgets/room"; +import { roleOfRoomSlug } from "@/entities/room"; +// frontend·backend는 역할 룸(API). 그 밖의 id는 목업 Room이다 export default async function RoomPage({ params }: { params: Promise<{ projectId: string; roomId: string }> }) { const { projectId, roomId } = await params; + const role = roleOfRoomSlug(roomId); + if (role) return ; return ; } diff --git a/src/entities/approval/ui/ApprovalCard.tsx b/src/entities/approval/ui/ApprovalCard.tsx index 47f5a5b..ea9e853 100644 --- a/src/entities/approval/ui/ApprovalCard.tsx +++ b/src/entities/approval/ui/ApprovalCard.tsx @@ -14,9 +14,25 @@ const MAX_REASON = 2000; const strings = (v: unknown) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []); +interface ReleaseTask { + taskId: string; + title: string; + teamRole: string | null; + repo: string | null; + branchName: string | null; + commitSha: string | null; +} + +/** G3 payload.tasks: 대표가 머지하고 실행해 볼 브랜치와 마지막 커밋. 형식이 다른 항목은 버린다 */ +function releaseTasks(v: unknown): ReleaseTask[] { + if (!Array.isArray(v)) return []; + return v.filter((t): t is ReleaseTask => typeof t === "object" && t !== null && typeof t.title === "string"); +} + /** - * ACTION 승인 카드. 검증은 통과했지만 정책 판정이 HUMAN·PM_REVIEW라 멈춘 산출물이다. - * 결정은 대표만(canDecide). 승인하면 태스크 DONE, 반려하면 READY로 돌아가 재시도 +1 (3회째 ESCALATED) + * 승인 카드. 결정은 대표만(canDecide). + * - ACTION: 검증은 통과했지만 정책 판정이 HUMAN·PM_REVIEW라 멈춘 산출물. 승인하면 태스크 DONE, 반려하면 READY로 돌아가 재시도 +1 (3회째 ESCALATED) + * - G3: 통합 확인(프로젝트 전체, taskId 없음). 모든 태스크가 DONE이 되면 생긴다. 승인하면 프로젝트 completed, 반려하면 진행 중 그대로 */ export function ApprovalCard({ approval: a, @@ -26,7 +42,8 @@ export function ApprovalCard({ }: { approval: Approval; canDecide: boolean; - taskHref: string; + /** G3는 태스크가 없다 */ + taskHref?: string; onDecided?: () => void; }) { const decide = useDecideApproval(); @@ -38,6 +55,9 @@ export function ApprovalCard({ const paths = strings(p.changedPaths); const commit = typeof p.commitSha === "string" ? p.commitSha : null; const attempt = typeof p.attempt === "number" ? p.attempt : null; + const release = a.gate === "G3"; + const checks = strings(p.integrationChecks); + const tasks = releaseTasks(p.tasks); const run = (decision: "approve" | "reject") => decide.mutate( @@ -48,14 +68,22 @@ export function ApprovalCard({ return (
- {a.gateMode === "PM_REVIEW" ? ( + {release ? ( + G3 · 통합 확인 + ) : a.gateMode === "PM_REVIEW" ? ( PM 검토 · 대표 대행 ) : ( 사람 승인 )} - - {a.taskTitle ?? "태스크"} - + {taskHref ? ( + + {a.taskTitle ?? "태스크"} + + ) : ( + + {release ? "모든 태스크 완료 · 프로젝트 완료 승인" : (a.taskTitle ?? "태스크")} + + )} {relTime(a.requestedAt)} @@ -99,6 +127,40 @@ export function ApprovalCard({ )} + {release && ( +
+

+ 에이전트는 통합을 확인하지 않습니다. 아래 브랜치를 머지해 실행해 보고 확인 항목을 직접 점검하세요. +

+ {checks.length > 0 && ( +
+
확인 항목 {checks.length}개
+
    + {checks.map((c) => ( +
  • {c}
  • + ))} +
+
+ )} + {tasks.length > 0 && ( +
+ 브랜치 · 마지막 커밋 {tasks.length}개 +
    + {tasks.map((t) => ( +
  • + {t.title} + + {t.repo} + {t.branchName && ` · ${t.branchName}`} + {t.commitSha && ` @ ${t.commitSha.slice(0, 7)}`} + +
  • + ))} +
+
+ )} +
+ )} {p.backfilled === true && (

승인 기능 이전부터 멈춰 있던 태스크라 정보 일부가 없습니다.

)} @@ -121,7 +183,11 @@ export function ApprovalCard({ value={reason} maxLength={MAX_REASON} onChange={(e) => setReason(e.target.value)} - placeholder="반려 사유 (필수) — 에이전트가 다시 고칠 때 이 사유를 받습니다" + placeholder={ + release + ? "반려 사유 (필수) — 무엇이 통합되지 않았는지 남깁니다" + : "반려 사유 (필수) — 에이전트가 다시 고칠 때 이 사유를 받습니다" + } aria-label="반려 사유" autoFocus /> @@ -155,7 +221,11 @@ export function ApprovalCard({ > 반려 - 반려하면 태스크가 다시 대기로 돌아가 에이전트가 고칩니다. + + {release + ? "승인하면 프로젝트가 완료됩니다. 반려하면 진행 중 그대로이고, 고칠 태스크를 만들어 전부 끝나면 새 카드가 생깁니다." + : "반려하면 태스크가 다시 대기로 돌아가 에이전트가 고칩니다."} +
)} {decide.isError &&

{errorMessage(decide.error)}

} diff --git a/src/entities/event/model/describe.ts b/src/entities/event/model/describe.ts index 1340871..cc252f7 100644 --- a/src/entities/event/model/describe.ts +++ b/src/entities/event/model/describe.ts @@ -31,7 +31,8 @@ const ROLE: Record = { FRONTEND: "FE", BACKEND: "BE" }; /** 누가 했는가. 에이전트가 했으면 에이전트, 아니면 책임 주체(사람 또는 system:*) */ export function eventActor(e: ProjectEvent, l: EventLookup) { // 배정·해제는 actorAgentId에 배정된 에이전트가 들어 있다 — 한 사람은 대표다 - const byPerson = e.type === "MEMBER_ASSIGNED" || e.type === "MEMBER_UNASSIGNED"; + const byPerson = + e.type === "MEMBER_ASSIGNED" || e.type === "MEMBER_UNASSIGNED" || e.type === "GITHUB_COLLABORATORS_INVITED"; if (e.actorAgentId && !byPerson) return l.agent(e.actorAgentId) ?? "에이전트"; if (e.onBehalfOf === "system:pm") return "PM"; if (e.onBehalfOf.startsWith("system:")) return "시스템"; @@ -107,6 +108,44 @@ export function describeEvent(e: ProjectEvent, l: EventLookup): { text: string; } case "PLAN_REJECTED": return { text: `계획 반려${str("reason") && ` — ${str("reason")}`}`, tone: "default" }; + case "TASK_DISPATCHED": + return { text: `실행 지시 · ${str("title") || task()}`, tone: "default" }; + case "AGENT_RUN_STARTED": + return { text: `에이전트 실행 시작 · ${task()}`, tone: "default" }; + case "AGENT_RUN_ENDED": + return { text: `에이전트 실행 종료 · ${task()}`, tone: "default" }; + case "TASK_BLOCKED": + return { text: `제출 없이 멈춤 · 대표 확인 필요 · ${task()}`, tone: "warn" }; + case "TASK_RESUMED": + return { text: `재개 · ${task()}${str("note") && ` — ${str("note")}`}`, tone: "default" }; + case "RELEASE_REQUESTED": + return { text: `모든 태스크 완료 · 통합 확인 요청 (확인 항목 ${num("checkCount")}개)`, tone: "warn" }; + case "RELEASE_DECIDED": + return str("decision") === "APPROVE" + ? { text: "통합 확인 승인 · 프로젝트 완료", tone: "success" } + : { text: `통합 확인 반려${str("reason") && ` — ${str("reason")}`}`, tone: "danger" }; + case "ACTION_DETECTED": + return { text: `행동 감지 · ${str("actionKey")} · ${task()}`, tone: "warn" }; + case "GITHUB_COLLABORATORS_INVITED": { + const results = Array.isArray(p.results) ? (p.results as { status?: unknown }[]) : []; + const bad = results.filter((r) => r.status === "skipped" || r.status === "failed").length; + return bad > 0 + ? { text: `GitHub 협업자 초대 · ${results.length}개 레포 중 ${bad}개 보내지 못함 · ${agent()}`, tone: "warn" } + : { text: `GitHub 협업자 초대 · 레포 ${results.length}개 · ${agent()}`, tone: "default" }; + } + case "PROJECT_SETTINGS_UPDATED": { + const after = p.after as { questionRelay?: unknown } | undefined; + return { text: `질문 중계 ${after?.questionRelay === false ? "끔" : "켬"}`, tone: "default" }; + } + case "QUESTION_ASKED": + return { + text: `${ROLE[str("askerRole")] ?? "에이전트"} → ${ROLE[str("targetRole")] ?? "자기 소관"} 질문 · ${task()}`, + tone: "default", + }; + case "QUESTION_ANSWERED": + return { text: `질문에 답함 · ${task()}`, tone: "default" }; + case "QUESTION_EXPIRED": + return { text: `질문 만료 · 답이 없음 · ${task()}`, tone: "danger" }; default: return { text: e.type, tone: "default" }; } diff --git a/src/entities/project/api/projectMutations.ts b/src/entities/project/api/projectMutations.ts index a3f7add..84c1e2b 100644 --- a/src/entities/project/api/projectMutations.ts +++ b/src/entities/project/api/projectMutations.ts @@ -42,14 +42,17 @@ function useRefetchOnStale(projectId: string) { }; } -/** 대표 전용. 응답 notice: 배정된 에이전트는 토큰을 재발급해야 태스크·노트 API를 쓸 수 있다 */ +/** + * 대표 전용. 응답 notice: 배정된 에이전트는 토큰을 재발급해야 태스크·노트 API를 쓸 수 있다. + * 서버가 에이전트 주인을 프로젝트의 GitHub 레포에 협업자로 초대한다 — 결과는 members[].githubInvites에도 남는다 + */ export function useAssignMember(projectId: string) { return useMutation({ mutationFn: (body: RequestBody<"/projects/{projectId}/members", "post">) => - apiFetch<{ members: Schemas["ProjectMember"][]; notice: string }>(`/projects/${projectId}/members`, { - method: "POST", - body, - }), + apiFetch<{ members: Schemas["ProjectMember"][]; githubInvites: Schemas["GithubInviteResult"][]; notice: string }>( + `/projects/${projectId}/members`, + { method: "POST", body }, + ), onSuccess: useInvalidateProject(projectId), onError: useRefetchOnStale(projectId), }); @@ -67,6 +70,29 @@ export function useUnassignMember(projectId: string) { }); } +/** 대표 전용. GitHub 협업자 초대 다시 보내기. 시작 뒤에도 된다. 초대가 실패해도 200이고 결과는 githubInvites에 적힌다 */ +export function useResendGithubInvite(projectId: string) { + return useMutation({ + mutationFn: (agentId: string) => + apiFetch<{ githubInvites: Schemas["GithubInviteResult"][] }>( + `/projects/${projectId}/members/${agentId}/github-invite`, + { method: "POST" }, + ), + onSuccess: useInvalidateProject(projectId), + onError: useRefetchOnStale(projectId), + }); +} + +/** 대표 전용, 시작 전에만 (시작 뒤 403 PROJECT_STARTED). 지금은 질문 중계 스위치뿐이다 */ +export function useUpdateProjectSettings(projectId: string) { + return useMutation({ + mutationFn: (body: RequestBody<"/projects/{projectId}/settings", "patch">) => + apiFetch(`/projects/${projectId}/settings`, { method: "PATCH", body }), + onSuccess: useInvalidateProject(projectId), + onError: useRefetchOnStale(projectId), + }); +} + /** * G1. 대표 전용. 이 순간부터 에이전트가 태스크를 가져간다. 시작 후에는 멤버를 바꿀 수 없다. * 422 PROJECT_START_INVALID는 details에 이유가 전부 온다 (errorDetails) diff --git a/src/entities/project/index.ts b/src/entities/project/index.ts index d46b7f8..327411d 100644 --- a/src/entities/project/index.ts +++ b/src/entities/project/index.ts @@ -1,6 +1,13 @@ export type { Membership, Project, Repo } from "./model/types"; export { PROJECT_STATUS, fmtUsd } from "./model/api"; export { projectKeys, useProject, useProjects } from "./api/projectApi"; -export { useAssignMember, useCreateProject, useStartProject, useUnassignMember } from "./api/projectMutations"; +export { + useAssignMember, + useCreateProject, + useResendGithubInvite, + useStartProject, + useUnassignMember, + useUpdateProjectSettings, +} from "./api/projectMutations"; export { ProjectStatusBadge } from "./ui/ProjectStatusBadge"; export { ProjectErrorView } from "./ui/ProjectErrorView"; diff --git a/src/entities/question/api/questionApi.ts b/src/entities/question/api/questionApi.ts new file mode 100644 index 0000000..166f902 --- /dev/null +++ b/src/entities/question/api/questionApi.ts @@ -0,0 +1,51 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { apiFetch, livePoll, type Schemas } from "@/shared/api"; + +type AgentQuestion = Schemas["AgentQuestion"]; +/** pending = 사람이 답해야 함 · agent_answered = 에이전트가 답함(사람 확인 권장) */ +type Status = "pending" | "agent_answered"; + +export const questionKeys = { + all: ["question"] as const, + mine: (status: Status) => [...questionKeys.all, "mine", status] as const, + project: (projectId: string, status: Status) => [...questionKeys.all, "project", projectId, status] as const, +}; + +const POLL = { refetchInterval: livePoll(5000), refetchIntervalInBackground: false } as const; + +/** 내가 답할 수 있는 질문 (최신순). 대표는 조직 전체, 팀원은 자기 역할이 답할 질문만. 알림 배지용 */ +export function useMyQuestions(status: Status = "pending") { + return useQuery({ + queryKey: questionKeys.mine(status), + queryFn: () => + apiFetch<{ questions: { question: AgentQuestion; projectName: string; taskTitle: string }[] }>( + `/me/questions?status=${status}`, + ), + select: (data) => data.questions, + ...POLL, + }); +} + +/** 프로젝트의 질문 (최신순). 볼 수 있는 범위는 태스크와 같다 — 답할 수 있는지는 targetRole로 따로 본다 */ +export function useProjectQuestions(projectId: string, status: Status = "pending") { + return useQuery({ + queryKey: questionKeys.project(projectId, status), + queryFn: () => apiFetch<{ questions: AgentQuestion[] }>(`/projects/${projectId}/questions?status=${status}`), + select: (data) => data.questions, + ...POLL, + }); +} + +/** + * 답하기. 낙관적 업데이트 없이 서버 응답으로만 바뀐다. answers의 키는 질문 문장 그대로여야 한다 (다르면 422 QUESTION_INVALID). + * agent_answered 질문에 같은 답을 보내면 확인, 다른 답이면 뒤집기다. + * 409 QUESTION_CLOSED도 상태가 바뀐 것이라 성공·실패 모두 목록을 다시 읽는다 + */ +export function useAnswerQuestion() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, answers }: { id: string; answers: Record }) => + apiFetch(`/questions/${id}/answer`, { method: "POST", body: { answers } }), + onSettled: () => queryClient.invalidateQueries({ queryKey: questionKeys.all }), + }); +} diff --git a/src/entities/question/index.ts b/src/entities/question/index.ts new file mode 100644 index 0000000..28f5812 --- /dev/null +++ b/src/entities/question/index.ts @@ -0,0 +1,2 @@ +export { questionKeys, useAnswerQuestion, useMyQuestions, useProjectQuestions } from "./api/questionApi"; +export { QuestionCard } from "./ui/QuestionCard"; diff --git a/src/entities/question/ui/QuestionCard.tsx b/src/entities/question/ui/QuestionCard.tsx new file mode 100644 index 0000000..3651f7c --- /dev/null +++ b/src/entities/question/ui/QuestionCard.tsx @@ -0,0 +1,214 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { ArrowRight } from "lucide-react"; +import { errorMessage, type Schemas } from "@/shared/api"; +import type { TeamRole } from "@/shared/model"; +import { Badge, Button, Textarea } from "@/shared/ui"; +import { cn, fmtDateTime, relTime } from "@/shared/lib/format"; +import { useAnswerQuestion } from "../api/questionApi"; + +type AgentQuestion = Schemas["AgentQuestion"]; + +const ROLE: Record = { + FRONTEND: { label: "FE", tone: "fe" }, + BACKEND: { label: "BE", tone: "be" }, +}; + +const SOURCE: Record, string> = { + agent: "에이전트가 답함 · 사람 미확인", + human: "사람이 답함", + agent_confirmed: "에이전트 답을 사람이 확인함", + human_override: "사람이 에이전트 답을 뒤집음", +}; + +const STATUS: Record< + AgentQuestion["status"], + { label: string; tone: "warn" | "info" | "success" | "danger" | "neutral" } +> = { + pending: { label: "답 필요", tone: "warn" }, + agent_answered: { label: "에이전트가 답함 · 확인 권장", tone: "info" }, + answered: { label: "답함", tone: "success" }, + expired: { label: "만료", tone: "danger" }, + self_owned: { label: "묻는 쪽 소관", tone: "neutral" }, +}; + +/** multiSelect는 고른 label을 "a, b"처럼 쉼표로 이어 보낸다 */ +function toggleLabel(value: string, label: string) { + const picked = value.split(", ").filter(Boolean); + return (picked.includes(label) ? picked.filter((x) => x !== label) : [...picked, label]).join(", "); +} + +/** + * 에이전트가 다른 역할에게 물은 질문 카드. 답할 역할의 담당자와 대표만 답한다(canAnswer) — 최종 판정은 403 NOT_QUESTION_TARGET. + * 한 카드의 질문(최대 4개)을 전부 답해야 보낼 수 있다. 초안(draft)이 있으면 그 답을 기본값으로 채운다 + */ +export function QuestionCard({ + question: q, + canAnswer, + taskHref, + taskTitle, +}: { + question: AgentQuestion; + canAnswer: boolean; + taskHref: string; + taskTitle?: string; +}) { + const answer = useAnswerQuestion(); + const agentAnswered = q.status === "agent_answered"; + const open = q.status === "pending" || agentAnswered; + // 에이전트가 이미 답했으면 그 답, 아니면 상담 초안 + const initial = agentAnswered ? q.answers : q.draft?.answers; + const [values, setValues] = useState>(() => + Object.fromEntries(q.questions.map((x) => [x.question, initial?.[x.question] ?? ""])), + ); + const set = (question: string, v: string) => setValues((prev) => ({ ...prev, [question]: v })); + + const complete = q.questions.every((x) => values[x.question].trim() !== ""); + // 손대지 않은 답은 원문 그대로 보낸다 — 공백만 달라도 서버는 "뒤집기"로 본다 + const original = (question: string) => (agentAnswered ? q.answers?.[question] : undefined); + const unchanged = (question: string) => values[question].trim() === original(question)?.trim(); + const overriding = agentAnswered && q.questions.some((x) => !unchanged(x.question)); + + const submit = () => + answer.mutate({ + id: q.id, + answers: Object.fromEntries( + q.questions.map((x) => [x.question, unchanged(x.question) ? original(x.question)! : values[x.question].trim()]), + ), + }); + + return ( +
+
+ {ROLE[q.askerRole].label} 에이전트 + + {ROLE[q.targetRole].label} + {STATUS[q.status].label} + + {taskTitle ?? "태스크 보기"} + + + {relTime(q.createdAt)} + +
+ +
    + {q.questions.map((x) => { + const decided = q.draft?.decided[x.question]; + const basis = q.draft?.basis[x.question] ?? []; + return ( +
  1. +
    + {x.header && {x.header}} + {/* decided=false: 코드에 없는 결정 — 사람이 정해야 한다 */} + {open && decided === false && 코드에 없는 결정 · 사람이 정해야 함} + {open && decided === true && 에이전트 초안} +
    +

    {x.question}

    + + {open && canAnswer ? ( + <> + {x.options.length > 0 && ( +
      + {x.options.map((o) => { + const picked = x.multiSelect + ? values[x.question].split(", ").includes(o.label) + : values[x.question] === o.label; + return ( +
    • + +
    • + ); + })} +
    + )} +