Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
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
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,24 @@ public interface SpringDataAgentRunRepository extends JpaRepository<AgentRunEnti
// 진행 중 상태를 복구할 포인터로 쓴다 — 메시지가 taskId 를 안 실어(과거 조회 시 null) 잃어버린
// 태스크에 다시 닿는 유일한 길이었다. 소유자로 필터해 남의 태스크가 새지 않고, terminal(DONE/
// FAILED/CANCELLED)은 제외해 이미 끝난 태스크의 낡은 상태는 돌려주지 않는다. 가장 최근 하나만 본다.
// U6(#341) 6-6: 엔티티가 아니라 (taskId, status) 두 컬럼만 읽는다. 호출부(TaskStore#findActiveTask)
// 가 쓰는 것이 그 둘뿐인데 엔티티로 읽으면 plan_json LONGTEXT 와 TEXT 7개(summary·error·question·
// clarification_json·answered_clarification_json·input_value·failure_log·suggested_fix)가 함께
// 실려 왔다. 대화를 열 때마다 도는 조회다.
interface ActiveRunView {
String getTaskId();
String getStatus();
}

@Query("""
select run
select run.taskId as taskId, run.status as status
from AgentRunEntity run
where run.conversationId = :conversationId
and run.ownerUserId = :ownerUserId
and run.status not in :terminalStatuses
order by run.createdAt desc
""")
List<AgentRunEntity> findActiveRuns(
List<ActiveRunView> findActiveRuns(
@Param("conversationId") Long conversationId,
@Param("ownerUserId") Long ownerUserId,
@Param("terminalStatuses") List<String> terminalStatuses,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ public java.util.Optional<ActiveTask> findActiveTask(Long conversationId, Long u
conversationId, userId, TERMINAL_STATUSES,
org.springframework.data.domain.PageRequest.of(0, 1))
.stream().findFirst()
.map(run -> new ActiveTask(run.getTaskId(), TaskStatus.valueOf(run.getStatus())));
.map(view -> new ActiveTask(view.getTaskId(), TaskStatus.valueOf(view.getStatus())));
}

/** 대화의 현재 살아있는 태스크 포인터(id + 상태). */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.example.dvely.approval.application.command.ApprovalCommandService;
import com.example.dvely.approval.application.query.ApprovalQueryService;
import com.example.dvely.approval.application.result.ApprovalResult;
import com.example.dvely.common.paging.CursorPage;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
Expand All @@ -14,8 +15,11 @@ public class ApprovalFacade {
private final ApprovalQueryService queryService;
private final ApprovalCommandService commandService;

public List<ApprovalResult> getProjectApprovals(Long ownerUserId, Long projectId) {
return queryService.getProjectApprovals(ownerUserId, projectId);
public CursorPage<ApprovalResult> getProjectApprovals(Long ownerUserId,
Long projectId,
Integer limit,
String after) {
return queryService.getProjectApprovals(ownerUserId, projectId, limit, after);
}

public ApprovalResult getApproval(Long ownerUserId, Long approvalId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import com.example.dvely.approval.domain.model.Approval;
import com.example.dvely.approval.domain.repository.ApprovalRepository;
import com.example.dvely.common.exception.NotFoundException;
import com.example.dvely.common.paging.CursorPage;
import com.example.dvely.common.paging.CursorPaging;
import com.example.dvely.project.domain.exception.ProjectNotFoundException;
import com.example.dvely.project.domain.repository.ProjectRepository;
import java.util.List;
Expand All @@ -23,14 +25,30 @@ public class ApprovalQueryService {
private final ApprovalRepository approvalRepository;
private final ProjectRepository projectRepository;

/**
* limit 를 안 받는 호출(프로젝트 개요·활동로그)의 상한. 승인은 사용자 요청 1회당 0~2건이라
* 200 이면 최근 100회 이상의 작업을 덮는다. 활동로그는 최신순으로 합쳐 보여주는 화면이라
* 잘리는 쪽은 맨 아래다.
*/
private static final int DEFAULT_APPROVAL_LIMIT = 200;
private static final int MAX_APPROVAL_LIMIT = 500;

public List<ApprovalResult> getProjectApprovals(Long ownerUserId, Long projectId) {
return getProjectApprovals(ownerUserId, projectId, null, null).items();
}

/** U6(#341) 6-4: 상한 + 커서. 최신순이라 {@code after} 는 "그 승인보다 오래된 것" 을 뜻한다. */
public CursorPage<ApprovalResult> getProjectApprovals(Long ownerUserId,
Long projectId,
Integer limit,
String after) {
projectRepository.findByIdAndOwnerUserIdAndDeletedFalse(projectId, ownerUserId)
.orElseThrow(() -> new ProjectNotFoundException(projectId, ownerUserId));
return approvalRepository
.findByProjectIdAndOwnerUserIdOrderByCreatedAtDesc(projectId, ownerUserId)
.stream()
.map(this::toResult)
.toList();
int size = CursorPaging.clamp(limit, DEFAULT_APPROVAL_LIMIT, MAX_APPROVAL_LIMIT);
List<Approval> probed = approvalRepository.findProjectApprovalsPage(
projectId, ownerUserId, CursorPaging.parseCursor(after), size + 1);
return CursorPaging.slice(probed, size, approval -> String.valueOf(approval.getId()))
.map(this::toResult);
}

public ApprovalResult getApproval(Long ownerUserId, Long approvalId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ public interface ApprovalRepository {
*/
Optional<ApprovalRouting> findRoutingInfo(Long approvalId, Long ownerUserId);

List<Approval> findByProjectIdAndOwnerUserIdOrderByCreatedAtDesc(Long projectId, Long ownerUserId);
/**
* U6(#341) 6-4: 프로젝트 승인 목록 한 페이지. 최신순이고 {@code after}(승인 id, 배타적)보다
* 오래된 것만 준다. {@code after} 가 null 이면 처음부터다.
*/
List<Approval> findProjectApprovalsPage(Long projectId, Long ownerUserId, Long after, int limit);

List<Approval> findByTaskIdOrderByIdAsc(String taskId);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ public Optional<Approval> findByIdAndOwnerUserIdForUpdate(Long approvalId, Long
}

@Override
public List<Approval> findByProjectIdAndOwnerUserIdOrderByCreatedAtDesc(Long projectId, Long ownerUserId) {
return springDataRepository.findByProjectIdAndOwnerUserIdOrderByCreatedAtDesc(projectId, ownerUserId)
public List<Approval> findProjectApprovalsPage(Long projectId, Long ownerUserId, Long after, int limit) {
return springDataRepository.findProjectApprovalsPage(
projectId, ownerUserId, after, org.springframework.data.domain.PageRequest.of(0, limit))
.stream()
.map(ApprovalEntity::toDomain)
.toList();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import jakarta.persistence.LockModeType;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
Expand All @@ -23,7 +24,23 @@ Optional<ApprovalEntity> findByIdAndOwnerUserIdForUpdate(
@Param("approvalId") Long approvalId, @Param("ownerUserId") Long ownerUserId
);

List<ApprovalEntity> findByProjectIdAndOwnerUserIdOrderByCreatedAtDesc(Long projectId, Long ownerUserId);
// U6(#341) 6-4: 무제한이던 목록에 상한과 커서를 더했다. created_at 이 DATETIME(초) 라 같은 초의
// 행 순서가 비결정적인데 커서 페이지네이션에서는 그게 행을 건너뛰거나 두 번 주는 버그가 되므로
// id 를 tiebreaker 로 붙였다. 최신순이라 커서는 "이 id 보다 작은 것"(= 더 오래된 것)이다.
@Query("""
select a
from ApprovalEntity a
where a.projectId = :projectId
and a.ownerUserId = :ownerUserId
and (:after is null or a.id < :after)
order by a.createdAt desc, a.id desc
""")
List<ApprovalEntity> findProjectApprovalsPage(
@Param("projectId") Long projectId,
@Param("ownerUserId") Long ownerUserId,
@Param("after") Long after,
Pageable pageable
);

// Backs ApprovalRepository#existsByProjectIdAndTypeAndStatus. Reuses the existing
// idx_approvals_project_id index (V14) — MySQL scans by project_id then filters type/status
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@
import com.example.dvely.approval.presentation.dto.ApprovalDecisionRequest;
import com.example.dvely.approval.presentation.dto.ApprovalInputResponse;
import com.example.dvely.approval.presentation.dto.ApprovalResponse;
import com.example.dvely.common.paging.CursorResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "Approval", description = "Agent 작업 승인 및 거절 API")
Expand All @@ -25,17 +28,21 @@ public class ApprovalController {

@Operation(
summary = "프로젝트 승인 목록 조회",
description = "프로젝트에서 생성된 모든 승인 요청을 상태(PENDING/APPROVED/REJECTED/CANCELLED)와 " +
"무관하게 최신순으로 조회합니다."
description = "프로젝트에서 생성된 승인 요청을 상태(PENDING/APPROVED/REJECTED/CANCELLED)와 " +
"무관하게 최신순으로 조회합니다. limit 기본 200, 최대 500(초과 시 500으로 보정). " +
"더 오래된 것이 남아 있으면 응답 헤더 X-Qeploy-Next-Cursor 에 다음 커서가 실리며, " +
"after 로 넘겨 이어 받습니다."
)
@GetMapping("/api/v1/projects/{projectId}/approvals")
public List<ApprovalResponse> getProjectApprovals(
public ResponseEntity<List<ApprovalResponse>> getProjectApprovals(
@AuthenticationPrincipal Long ownerUserId,
@PathVariable Long projectId
@PathVariable Long projectId,
@RequestParam(required = false) Integer limit,
@RequestParam(required = false) String after
) {
return approvalFacade.getProjectApprovals(ownerUserId, projectId).stream()
.map(this::toResponse)
.toList();
return CursorResponse.of(
approvalFacade.getProjectApprovals(ownerUserId, projectId, limit, after)
.map(this::toResponse));
}

@Operation(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
import com.example.dvely.change.application.result.ChangeResult;
import com.example.dvely.change.infrastructure.persistence.entity.ChangeEntity;
import com.example.dvely.change.infrastructure.persistence.repository.SpringDataChangeRepository;
import com.example.dvely.change.infrastructure.persistence.repository.SpringDataChangeRepository.ChangeSummaryView;
import com.example.dvely.common.exception.NotFoundException;
import com.example.dvely.common.paging.CursorPage;
import com.example.dvely.common.paging.CursorPaging;
import com.example.dvely.preview.application.result.PreviewSessionInfo;
import com.example.dvely.preview.application.service.PreviewSessionService;
import com.example.dvely.project.domain.exception.ProjectNotFoundException;
Expand Down Expand Up @@ -125,14 +128,50 @@ public void markDeployed(String taskId) {
});
}

/**
* limit 를 안 받는 호출(프로젝트 개요·활동로그)의 상한. 개요/활동로그는 최신순 목록을 그대로
* 합쳐 보여주는 화면이라 오래된 꼬리가 잘려도 화면이 달라지지 않는다 — 잘리는 쪽은 활동로그
* 맨 아래다. 변경 건은 사용자 요청 1회당 최대 1건씩 쌓이므로 200 이면 최근 200번의 작업을 덮는다.
*/
private static final int DEFAULT_CHANGE_LIMIT = 200;
private static final int MAX_CHANGE_LIMIT = 500;

@Transactional(readOnly = true)
public List<ChangeResult> getProjectChanges(Long ownerUserId, Long projectId) {
return getProjectChanges(ownerUserId, projectId, null, null).items();
}

@Transactional(readOnly = true)
public CursorPage<ChangeResult> getProjectChanges(Long ownerUserId,
Long projectId,
Integer limit,
String after) {
projectRepository.findByIdAndOwnerUserIdAndDeletedFalse(projectId, ownerUserId)
.orElseThrow(() -> new ProjectNotFoundException(projectId, ownerUserId));
return changeRepository.findByProjectIdAndOwnerUserIdOrderByCreatedAtDesc(projectId, ownerUserId)
.stream()
.map(ChangeEntity::toResult)
.toList();
int size = CursorPaging.clamp(limit, DEFAULT_CHANGE_LIMIT, MAX_CHANGE_LIMIT);
List<ChangeSummaryView> probed = changeRepository.findProjectChangeSummaries(
projectId, ownerUserId, CursorPaging.parseCursor(after), CursorPaging.probe(size));
return CursorPaging.slice(probed, size, view -> String.valueOf(view.getId()))
.map(ChangeService::toResult);
}

/** 프로젝션 → ChangeResult. 필드 순서·값은 {@code ChangeEntity#toResult} 와 1:1 이다. */
private static ChangeResult toResult(ChangeSummaryView view) {
return new ChangeResult(
view.getId(),
view.getProjectId(),
view.getConversationId(),
view.getTaskId(),
view.getPreviewSessionId(),
view.getStatus(),
view.getSummary(),
view.getApprovalId(),
view.getPrNumber(),
view.getMergeCommitSha(),
view.getMergedAt(),
view.getCreatedAt(),
view.getUpdatedAt()
);
}

@Transactional(readOnly = true)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,68 @@
package com.example.dvely.change.infrastructure.persistence.repository;

import com.example.dvely.change.infrastructure.persistence.entity.ChangeEntity;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface SpringDataChangeRepository extends JpaRepository<ChangeEntity, Long> {

Optional<ChangeEntity> findByTaskId(String taskId);

Optional<ChangeEntity> findByIdAndOwnerUserId(Long changeId, Long ownerUserId);

List<ChangeEntity> findByProjectIdAndOwnerUserIdOrderByCreatedAtDesc(Long projectId, Long ownerUserId);
// U6 6-1: 목록은 프로젝션으로만 읽는다. 엔티티로 읽으면 diff_text(MEDIUMTEXT, 행당 최대 1MB)까지
// 함께 실려 오는데 ChangeResult 에는 diff 가 없다 — 프로젝트 개요를 한 번 열면 그 프로젝트의
// 모든 diff 를 DB 에서 끌어오고 그대로 버리고 있었다. 아래 뷰의 컬럼 = ChangeResult 의 필드다.
interface ChangeSummaryView {
Long getId();
Long getProjectId();
Long getConversationId();
String getTaskId();
String getPreviewSessionId();
String getStatus();
String getSummary();
Long getApprovalId();
Integer getPrNumber();
String getMergeCommitSha();
LocalDateTime getMergedAt();
LocalDateTime getCreatedAt();
LocalDateTime getUpdatedAt();
}

// createdAt 뒤에 id 를 덧붙여 정렬한다 — created_at 이 같은 초인 행들의 순서가 비결정적이면
// 커서(:after) 페이지네이션이 같은 행을 건너뛰거나 두 번 준다.
@Query("""
select c.id as id,
c.projectId as projectId,
c.conversationId as conversationId,
c.taskId as taskId,
c.previewSessionId as previewSessionId,
c.status as status,
c.summary as summary,
c.approvalId as approvalId,
c.prNumber as prNumber,
c.mergeCommitSha as mergeCommitSha,
c.mergedAt as mergedAt,
c.createdAt as createdAt,
c.updatedAt as updatedAt
from ChangeEntity c
where c.projectId = :projectId
and c.ownerUserId = :ownerUserId
and (:after is null or c.id < :after)
order by c.createdAt desc, c.id desc
""")
List<ChangeSummaryView> findProjectChangeSummaries(
@Param("projectId") Long projectId,
@Param("ownerUserId") Long ownerUserId,
@Param("after") Long after,
Pageable pageable
);

// Track Z (#56) review follow-up (BLOCKING-1): backs ResultApprovalService#hasResultGateHistory
// — a project-scoped existence check (any status, not just the current task's own Change row)
Expand Down
Loading
Loading