Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 금지.
Expand Down
6 changes: 5 additions & 1 deletion src/app/p/[projectId]/rooms/[roomId]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <RoleRoomView projectId={projectId} role={role} />;
return <RoomView projectId={projectId} roomId={roomId} />;
}
88 changes: 79 additions & 9 deletions src/entities/approval/ui/ApprovalCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,7 +42,8 @@ export function ApprovalCard({
}: {
approval: Approval;
canDecide: boolean;
taskHref: string;
/** G3는 태스크가 없다 */
taskHref?: string;
onDecided?: () => void;
}) {
const decide = useDecideApproval();
Expand All @@ -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(
Expand All @@ -48,14 +68,22 @@ export function ApprovalCard({
return (
<div id={a.id} className="card scroll-mt-6 p-4">
<div className="flex flex-wrap items-center gap-2">
{a.gateMode === "PM_REVIEW" ? (
{release ? (
<Badge tone="warn">G3 · 통합 확인</Badge>
) : a.gateMode === "PM_REVIEW" ? (
<Badge tone="pm">PM 검토 · 대표 대행</Badge>
) : (
<Badge tone="warn">사람 승인</Badge>
)}
<Link href={taskHref} className="min-w-0 flex-1 truncate text-[14px] font-semibold hover:underline">
{a.taskTitle ?? "태스크"}
</Link>
{taskHref ? (
<Link href={taskHref} className="min-w-0 flex-1 truncate text-[14px] font-semibold hover:underline">
{a.taskTitle ?? "태스크"}
</Link>
) : (
<span className="min-w-0 flex-1 truncate text-[14px] font-semibold">
{release ? "모든 태스크 완료 · 프로젝트 완료 승인" : (a.taskTitle ?? "태스크")}
</span>
)}
<span className="text-[11.5px] text-ink-500" title={fmtDateTime(a.requestedAt)}>
{relTime(a.requestedAt)}
</span>
Expand Down Expand Up @@ -99,6 +127,40 @@ export function ApprovalCard({
</>
)}
</dl>
{release && (
<div className="mt-3 space-y-3 text-[12.5px]">
<p className="text-ink-700">
에이전트는 통합을 확인하지 않습니다. 아래 브랜치를 머지해 실행해 보고 확인 항목을 직접 점검하세요.
</p>
{checks.length > 0 && (
<div>
<div className="text-ink-500">확인 항목 {checks.length}개</div>
<ul className="mt-1 list-disc space-y-0.5 pl-5 text-[13px] text-ink-900">
{checks.map((c) => (
<li key={c}>{c}</li>
))}
</ul>
</div>
)}
{tasks.length > 0 && (
<details>
<summary className="cursor-pointer text-ink-700">브랜치 · 마지막 커밋 {tasks.length}개</summary>
<ul className="mt-1 space-y-1">
{tasks.map((t) => (
<li key={t.taskId} className="flex flex-wrap items-baseline gap-x-2">
<span className="font-medium">{t.title}</span>
<span className="font-mono text-[11.5px] text-ink-700">
{t.repo}
{t.branchName && ` · ${t.branchName}`}
{t.commitSha && ` @ ${t.commitSha.slice(0, 7)}`}
</span>
</li>
))}
</ul>
</details>
)}
</div>
)}
{p.backfilled === true && (
<p className="mt-2 text-[12px] text-ink-500">승인 기능 이전부터 멈춰 있던 태스크라 정보 일부가 없습니다.</p>
)}
Expand All @@ -121,7 +183,11 @@ export function ApprovalCard({
value={reason}
maxLength={MAX_REASON}
onChange={(e) => setReason(e.target.value)}
placeholder="반려 사유 (필수) — 에이전트가 다시 고칠 때 이 사유를 받습니다"
placeholder={
release
? "반려 사유 (필수) — 무엇이 통합되지 않았는지 남깁니다"
: "반려 사유 (필수) — 에이전트가 다시 고칠 때 이 사유를 받습니다"
}
aria-label="반려 사유"
autoFocus
/>
Expand Down Expand Up @@ -155,7 +221,11 @@ export function ApprovalCard({
>
<X size={14} /> 반려
</Button>
<span className="text-[12px] text-ink-500">반려하면 태스크가 다시 대기로 돌아가 에이전트가 고칩니다.</span>
<span className="text-[12px] text-ink-500">
{release
? "승인하면 프로젝트가 완료됩니다. 반려하면 진행 중 그대로이고, 고칠 태스크를 만들어 전부 끝나면 새 카드가 생깁니다."
: "반려하면 태스크가 다시 대기로 돌아가 에이전트가 고칩니다."}
</span>
</div>
)}
{decide.isError && <p className="mt-2 text-[12.5px] text-forbidden">{errorMessage(decide.error)}</p>}
Expand Down
41 changes: 40 additions & 1 deletion src/entities/event/model/describe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ const ROLE: Record<string, string> = { 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 "시스템";
Expand Down Expand Up @@ -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" };
}
Expand Down
36 changes: 31 additions & 5 deletions src/entities/project/api/projectMutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
Expand All @@ -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<Schemas["Project"]>(`/projects/${projectId}/settings`, { method: "PATCH", body }),
onSuccess: useInvalidateProject(projectId),
onError: useRefetchOnStale(projectId),
});
}

/**
* G1. 대표 전용. 이 순간부터 에이전트가 태스크를 가져간다. 시작 후에는 멤버를 바꿀 수 없다.
* 422 PROJECT_START_INVALID는 details에 이유가 전부 온다 (errorDetails)
Expand Down
9 changes: 8 additions & 1 deletion src/entities/project/index.ts
Original file line number Diff line number Diff line change
@@ -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";
51 changes: 51 additions & 0 deletions src/entities/question/api/questionApi.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> }) =>
apiFetch<AgentQuestion>(`/questions/${id}/answer`, { method: "POST", body: { answers } }),
onSettled: () => queryClient.invalidateQueries({ queryKey: questionKeys.all }),
});
}
2 changes: 2 additions & 0 deletions src/entities/question/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { questionKeys, useAnswerQuestion, useMyQuestions, useProjectQuestions } from "./api/questionApi";
export { QuestionCard } from "./ui/QuestionCard";
Loading
Loading