From 74ba7ebd764e0be3e9d6ef23c61e54de593f8a25 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 6 Jun 2026 19:50:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(core):=20=EC=9B=8C=ED=81=AC=EC=8A=A4?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=8A=AC=EB=A1=AF=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone work item 생성 동기화에서 workspace slot 상태를 DB에 저장하고 available slot을 원자적으로 예약할 수 있어야 한다. 리뷰 루프 산출물을 archive하고, 현재 로드맵 우선순위를 Workbench Provider Slot Composition으로 조정한다. --- agent-roadmap/ROADMAP.md | 8 +- .../agent-ops-mcp-control-plane/PHASE.md | 8 +- .../milestone-work-item-creation-sync.md | 2 +- .../project-workspace-management-ux/PHASE.md | 10 +- .../project-workspace-management-ux.md | 4 +- .../workbench-provider-slot-composition.md | 2 +- .../code_review_local_G06_0.log | 190 +++++++++++ .../02+01_workspace_slot_store/complete.log | 45 +++ .../plan_local_G06_0.log} | 0 .../CODE_REVIEW-local-G06.md | 142 -------- services/core/internal/db/models.go | 10 + .../core/internal/db/workspace_slots.sql.go | 129 ++++++++ services/core/internal/projectsync/config.go | 46 +++ .../core/internal/projectsync/config_test.go | 59 ++++ .../core/internal/storage/workspace_slots.go | 50 +++ .../internal/storage/workspace_slots_test.go | 307 ++++++++++++++++++ .../00005_create_workspace_slots.sql | 25 ++ services/core/queries/workspace_slots.sql | 30 ++ 18 files changed, 908 insertions(+), 159 deletions(-) create mode 100644 agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_workspace_slot_store/code_review_local_G06_0.log create mode 100644 agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_workspace_slot_store/complete.log rename agent-task/{m-milestone-work-item-creation-sync/02+01_workspace_slot_store/PLAN-local-G06.md => archive/2026/06/m-milestone-work-item-creation-sync/02+01_workspace_slot_store/plan_local_G06_0.log} (100%) delete mode 100644 agent-task/m-milestone-work-item-creation-sync/02+01_workspace_slot_store/CODE_REVIEW-local-G06.md create mode 100644 services/core/internal/db/workspace_slots.sql.go create mode 100644 services/core/internal/storage/workspace_slots.go create mode 100644 services/core/internal/storage/workspace_slots_test.go create mode 100644 services/core/migrations/00005_create_workspace_slots.sql create mode 100644 services/core/queries/workspace_slots.sql diff --git a/agent-roadmap/ROADMAP.md b/agent-roadmap/ROADMAP.md index abdcfe0..085931f 100644 --- a/agent-roadmap/ROADMAP.md +++ b/agent-roadmap/ROADMAP.md @@ -4,7 +4,7 @@ NomadCode는 Flutter 기반 앱, core 서비스, 공유 계약, agent-operation 규칙을 하나의 원레포로 묶어 AI-assisted development workflow를 조율하는 프로젝트다. -현재 로드맵은 `ROADMAP.md -> phase//PHASE.md -> phase//milestones/.md` scaffold를 기준으로 관리한다. React/Vite 웹 콘솔 제거, 서버/Plane/provider 기반 작업, Flutter-first 클라이언트 정리, Mattermost push plugin extraction, client integration 표준화는 완료되었다. 현재는 Project Workspace Management UX와 Agent-Ops MCP Control Plane이 진행중이며, Agent-Ops MCP Control Plane은 Milestone 생성 동기화를 Plane `Todo` 검토 상태까지 닫는 첫 slice를 다룬다. +현재 로드맵은 `ROADMAP.md -> phase//PHASE.md -> phase//milestones/.md` scaffold를 기준으로 관리한다. React/Vite 웹 콘솔 제거, 서버/Plane/provider 기반 작업, Flutter-first 클라이언트 정리, Mattermost push plugin extraction, client integration 표준화는 완료되었다. 현재는 Project Workspace Management UX가 진행중이며, 최우선 작업은 IOP/OTO 같은 외부 Flutter console을 provider slot으로 삽입할 수 있게 하는 Workbench Provider Slot Composition이다. Agent-Ops MCP Control Plane은 provider slot 작업 이후 계획 후보로 둔다. IOP 외부 실행 호출은 OpenAI-compatible Responses API 방식을 기본 계약으로 채택하고, NomadCode/IOP 고유의 task, workspace, session, approval, artifact, notification 문맥은 별도 `iop` wrapper field가 아니라 `metadata` 확장으로 전달한다. A2A는 agent-to-agent delegation이 명확히 필요할 때 재검토하며, IOP native protocol은 NomadCode의 기본 외부 실행 호출 표면으로 쓰지 않는다. @@ -37,10 +37,10 @@ IOP 외부 실행 호출은 OpenAI-compatible Responses API 방식을 기본 계 - 요약: Plane 확장, Jira-compatible provider 추상화, Mattermost, IOP OpenAI-compatible Responses 호출과 metadata 기반 실행 문맥 전달을 완료했고, Phase 완료 확인과 archive 승인을 기다린다. - [진행중] Project Workspace Management UX - 경로: `agent-roadmap/phase/project-workspace-management-ux/PHASE.md` - - 요약: client integration 표준화, core workflow, 외부 통합 기준 이후 프로젝트 단위 앱 UX의 desktop/mobile 역할과 IOP/task 결과 표시 기준을 정리한다. -- [진행중] Agent-Ops MCP Control Plane + - 요약: client integration 표준화, core workflow, 외부 통합 기준 이후 프로젝트 단위 앱 UX를 다루며, 현재는 provider slot 기반 외부 console composition을 최우선으로 진행한다. +- [계획] Agent-Ops MCP Control Plane - 경로: `agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md` - - 요약: 로드맵 기반 agent-ops 운영 자동화, Plane/Jira 같은 work item provider와 Milestone item의 양방향 동기화 도메인, 외부 agent용 MCP 제어 표면을 다룬다. 현재 작업은 Milestone 생성 동기화를 Plane Todo 검토 상태까지 닫는 첫 slice다. + - 요약: 로드맵 기반 agent-ops 운영 자동화, Plane/Jira 같은 work item provider와 Milestone item의 양방향 동기화 도메인, 외부 agent용 MCP 제어 표면을 다루는 계획 후보다. ## 로딩 정책 diff --git a/agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md b/agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md index 80a57ca..726ae1b 100644 --- a/agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md +++ b/agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md @@ -2,11 +2,11 @@ ## 상태 -[진행중] +[계획] ## 목표 -로드맵 기반 agent-ops 운영 루프, Plane/Jira 같은 work item provider와 Milestone item의 양방향 동기화 도메인, 외부 agent 제어 표면을 Core action과 MCP tool 계층으로 분리한다. 현재 작업은 Milestone 생성 동기화를 Plane `Todo` 검토 상태까지 닫는 첫 slice다. +로드맵 기반 agent-ops 운영 루프, Plane/Jira 같은 work item provider와 Milestone item의 양방향 동기화 도메인, 외부 agent 제어 표면을 Core action과 MCP tool 계층으로 분리한다. Workbench Provider Slot Composition을 최우선으로 전환하는 동안 이 Phase는 계획 후보로 둔다. ## Milestone 흐름 @@ -14,9 +14,9 @@ 완료, 검토중, 진행중, 계획, 스케치 순서로 두어 아래로 갈수록 미래 작업에 가까워지게 정렬한다. 스케치 Milestone은 아직 구현 가능한 계획이 아니므로 계획 Milestone보다 아래에 둔다. -- [진행중] Milestone Work Item Creation Sync +- [계획] Milestone Work Item Creation Sync - 경로: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md` - - 요약: 먼저 진행할 첫 구현 slice다. Plane-origin 방식으로 Milestone을 생성하고 Plane Todo 티켓까지 동기화한 뒤 멈춘다. Workspace agent 실행은 IOP CLI 1차 통로를 선행으로 두며, Agent chat/IDE-origin 생성 projection은 이번 범위에서 제외한다. + - 요약: Plane-origin 방식으로 Milestone을 생성하고 Plane Todo 티켓까지 동기화한 뒤 멈추는 계획 후보다. Workspace agent 실행은 IOP CLI 1차 통로를 선행으로 두며, Agent chat/IDE-origin 생성 projection은 이번 범위에서 제외한다. - [계획] Roadmap Driven Agent-Ops Automation - 경로: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/roadmap-driven-agent-ops-automation.md` diff --git a/agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md b/agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md index 8713bb9..43ce8ac 100644 --- a/agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md +++ b/agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md @@ -11,7 +11,7 @@ Plane-origin 경로로 Milestone을 생성하고, `develop` branch의 agent-road ## 상태 -[진행중] +[계획] ## 구현 잠금 diff --git a/agent-roadmap/phase/project-workspace-management-ux/PHASE.md b/agent-roadmap/phase/project-workspace-management-ux/PHASE.md index 2dca1bd..68a4296 100644 --- a/agent-roadmap/phase/project-workspace-management-ux/PHASE.md +++ b/agent-roadmap/phase/project-workspace-management-ux/PHASE.md @@ -17,14 +17,14 @@ Flutter-first 클라이언트 구조 위에서 `apps/client` 모듈, web/mobile - 경로: `agent-roadmap/archive/phase/project-workspace-management-ux/milestones/client-integration-standardization.md` - 요약: NomadCode client를 기준으로 `apps/client` 모듈명, Web + Android 우선 bootstrap, proto-socket, Mattermost push host integration, cloning handoff를 표준화했다. -- [진행중] Project Workspace Management UX - - 경로: `agent-roadmap/phase/project-workspace-management-ux/milestones/project-workspace-management-ux.md` - - 요약: 프로젝트 단위 제어 UX의 desktop/mobile 역할, parity 기준, IOP/task 결과 표시 기준을 정리하는 현재 작업 지점이다. - -- [계획] Workbench Provider Slot Composition +- [진행중] Workbench Provider Slot Composition - 경로: `agent-roadmap/phase/project-workspace-management-ux/milestones/workbench-provider-slot-composition.md` - 요약: 상단 titlebar와 우측 icon rail 기반 workbench를 유지하면서 IOP/OTO 같은 외부 Flutter console을 provider slot으로 등록, 삽입, 제거할 수 있는 구조로 정리한다. +- [계획] Project Workspace Management UX + - 경로: `agent-roadmap/phase/project-workspace-management-ux/milestones/project-workspace-management-ux.md` + - 요약: 프로젝트 단위 제어 UX의 desktop/mobile 역할, parity 기준, IOP/task 결과 표시 기준을 정리하는 계획 후보다. + ## Phase 경계 - 기존 Project/Session/Workspace 방향을 대체하지 않고 보강한다. diff --git a/agent-roadmap/phase/project-workspace-management-ux/milestones/project-workspace-management-ux.md b/agent-roadmap/phase/project-workspace-management-ux/milestones/project-workspace-management-ux.md index 6dc3a33..1441ed7 100644 --- a/agent-roadmap/phase/project-workspace-management-ux/milestones/project-workspace-management-ux.md +++ b/agent-roadmap/phase/project-workspace-management-ux/milestones/project-workspace-management-ux.md @@ -11,7 +11,7 @@ Flutter-first 클라이언트 구조 위에서 기존 Project/Session/Workspace ## 상태 -[진행중] +[계획] ## 구현 잠금 @@ -66,5 +66,5 @@ Flutter-first 앱에서 project/session/workspace 제어 표면과 desktop/mobil - 전환 참고 경로: [Legacy/Retired] `apps/web/` (제거됨) - 선행 작업: Flutter-first Client Consolidation, Client Integration Standardization, External Integration - 후속 작업: 확인 필요 -- 현재 지점: External Integration Milestone 완료 이후의 다음 활성 마일스톤이다. `구현 잠금`은 유지되며, 프로젝트 단위 제어 화면의 공통 기능/우선순위, desktop/mobile 역할 경계, IOP/task 결과 UX 투영 기준 결정이 선행되어야 한다. +- 현재 지점: Workbench Provider Slot Composition을 최우선 진행중 마일스톤으로 전환하면서 이 마일스톤은 계획 후보로 내려둔다. `구현 잠금`은 유지되며, 프로젝트 단위 제어 화면의 공통 기능/우선순위, desktop/mobile 역할 경계, IOP/task 결과 UX 투영 기준 결정이 선행되어야 한다. - 확인 필요: `구현 잠금`의 결정 필요 항목 참고. diff --git a/agent-roadmap/phase/project-workspace-management-ux/milestones/workbench-provider-slot-composition.md b/agent-roadmap/phase/project-workspace-management-ux/milestones/workbench-provider-slot-composition.md index 4b1fbf9..853f9b3 100644 --- a/agent-roadmap/phase/project-workspace-management-ux/milestones/workbench-provider-slot-composition.md +++ b/agent-roadmap/phase/project-workspace-management-ux/milestones/workbench-provider-slot-composition.md @@ -11,7 +11,7 @@ NomadCode Flutter 앱의 상단 titlebar와 우측 icon rail 기반 workbench sh ## 상태 -[계획] +[진행중] ## 구현 잠금 diff --git a/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_workspace_slot_store/code_review_local_G06_0.log b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_workspace_slot_store/code_review_local_G06_0.log new file mode 100644 index 0000000..aa0c5aa --- /dev/null +++ b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_workspace_slot_store/code_review_local_G06_0.log @@ -0,0 +1,190 @@ + + +# Code Review Reference - API + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-06-06 +task=m-milestone-work-item-creation-sync/02+01_workspace_slot_store, plan=1, tag=API + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md` +- Task ids: + - `workspace-slot-state`: Workspace slot의 사용중 상태 모델을 정의한다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-local-G06.md` → `code_review_local_G06_N.log`, `PLAN-local-G06.md` → `plan_local_G06_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-milestone-work-item-creation-sync/02+01_workspace_slot_store/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [API-1] Workspace slot DB schema and queries | [x] | +| [API-2] Atomic slot reservation storage API | [x] | +| [API-3] Slot state regression coverage | [x] | + +## 구현 체크리스트 + +- [x] `workspace_slots` migration과 SQLC queries를 추가한다. 검증: 각 slot은 `available`, `in_use`, `dirty`, `error` 상태 후보를 갖는다. (`migrations/00005_create_workspace_slots.sql`의 `workspace_slots_state_check CHECK (state IN ('available','in_use','dirty','error'))`, `queries/workspace_slots.sql`의 list/upsert/reserve/update query) +- [x] `available` slot만 원자적으로 `in_use`로 예약하는 storage method를 추가한다. 검증: 동시에 들어온 요청이 같은 slot을 잡지 않도록 SQL 조건이 있다. (`Store.ReserveWorkspaceSlot` → `ReserveAvailableWorkspaceSlot`의 `WHERE state='available' ... LIMIT 1 FOR UPDATE SKIP LOCKED` 단일 statement update) +- [x] Slot state persistence 테스트를 작성한다. (`internal/storage/workspace_slots_test.go`, `internal/projectsync/config_test.go`) +- [x] `cd services/core && go test -count=1 ./...`와 `git diff --check`를 실행한다. (아래 검증 결과 참조) +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [x] PASS이면 active task 디렉터리 `agent-task/m-milestone-work-item-creation-sync/02+01_workspace_slot_store/`를 `agent-task/archive/YYYY/MM/m-milestone-work-item-creation-sync/02+01_workspace_slot_store/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-milestone-work-item-creation-sync/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. +- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +- **SQLC 바이너리 부재로 generated 코드를 수기 작성**: `sqlc`가 환경에 없어(`which sqlc` → NOT FOUND) `internal/db/workspace_slots.sql.go`와 `internal/db/models.go`의 `WorkspaceSlot` 모델을 기존 generated 파일(`project_sync_settings.sql.go`, `tasks.sql.go`)과 동일한 sqlc v1.31.1 출력 포맷·헤더·alphabetical 정렬·scan 순서로 직접 작성했다. query 정의는 `queries/workspace_slots.sql`에 두어 다음 `sqlc generate` 시 재생성이 일치하도록 했다. +- **storage 반환 타입을 `projectsync.*` 대신 `db.WorkspaceSlot`로 유지**: 계획 API-2는 "반환 타입은 `projectsync.SlotIndex`, `projectsync.SlotState`, path를 보존"하라 했으나, `storage`가 `projectsync`를 import하면 `storage → projectsync → workitem → workflow → storage` import cycle이 발생한다(컴파일 실패 확인). predecessor `01_project_sync_store`와 동일한 계층 분리를 따라 `storage`는 `db.WorkspaceSlot`을 그대로 반환하는 thin wrapper로 두고, typed 보존(`projectsync.WorkspaceSlot`, `SlotFromDBRecord`)과 index/state 검증(`ToUpsertWorkspaceSlotParams`, `ToUpdateWorkspaceSlotStateParams`)은 `projectsync/config.go`에 추가했다. typed 보존 요구는 `projectsync.SlotFromDBRecord`로 충족된다. +- **`ReserveWorkspaceSlot`의 식별자 타입을 `int64`로 사용**: 계획 본문 예시는 `projectSyncSettingID string`이었으나 실제 `project_sync_settings.id`는 `BIGSERIAL`(=`int64`)이므로 FK 타입에 맞춰 `int64`로 구현했다. +- **state 검증 위치**: 계획 API-2의 "상태 update method" 검증은 `storage`가 아니라 `projectsync.ToUpdateWorkspaceSlotStateParams`에서 unknown state를 `ErrInvalidConfig`로 거르도록 두어 DB check constraint와 도메인 검증을 한 곳(projectsync)에 모았다. storage는 검증된 `db.UpdateWorkspaceSlotStateParams`만 전달받는다. + +## 주요 설계 결정 + +- **원자 예약 패턴**: `ReserveAvailableWorkspaceSlot`는 한 statement에서 `UPDATE ... SET state='in_use' WHERE id = (SELECT id ... WHERE state='available' ORDER BY slot_index LIMIT 1 FOR UPDATE SKIP LOCKED)`를 사용한다. `SKIP LOCKED`로 동시 트랜잭션이 같은 row를 잡지 않고 서로 다른 available slot을 고르며, available이 없으면 0 row → `pgx.ErrNoRows` → storage에서 `ErrNoAvailableWorkspaceSlot`로 변환한다. +- **state는 `projectsync.SlotState`를 single source of truth로 유지**: 중복 enum을 만들지 않고, DB `state` 컬럼은 `CHECK (state IN ('available','in_use','dirty','error'))`로 같은 후보 집합을 강제한다. slot index 범위도 `CHECK (slot_index BETWEEN 0 AND 999)`로 `projectsync.MaxSlotIndex`(999)와 일치시켰다. +- **upsert는 state를 보존**: `UpsertWorkspaceSlot`는 신규 insert 시 `'available'`로 등록하고 conflict 시 `path`/`updated_at`만 갱신한다. 이미 `in_use`인 slot이 재등록으로 초기화되지 않게 했다. +- **`(project_sync_setting_id, slot_index)` unique index**: 한 project sync 설정당 같은 slot index가 중복 등록되지 않도록 보장하고 upsert의 conflict target으로 사용한다. FK는 `ON DELETE CASCADE`로 설정 삭제 시 slot도 정리된다. + +## 남은 검증 공백 (자동 후속 가능) + +- 실제 동시 트랜잭션 race(`FOR UPDATE SKIP LOCKED`의 실DB 동작)와 check constraint 거부는 local unit test에서 DB 없이 완전 검증할 수 없다. unit test는 storage의 query shape(`state='available'`, `FOR UPDATE SKIP LOCKED`, `SET state='in_use'`, `LIMIT 1`), 인자, ErrNoRows→sentinel 매핑, 그리고 4개 state 후보 보존을 검증한다. 실DB integration 테스트는 후속 에이전트가 DB fixture로 보강할 수 있는 일반 follow-up 공백이며 사용자 리뷰 요청 사유가 아니다. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- Slot state check constraint와 Go enum이 같은 후보만 허용하는지 확인한다. +- 예약 query가 `available` 조건을 한 statement에서 보장하는지 확인한다. +- predecessor `01_project_sync_store`의 완료 근거가 있는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. +- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. + +### API-1 중간 검증 +``` +$ cd services/core && go test -count=1 ./internal/db +? github.com/nomadcode/nomadcode-core/internal/db [no test files] +``` +(생성 코드는 db 패키지에 별도 test가 없고, query behavior는 storage-level test에서 검증한다. 계획 API-1 "SQL parser-only test는 작성하지 않는다"와 일치.) + +### API-2 중간 검증 +``` +$ cd services/core && go test -count=1 ./internal/storage ./internal/projectsync +ok github.com/nomadcode/nomadcode-core/internal/storage 0.003s +ok github.com/nomadcode/nomadcode-core/internal/projectsync 0.003s +``` + +### API-3 중간 검증 +``` +$ cd services/core && go test -count=1 ./internal/storage ./internal/projectsync +ok github.com/nomadcode/nomadcode-core/internal/storage 0.003s +ok github.com/nomadcode/nomadcode-core/internal/projectsync 0.003s +``` + +### 최종 검증 +``` +$ cd services/core && go test -count=1 ./... +ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke 0.003s +? github.com/nomadcode/nomadcode-core/cmd/server [no test files] +ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a 0.009s +ok github.com/nomadcode/nomadcode-core/internal/adapters/jira 0.009s +ok github.com/nomadcode/nomadcode-core/internal/adapters/mattermost 0.009s +ok github.com/nomadcode/nomadcode-core/internal/adapters/openai 0.006s +ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 0.007s +? github.com/nomadcode/nomadcode-core/internal/agent [no test files] +ok github.com/nomadcode/nomadcode-core/internal/config 0.002s +? github.com/nomadcode/nomadcode-core/internal/db [no test files] +ok github.com/nomadcode/nomadcode-core/internal/http 0.004s +? github.com/nomadcode/nomadcode-core/internal/model [no test files] +ok github.com/nomadcode/nomadcode-core/internal/notification 0.003s +ok github.com/nomadcode/nomadcode-core/internal/projectsync 0.004s +ok github.com/nomadcode/nomadcode-core/internal/protosocket 0.018s +ok github.com/nomadcode/nomadcode-core/internal/scheduler 1.514s +ok github.com/nomadcode/nomadcode-core/internal/storage 0.011s +ok github.com/nomadcode/nomadcode-core/internal/workflow 0.011s +ok github.com/nomadcode/nomadcode-core/internal/workitem 0.011s +ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 0.002s + +$ git diff --check +(출력 없음, exit code 0) +``` +보조 검증: `go vet ./...`(출력 없음), `gofmt -l`(변경 파일 모두 출력 없음). + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - correctness: Pass + - completeness: Pass + - test coverage: Pass + - API contract: Pass + - code quality: Pass + - plan deviation: Pass + - verification trust: Pass +- 발견된 문제: 없음 +- 다음 단계: PASS이므로 active plan/review를 log로 아카이브하고, `complete.log` 작성 후 task directory를 archive로 이동한다. diff --git a/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_workspace_slot_store/complete.log b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_workspace_slot_store/complete.log new file mode 100644 index 0000000..9ae7331 --- /dev/null +++ b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_workspace_slot_store/complete.log @@ -0,0 +1,45 @@ +# Complete - m-milestone-work-item-creation-sync/02+01_workspace_slot_store + +## 완료 일시 + +2026-06-06 + +## 요약 + +Workspace slot 상태 DB schema, SQLC query boundary, storage 예약 API, regression coverage를 1회 리뷰 루프에서 PASS로 완료했다. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_local_G06_0.log` | `code_review_local_G06_0.log` | PASS | `workspace_slots` persistence와 `available` slot 원자 예약 boundary가 계획 범위대로 구현되고 검증됐다. | + +## 구현/정리 내용 + +- `workspace_slots` migration, SQL query, generated DB model/query wrapper를 추가했다. +- `Store`에 slot list/upsert/reserve/update method와 no-available sentinel error를 추가했다. +- `projectsync` typed slot conversion과 upsert/state update params validation helper를 추가했다. +- storage/projectsync regression tests가 state 후보, index/state validation, query shape, args, error mapping을 검증하도록 추가됐다. + +## 최종 검증 + +- `cd services/core && go test -count=1 ./internal/storage ./internal/projectsync` - PASS; focused storage/projectsync tests 성공. +- `cd services/core && go test -count=1 ./...` - PASS; core 전체 Go packages 성공. +- `cd services/core && go vet ./...` - PASS; 출력 없음. +- `gofmt -l services/core/internal/db/models.go services/core/internal/db/workspace_slots.sql.go services/core/internal/projectsync/config.go services/core/internal/projectsync/config_test.go services/core/internal/storage/workspace_slots.go services/core/internal/storage/workspace_slots_test.go` - PASS; 출력 없음. +- `git diff --check` - PASS; 출력 없음. + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md` +- Completed task ids: + - `workspace-slot-state`: PASS; evidence=`agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_workspace_slot_store/plan_local_G06_0.log`, `agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_workspace_slot_store/code_review_local_G06_0.log`; verification=`cd services/core && go test -count=1 ./internal/storage ./internal/projectsync`, `cd services/core && go test -count=1 ./...`, `git diff --check` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-milestone-work-item-creation-sync/02+01_workspace_slot_store/PLAN-local-G06.md b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_workspace_slot_store/plan_local_G06_0.log similarity index 100% rename from agent-task/m-milestone-work-item-creation-sync/02+01_workspace_slot_store/PLAN-local-G06.md rename to agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_workspace_slot_store/plan_local_G06_0.log diff --git a/agent-task/m-milestone-work-item-creation-sync/02+01_workspace_slot_store/CODE_REVIEW-local-G06.md b/agent-task/m-milestone-work-item-creation-sync/02+01_workspace_slot_store/CODE_REVIEW-local-G06.md deleted file mode 100644 index 6562fb4..0000000 --- a/agent-task/m-milestone-work-item-creation-sync/02+01_workspace_slot_store/CODE_REVIEW-local-G06.md +++ /dev/null @@ -1,142 +0,0 @@ - - -# Code Review Reference - API - -> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** -> The task is NOT complete until every implementation-owned section below is filled in. -> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. -> Fill implementation-owned sections, then stop with active files in place and report ready for review. -> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves. -> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review. -> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. -> Follow the ownership table at the bottom of this file for which sections you own. - -## 개요 - -date=2026-06-06 -task=m-milestone-work-item-creation-sync/02+01_workspace_slot_store, plan=1, tag=API - -## Roadmap Targets - -- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md` -- Task ids: - - `workspace-slot-state`: Workspace slot의 사용중 상태 모델을 정의한다. -- Completion mode: check-on-pass - -## 이 파일을 읽는 리뷰 에이전트에게 - -> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. - -각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. -리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. - -1. 판정을 append한다. -2. `CODE_REVIEW-local-G06.md` → `code_review_local_G06_N.log`, `PLAN-local-G06.md` → `plan_local_G06_M.log`로 아카이브한다. -3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-milestone-work-item-creation-sync/02+01_workspace_slot_store/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. -4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. -5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. - ---- - -## 구현 항목별 완료 여부 - -| 항목 | 완료 여부 | -|------|---------| -| [API-1] Workspace slot DB schema and queries | [ ] | -| [API-2] Atomic slot reservation storage API | [ ] | -| [API-3] Slot state regression coverage | [ ] | - -## 구현 체크리스트 - -- [ ] `workspace_slots` migration과 SQLC queries를 추가한다. 검증: 각 slot은 `available`, `in_use`, `dirty`, `error` 상태 후보를 갖는다. -- [ ] `available` slot만 원자적으로 `in_use`로 예약하는 storage method를 추가한다. 검증: 동시에 들어온 요청이 같은 slot을 잡지 않도록 SQL 조건이 있다. -- [ ] Slot state persistence 테스트를 작성한다. -- [ ] `cd services/core && go test -count=1 ./...`와 `git diff --check`를 실행한다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. - -## 코드리뷰 전용 체크리스트 - -> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. -> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. - -- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. -- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. -- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_N.log`로 아카이브한다. -- [ ] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다. -- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. -- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. -- [ ] PASS이면 active task 디렉터리 `agent-task/m-milestone-work-item-creation-sync/02+01_workspace_slot_store/`를 `agent-task/archive/YYYY/MM/m-milestone-work-item-creation-sync/02+01_workspace_slot_store/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. -- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. -- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-milestone-work-item-creation-sync/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. -- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`를 작성하고 `complete.log`를 작성하지 않는다. -- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. -- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. - -## 계획 대비 변경 사항 - -_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ - -## 주요 설계 결정 - -_구현 에이전트가 주요 설계 결정 사항을 기록한다._ - -## 사용자 리뷰 요청 - -_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ - -- 상태: 없음 -- 사유 유형: 없음 -- 결정 필요: 없음 -- 차단 근거: 없음 -- 실행한 검증/명령: 없음 -- 자동 후속 불가 이유: 없음 -- 재개 조건: 없음 - -## 리뷰어를 위한 체크포인트 - -- Slot state check constraint와 Go enum이 같은 후보만 허용하는지 확인한다. -- 예약 query가 `available` 조건을 한 statement에서 보장하는지 확인한다. -- predecessor `01_project_sync_store`의 완료 근거가 있는지 확인한다. - -## 검증 결과 - -_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ - -필수 규칙: -- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. -- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. -- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. -- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. -- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. - -### API-1 중간 검증 -``` -$ cd services/core && go test -count=1 ./internal/db -(output) -``` - -### API-2 중간 검증 -``` -$ cd services/core && go test -count=1 ./internal/storage ./internal/projectsync -(output) -``` - -### API-3 중간 검증 -``` -$ cd services/core && go test -count=1 ./internal/storage ./internal/projectsync -(output) -``` - -### 최종 검증 -``` -$ cd services/core && go test -count=1 ./... -(output) -$ git diff --check -(output) -``` - ---- - -> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** -> If anything is blank, go back and fill it in before saving this file. -> Leave review-agent-only sections unchanged. diff --git a/services/core/internal/db/models.go b/services/core/internal/db/models.go index 2bf1f52..36d4187 100644 --- a/services/core/internal/db/models.go +++ b/services/core/internal/db/models.go @@ -40,3 +40,13 @@ type Task struct { ExternalMetadata json.RawMessage `json:"external_metadata"` Metadata json.RawMessage `json:"metadata"` } + +type WorkspaceSlot struct { + ID int64 `json:"id"` + ProjectSyncSettingID int64 `json:"project_sync_setting_id"` + SlotIndex int32 `json:"slot_index"` + State string `json:"state"` + Path string `json:"path"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/services/core/internal/db/workspace_slots.sql.go b/services/core/internal/db/workspace_slots.sql.go new file mode 100644 index 0000000..8d45762 --- /dev/null +++ b/services/core/internal/db/workspace_slots.sql.go @@ -0,0 +1,129 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: workspace_slots.sql + +package db + +import ( + "context" +) + +const listWorkspaceSlotsByProject = `-- name: ListWorkspaceSlotsByProject :many +SELECT id, project_sync_setting_id, slot_index, state, path, created_at, updated_at +FROM workspace_slots +WHERE project_sync_setting_id = $1 +ORDER BY slot_index +` + +func (q *Queries) ListWorkspaceSlotsByProject(ctx context.Context, projectSyncSettingID int64) ([]WorkspaceSlot, error) { + rows, err := q.db.Query(ctx, listWorkspaceSlotsByProject, projectSyncSettingID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceSlot + for rows.Next() { + var i WorkspaceSlot + if err := rows.Scan( + &i.ID, + &i.ProjectSyncSettingID, + &i.SlotIndex, + &i.State, + &i.Path, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const reserveAvailableWorkspaceSlot = `-- name: ReserveAvailableWorkspaceSlot :one +UPDATE workspace_slots +SET state = 'in_use', updated_at = now() +WHERE id = ( + SELECT id FROM workspace_slots + WHERE project_sync_setting_id = $1 AND state = 'available' + ORDER BY slot_index + LIMIT 1 + FOR UPDATE SKIP LOCKED +) +RETURNING id, project_sync_setting_id, slot_index, state, path, created_at, updated_at +` + +func (q *Queries) ReserveAvailableWorkspaceSlot(ctx context.Context, projectSyncSettingID int64) (WorkspaceSlot, error) { + row := q.db.QueryRow(ctx, reserveAvailableWorkspaceSlot, projectSyncSettingID) + var i WorkspaceSlot + err := row.Scan( + &i.ID, + &i.ProjectSyncSettingID, + &i.SlotIndex, + &i.State, + &i.Path, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateWorkspaceSlotState = `-- name: UpdateWorkspaceSlotState :one +UPDATE workspace_slots +SET state = $2, updated_at = now() +WHERE id = $1 +RETURNING id, project_sync_setting_id, slot_index, state, path, created_at, updated_at +` + +type UpdateWorkspaceSlotStateParams struct { + ID int64 `json:"id"` + State string `json:"state"` +} + +func (q *Queries) UpdateWorkspaceSlotState(ctx context.Context, arg UpdateWorkspaceSlotStateParams) (WorkspaceSlot, error) { + row := q.db.QueryRow(ctx, updateWorkspaceSlotState, arg.ID, arg.State) + var i WorkspaceSlot + err := row.Scan( + &i.ID, + &i.ProjectSyncSettingID, + &i.SlotIndex, + &i.State, + &i.Path, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const upsertWorkspaceSlot = `-- name: UpsertWorkspaceSlot :one +INSERT INTO workspace_slots (project_sync_setting_id, slot_index, state, path) +VALUES ($1, $2, 'available', $3) +ON CONFLICT (project_sync_setting_id, slot_index) +DO UPDATE SET path = EXCLUDED.path, updated_at = now() +RETURNING id, project_sync_setting_id, slot_index, state, path, created_at, updated_at +` + +type UpsertWorkspaceSlotParams struct { + ProjectSyncSettingID int64 `json:"project_sync_setting_id"` + SlotIndex int32 `json:"slot_index"` + Path string `json:"path"` +} + +func (q *Queries) UpsertWorkspaceSlot(ctx context.Context, arg UpsertWorkspaceSlotParams) (WorkspaceSlot, error) { + row := q.db.QueryRow(ctx, upsertWorkspaceSlot, arg.ProjectSyncSettingID, arg.SlotIndex, arg.Path) + var i WorkspaceSlot + err := row.Scan( + &i.ID, + &i.ProjectSyncSettingID, + &i.SlotIndex, + &i.State, + &i.Path, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/services/core/internal/projectsync/config.go b/services/core/internal/projectsync/config.go index b04f495..96e65d9 100644 --- a/services/core/internal/projectsync/config.go +++ b/services/core/internal/projectsync/config.go @@ -197,6 +197,52 @@ func (c Config) ToCreateProjectSyncParams() db.CreateProjectSyncSettingParams { } } +// WorkspaceSlot is the typed view of a persisted workspace slot row, keeping +// the slot index and state as projectsync domain types. +type WorkspaceSlot struct { + ID int64 + ProjectSyncSettingID int64 + Index SlotIndex + State SlotState + Path string +} + +// SlotFromDBRecord converts a generated db row into the typed WorkspaceSlot. +func SlotFromDBRecord(r db.WorkspaceSlot) WorkspaceSlot { + return WorkspaceSlot{ + ID: r.ID, + ProjectSyncSettingID: r.ProjectSyncSettingID, + Index: SlotIndex(r.SlotIndex), + State: SlotState(r.State), + Path: r.Path, + } +} + +// ToUpsertWorkspaceSlotParams builds DB upsert params for a slot, rejecting an +// out-of-range index before it reaches the database. +func ToUpsertWorkspaceSlotParams(projectSyncSettingID int64, index SlotIndex, path string) (db.UpsertWorkspaceSlotParams, error) { + if !index.Valid() { + return db.UpsertWorkspaceSlotParams{}, ErrInvalidConfig + } + return db.UpsertWorkspaceSlotParams{ + ProjectSyncSettingID: projectSyncSettingID, + SlotIndex: int32(index), + Path: path, + }, nil +} + +// ToUpdateWorkspaceSlotStateParams builds DB params for a slot state +// transition, rejecting unknown states to mirror the DB check constraint. +func ToUpdateWorkspaceSlotStateParams(id int64, state SlotState) (db.UpdateWorkspaceSlotStateParams, error) { + if !state.Valid() { + return db.UpdateWorkspaceSlotStateParams{}, ErrInvalidConfig + } + return db.UpdateWorkspaceSlotStateParams{ + ID: id, + State: string(state), + }, nil +} + func ConfigFromDBRecord(r db.ProjectSyncSetting) Config { return Config{ Target: ProviderProjectTarget{ diff --git a/services/core/internal/projectsync/config_test.go b/services/core/internal/projectsync/config_test.go index d33958d..9ee6780 100644 --- a/services/core/internal/projectsync/config_test.go +++ b/services/core/internal/projectsync/config_test.go @@ -151,6 +151,65 @@ func TestRepoDirNameRejectsNestedOrAbsolutePaths(t *testing.T) { } } +func TestSlotFromDBRecordPreservesTypedFields(t *testing.T) { + now := time.Now() + record := db.WorkspaceSlot{ + ID: 11, + ProjectSyncSettingID: 5, + SlotIndex: 2, + State: string(SlotStateInUse), + Path: "/home/user/workspace/nomadcode/002", + CreatedAt: now, + UpdatedAt: now, + } + + slot := SlotFromDBRecord(record) + if slot.ID != 11 || slot.ProjectSyncSettingID != 5 { + t.Errorf("identity not preserved: %+v", slot) + } + if slot.Index != SlotIndex(2) { + t.Errorf("index: got %d", slot.Index) + } + if slot.State != SlotStateInUse { + t.Errorf("state: got %q", slot.State) + } + if slot.Path != "/home/user/workspace/nomadcode/002" { + t.Errorf("path: got %q", slot.Path) + } +} + +func TestToUpsertWorkspaceSlotParamsValidatesIndex(t *testing.T) { + params, err := ToUpsertWorkspaceSlotParams(5, DefaultSlotIndex, "/ws/nomadcode/000") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if params.ProjectSyncSettingID != 5 || params.SlotIndex != 0 || params.Path != "/ws/nomadcode/000" { + t.Errorf("unexpected params: %+v", params) + } + + for _, index := range []SlotIndex{-1, MaxSlotIndex + 1} { + if _, err := ToUpsertWorkspaceSlotParams(5, index, "/ws/x"); !errors.Is(err, ErrInvalidConfig) { + t.Errorf("index %d: expected ErrInvalidConfig, got %v", index, err) + } + } +} + +func TestToUpdateWorkspaceSlotStateParamsAcceptsKnownStatesOnly(t *testing.T) { + for _, state := range []SlotState{SlotStateAvailable, SlotStateInUse, SlotStateDirty, SlotStateError} { + params, err := ToUpdateWorkspaceSlotStateParams(9, state) + if err != nil { + t.Fatalf("state %q: unexpected error: %v", state, err) + } + if params.ID != 9 || params.State != string(state) { + t.Errorf("state %q: unexpected params: %+v", state, params) + } + } + + if _, err := ToUpdateWorkspaceSlotStateParams(9, SlotState("bogus")); !errors.Is(err, ErrInvalidConfig) { + t.Errorf("expected ErrInvalidConfig for unknown state, got %v", err) + } +} + // TestConfigToCreateProjectSyncParams verifies the DB params conversion. func TestConfigToCreateProjectSyncParams(t *testing.T) { config := Config{ diff --git a/services/core/internal/storage/workspace_slots.go b/services/core/internal/storage/workspace_slots.go new file mode 100644 index 0000000..50053f9 --- /dev/null +++ b/services/core/internal/storage/workspace_slots.go @@ -0,0 +1,50 @@ +package storage + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + "github.com/nomadcode/nomadcode-core/internal/db" +) + +// ErrNoAvailableWorkspaceSlot is returned when no slot is in the available +// state for the requested project sync setting. +var ErrNoAvailableWorkspaceSlot = errors.New("no available workspace slot") + +// ListWorkspaceSlots returns all slots registered for a project sync setting +// ordered by slot index. +func (s *Store) ListWorkspaceSlots(ctx context.Context, projectSyncSettingID int64) ([]db.WorkspaceSlot, error) { + return s.queries.ListWorkspaceSlotsByProject(ctx, projectSyncSettingID) +} + +// UpsertWorkspaceSlot registers a slot for a project sync setting, or updates +// its path if the (project, index) pair already exists. The slot state is left +// untouched on conflict so an in-use slot is never reset by registration. +// Callers build args via projectsync.ToUpsertWorkspaceSlotParams to enforce the +// index range. +func (s *Store) UpsertWorkspaceSlot(ctx context.Context, args db.UpsertWorkspaceSlotParams) (db.WorkspaceSlot, error) { + return s.queries.UpsertWorkspaceSlot(ctx, args) +} + +// ReserveWorkspaceSlot atomically transitions a single available slot to +// in_use and returns it. Concurrent callers never receive the same slot +// because the underlying query reserves one row with FOR UPDATE SKIP LOCKED. +// When no slot is available it returns ErrNoAvailableWorkspaceSlot. +func (s *Store) ReserveWorkspaceSlot(ctx context.Context, projectSyncSettingID int64) (db.WorkspaceSlot, error) { + slot, err := s.queries.ReserveAvailableWorkspaceSlot(ctx, projectSyncSettingID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return db.WorkspaceSlot{}, ErrNoAvailableWorkspaceSlot + } + return db.WorkspaceSlot{}, err + } + return slot, nil +} + +// UpdateWorkspaceSlotState transitions a slot to a new state. Callers build +// args via projectsync.ToUpdateWorkspaceSlotStateParams, which rejects unknown +// states to mirror the DB check constraint. +func (s *Store) UpdateWorkspaceSlotState(ctx context.Context, args db.UpdateWorkspaceSlotStateParams) (db.WorkspaceSlot, error) { + return s.queries.UpdateWorkspaceSlotState(ctx, args) +} diff --git a/services/core/internal/storage/workspace_slots_test.go b/services/core/internal/storage/workspace_slots_test.go new file mode 100644 index 0000000..3bc585e --- /dev/null +++ b/services/core/internal/storage/workspace_slots_test.go @@ -0,0 +1,307 @@ +package storage + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/nomadcode/nomadcode-core/internal/db" +) + +// ---- mock DBTX for workspace slot queries ---- + +type slotFakeResult struct { + row db.WorkspaceSlot + err error +} + +type slotQueryCall struct { + sql string + args []interface{} +} + +type slotMockDBTX struct { + // result drives every QueryRow (:one) call. + result slotFakeResult + // listRows / listErr drive the Query (:many) call used by list. + listRows []db.WorkspaceSlot + listErr error + + queryLog []string + queryCalls []slotQueryCall +} + +func (m *slotMockDBTX) Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) { + return pgconn.NewCommandTag("OK"), nil +} + +func (m *slotMockDBTX) Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) { + m.queryLog = append(m.queryLog, sql) + m.queryCalls = append(m.queryCalls, slotQueryCall{sql: sql, args: args}) + if m.listErr != nil { + return nil, m.listErr + } + return &fakeSlotRows{rows: m.listRows}, nil +} + +func (m *slotMockDBTX) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row { + m.queryLog = append(m.queryLog, sql) + m.queryCalls = append(m.queryCalls, slotQueryCall{sql: sql, args: args}) + return &slotMockRow{result: m.result} +} + +type slotMockRow struct { + result slotFakeResult +} + +func (r *slotMockRow) Scan(dest ...interface{}) error { + if r.result.err != nil { + return r.result.err + } + scanWorkspaceSlot(r.result.row, dest) + return nil +} + +// fakeSlotRows is a minimal pgx.Rows over a fixed slice of workspace slots. +type fakeSlotRows struct { + rows []db.WorkspaceSlot + idx int + err error +} + +func (r *fakeSlotRows) Close() {} +func (r *fakeSlotRows) Err() error { return r.err } +func (r *fakeSlotRows) CommandTag() pgconn.CommandTag { return pgconn.NewCommandTag("SELECT") } +func (r *fakeSlotRows) FieldDescriptions() []pgconn.FieldDescription { return nil } +func (r *fakeSlotRows) Values() ([]interface{}, error) { return nil, nil } +func (r *fakeSlotRows) RawValues() [][]byte { return nil } +func (r *fakeSlotRows) Conn() *pgx.Conn { return nil } + +func (r *fakeSlotRows) Next() bool { + if r.idx >= len(r.rows) { + return false + } + r.idx++ + return true +} + +func (r *fakeSlotRows) Scan(dest ...interface{}) error { + scanWorkspaceSlot(r.rows[r.idx-1], dest) + return nil +} + +// scanWorkspaceSlot fills scan destinations in the column order used by the +// generated workspace_slots queries. +func scanWorkspaceSlot(row db.WorkspaceSlot, dest []interface{}) { + if len(dest) < 7 { + return + } + if p, ok := dest[0].(*int64); ok { + *p = row.ID + } + if p, ok := dest[1].(*int64); ok { + *p = row.ProjectSyncSettingID + } + if p, ok := dest[2].(*int32); ok { + *p = row.SlotIndex + } + if p, ok := dest[3].(*string); ok { + *p = row.State + } + if p, ok := dest[4].(*string); ok { + *p = row.Path + } + if p, ok := dest[5].(*time.Time); ok { + *p = row.CreatedAt + } + if p, ok := dest[6].(*time.Time); ok { + *p = row.UpdatedAt + } +} + +func slotSQLContains(logs []string, sub string) bool { + for _, l := range logs { + if strings.Contains(l, sub) { + return true + } + } + return false +} + +func newSlotStore(m *slotMockDBTX) *Store { + return NewStoreWithQueries(db.New(m)) +} + +// ---- ReserveWorkspaceSlot ---- + +func TestReserveWorkspaceSlot_ReservesAvailableAndReturnsRow(t *testing.T) { + m := &slotMockDBTX{result: slotFakeResult{row: db.WorkspaceSlot{ + ID: 11, ProjectSyncSettingID: 5, SlotIndex: 2, State: "in_use", Path: "/ws/nomadcode/002", + }}} + store := newSlotStore(m) + + slot, err := store.ReserveWorkspaceSlot(context.Background(), 5) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if slot.ID != 11 || slot.ProjectSyncSettingID != 5 || slot.SlotIndex != 2 { + t.Errorf("identity not preserved: %+v", slot) + } + if slot.State != "in_use" { + t.Errorf("state: got %q, want in_use", slot.State) + } + if slot.Path != "/ws/nomadcode/002" { + t.Errorf("path: got %q", slot.Path) + } +} + +func TestReserveWorkspaceSlot_AtomicGuardsInQuery(t *testing.T) { + m := &slotMockDBTX{result: slotFakeResult{row: db.WorkspaceSlot{ID: 1, State: "in_use"}}} + store := newSlotStore(m) + + if _, err := store.ReserveWorkspaceSlot(context.Background(), 7); err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, fragment := range []string{ + "state = 'available'", + "SET state = 'in_use'", + "FOR UPDATE SKIP LOCKED", + "LIMIT 1", + } { + if !slotSQLContains(m.queryLog, fragment) { + t.Errorf("expected reserve query to contain %q, queryLog=%v", fragment, m.queryLog) + } + } + if len(m.queryCalls) == 0 { + t.Fatal("expected a query call") + } + if got := m.queryCalls[0].args; len(got) != 1 || !reflect.DeepEqual(got[0], int64(7)) { + t.Errorf("reserve args: got %v, want [int64(7)]", got) + } +} + +func TestReserveWorkspaceSlot_NoAvailableReturnsSentinel(t *testing.T) { + m := &slotMockDBTX{result: slotFakeResult{err: pgx.ErrNoRows}} + store := newSlotStore(m) + + _, err := store.ReserveWorkspaceSlot(context.Background(), 5) + if !errors.Is(err, ErrNoAvailableWorkspaceSlot) { + t.Fatalf("expected ErrNoAvailableWorkspaceSlot, got %v", err) + } +} + +func TestReserveWorkspaceSlot_PreservesGenericDBError(t *testing.T) { + sentinel := errors.New("connection reset") + m := &slotMockDBTX{result: slotFakeResult{err: sentinel}} + store := newSlotStore(m) + + _, err := store.ReserveWorkspaceSlot(context.Background(), 5) + if !errors.Is(err, sentinel) { + t.Fatalf("expected sentinel DB error, got %v", err) + } + if errors.Is(err, ErrNoAvailableWorkspaceSlot) { + t.Error("generic DB error should not be reported as no-available") + } +} + +// ---- UpdateWorkspaceSlotState ---- + +func TestUpdateWorkspaceSlotState_PassesArgsAndGuard(t *testing.T) { + m := &slotMockDBTX{result: slotFakeResult{row: db.WorkspaceSlot{ID: 9, State: "dirty"}}} + store := newSlotStore(m) + + slot, err := store.UpdateWorkspaceSlotState(context.Background(), db.UpdateWorkspaceSlotStateParams{ + ID: 9, State: "dirty", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if slot.State != "dirty" { + t.Errorf("state: got %q", slot.State) + } + if !slotSQLContains(m.queryLog, "SET state = $2") { + t.Errorf("expected parameterized state update, queryLog=%v", m.queryLog) + } + if got := m.queryCalls[0].args; len(got) != 2 || + !reflect.DeepEqual(got[0], int64(9)) || + !reflect.DeepEqual(got[1], "dirty") { + t.Errorf("update args: got %v, want [int64(9) dirty]", got) + } +} + +// ---- UpsertWorkspaceSlot ---- + +func TestUpsertWorkspaceSlot_PassesArgsAndKeepsState(t *testing.T) { + m := &slotMockDBTX{result: slotFakeResult{row: db.WorkspaceSlot{ + ID: 3, ProjectSyncSettingID: 5, SlotIndex: 0, State: "available", Path: "/ws/nomadcode/000", + }}} + store := newSlotStore(m) + + slot, err := store.UpsertWorkspaceSlot(context.Background(), db.UpsertWorkspaceSlotParams{ + ProjectSyncSettingID: 5, SlotIndex: 0, Path: "/ws/nomadcode/000", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if slot.State != "available" { + t.Errorf("upserted state: got %q", slot.State) + } + if !slotSQLContains(m.queryLog, "INSERT INTO workspace_slots") || + !slotSQLContains(m.queryLog, "ON CONFLICT (project_sync_setting_id, slot_index)") { + t.Errorf("expected upsert query shape, queryLog=%v", m.queryLog) + } + if got := m.queryCalls[0].args; len(got) != 3 || + !reflect.DeepEqual(got[0], int64(5)) || + !reflect.DeepEqual(got[1], int32(0)) || + !reflect.DeepEqual(got[2], "/ws/nomadcode/000") { + t.Errorf("upsert args: got %v, want [int64(5) int32(0) path]", got) + } +} + +// ---- ListWorkspaceSlots ---- + +func TestListWorkspaceSlots_ReturnsRowsForEachState(t *testing.T) { + m := &slotMockDBTX{listRows: []db.WorkspaceSlot{ + {ID: 1, ProjectSyncSettingID: 5, SlotIndex: 0, State: "available", Path: "/ws/000"}, + {ID: 2, ProjectSyncSettingID: 5, SlotIndex: 1, State: "in_use", Path: "/ws/001"}, + {ID: 3, ProjectSyncSettingID: 5, SlotIndex: 2, State: "dirty", Path: "/ws/002"}, + {ID: 4, ProjectSyncSettingID: 5, SlotIndex: 3, State: "error", Path: "/ws/003"}, + }} + store := newSlotStore(m) + + slots, err := store.ListWorkspaceSlots(context.Background(), 5) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"available", "in_use", "dirty", "error"} + if len(slots) != len(want) { + t.Fatalf("expected %d slots, got %d", len(want), len(slots)) + } + for i, slot := range slots { + if int(slot.SlotIndex) != i { + t.Errorf("slot[%d] index: got %d", i, slot.SlotIndex) + } + if slot.State != want[i] { + t.Errorf("slot[%d] state: got %q, want %q", i, slot.State, want[i]) + } + } + if got := m.queryCalls[0].args; len(got) != 1 || !reflect.DeepEqual(got[0], int64(5)) { + t.Errorf("list args: got %v, want [int64(5)]", got) + } +} + +func TestListWorkspaceSlots_PropagatesQueryError(t *testing.T) { + sentinel := errors.New("query failed") + m := &slotMockDBTX{listErr: sentinel} + store := newSlotStore(m) + + _, err := store.ListWorkspaceSlots(context.Background(), 5) + if !errors.Is(err, sentinel) { + t.Fatalf("expected sentinel error, got %v", err) + } +} diff --git a/services/core/migrations/00005_create_workspace_slots.sql b/services/core/migrations/00005_create_workspace_slots.sql new file mode 100644 index 0000000..2ca83b7 --- /dev/null +++ b/services/core/migrations/00005_create_workspace_slots.sql @@ -0,0 +1,25 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS workspace_slots ( + id BIGSERIAL PRIMARY KEY, + project_sync_setting_id BIGINT NOT NULL REFERENCES project_sync_settings(id) ON DELETE CASCADE, + slot_index INTEGER NOT NULL, + state TEXT NOT NULL DEFAULT 'available', + path TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT workspace_slots_state_check + CHECK (state IN ('available', 'in_use', 'dirty', 'error')), + CONSTRAINT workspace_slots_index_range + CHECK (slot_index >= 0 AND slot_index <= 999) +); + +-- Each project sync setting owns at most one slot per index. +CREATE UNIQUE INDEX ux_workspace_slots_project_index + ON workspace_slots (project_sync_setting_id, slot_index); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS workspace_slots; +-- +goose StatementEnd diff --git a/services/core/queries/workspace_slots.sql b/services/core/queries/workspace_slots.sql new file mode 100644 index 0000000..ccc3447 --- /dev/null +++ b/services/core/queries/workspace_slots.sql @@ -0,0 +1,30 @@ +-- name: ListWorkspaceSlotsByProject :many +SELECT id, project_sync_setting_id, slot_index, state, path, created_at, updated_at +FROM workspace_slots +WHERE project_sync_setting_id = $1 +ORDER BY slot_index; + +-- name: UpsertWorkspaceSlot :one +INSERT INTO workspace_slots (project_sync_setting_id, slot_index, state, path) +VALUES ($1, $2, 'available', $3) +ON CONFLICT (project_sync_setting_id, slot_index) +DO UPDATE SET path = EXCLUDED.path, updated_at = now() +RETURNING id, project_sync_setting_id, slot_index, state, path, created_at, updated_at; + +-- name: ReserveAvailableWorkspaceSlot :one +UPDATE workspace_slots +SET state = 'in_use', updated_at = now() +WHERE id = ( + SELECT id FROM workspace_slots + WHERE project_sync_setting_id = $1 AND state = 'available' + ORDER BY slot_index + LIMIT 1 + FOR UPDATE SKIP LOCKED +) +RETURNING id, project_sync_setting_id, slot_index, state, path, created_at, updated_at; + +-- name: UpdateWorkspaceSlotState :one +UPDATE workspace_slots +SET state = $2, updated_at = now() +WHERE id = $1 +RETURNING id, project_sync_setting_id, slot_index, state, path, created_at, updated_at;