feat(core): 워크스페이스 슬롯 저장소를 추가한다

Milestone work item 생성 동기화에서 workspace slot 상태를 DB에 저장하고 available slot을 원자적으로 예약할 수 있어야 한다.

리뷰 루프 산출물을 archive하고, 현재 로드맵 우선순위를 Workbench Provider Slot Composition으로 조정한다.
This commit is contained in:
toki 2026-06-06 19:50:16 +09:00
parent 488a2e6c6a
commit 74ba7ebd76
18 changed files with 908 additions and 159 deletions

View file

@ -4,7 +4,7 @@
NomadCode는 Flutter 기반 앱, core 서비스, 공유 계약, agent-operation 규칙을 하나의 원레포로 묶어 AI-assisted development workflow를 조율하는 프로젝트다. NomadCode는 Flutter 기반 앱, core 서비스, 공유 계약, agent-operation 규칙을 하나의 원레포로 묶어 AI-assisted development workflow를 조율하는 프로젝트다.
현재 로드맵은 `ROADMAP.md -> phase/<phase-slug>/PHASE.md -> phase/<phase-slug>/milestones/<milestone-slug>.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-slug>/PHASE.md -> phase/<phase-slug>/milestones/<milestone-slug>.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의 기본 외부 실행 호출 표면으로 쓰지 않는다. 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 승인을 기다린다. - 요약: Plane 확장, Jira-compatible provider 추상화, Mattermost, IOP OpenAI-compatible Responses 호출과 metadata 기반 실행 문맥 전달을 완료했고, Phase 완료 확인과 archive 승인을 기다린다.
- [진행중] Project Workspace Management UX - [진행중] Project Workspace Management UX
- 경로: `agent-roadmap/phase/project-workspace-management-ux/PHASE.md` - 경로: `agent-roadmap/phase/project-workspace-management-ux/PHASE.md`
- 요약: client integration 표준화, core workflow, 외부 통합 기준 이후 프로젝트 단위 앱 UX의 desktop/mobile 역할과 IOP/task 결과 표시 기준을 정리한다. - 요약: client integration 표준화, core workflow, 외부 통합 기준 이후 프로젝트 단위 앱 UX를 다루며, 현재는 provider slot 기반 외부 console composition을 최우선으로 진행한다.
- [진행중] Agent-Ops MCP Control Plane - [계획] Agent-Ops MCP Control Plane
- 경로: `agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md` - 경로: `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 제어 표면을 다루는 계획 후보다.
## 로딩 정책 ## 로딩 정책

View file

@ -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 흐름 ## Milestone 흐름
@ -14,9 +14,9 @@
완료, 검토중, 진행중, 계획, 스케치 순서로 두어 아래로 갈수록 미래 작업에 가까워지게 정렬한다. 완료, 검토중, 진행중, 계획, 스케치 순서로 두어 아래로 갈수록 미래 작업에 가까워지게 정렬한다.
스케치 Milestone은 아직 구현 가능한 계획이 아니므로 계획 Milestone보다 아래에 둔다. 스케치 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` - 경로: `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 - [계획] Roadmap Driven Agent-Ops Automation
- 경로: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/roadmap-driven-agent-ops-automation.md` - 경로: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/roadmap-driven-agent-ops-automation.md`

View file

@ -11,7 +11,7 @@ Plane-origin 경로로 Milestone을 생성하고, `develop` branch의 agent-road
## 상태 ## 상태
[진행중] [계획]
## 구현 잠금 ## 구현 잠금

View file

@ -17,14 +17,14 @@ Flutter-first 클라이언트 구조 위에서 `apps/client` 모듈, web/mobile
- 경로: `agent-roadmap/archive/phase/project-workspace-management-ux/milestones/client-integration-standardization.md` - 경로: `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를 표준화했다. - 요약: NomadCode client를 기준으로 `apps/client` 모듈명, Web + Android 우선 bootstrap, proto-socket, Mattermost push host integration, cloning handoff를 표준화했다.
- [진행중] Project Workspace Management UX - [진행중] Workbench Provider Slot Composition
- 경로: `agent-roadmap/phase/project-workspace-management-ux/milestones/project-workspace-management-ux.md`
- 요약: 프로젝트 단위 제어 UX의 desktop/mobile 역할, parity 기준, IOP/task 결과 표시 기준을 정리하는 현재 작업 지점이다.
- [계획] Workbench Provider Slot Composition
- 경로: `agent-roadmap/phase/project-workspace-management-ux/milestones/workbench-provider-slot-composition.md` - 경로: `agent-roadmap/phase/project-workspace-management-ux/milestones/workbench-provider-slot-composition.md`
- 요약: 상단 titlebar와 우측 icon rail 기반 workbench를 유지하면서 IOP/OTO 같은 외부 Flutter console을 provider slot으로 등록, 삽입, 제거할 수 있는 구조로 정리한다. - 요약: 상단 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 경계 ## Phase 경계
- 기존 Project/Session/Workspace 방향을 대체하지 않고 보강한다. - 기존 Project/Session/Workspace 방향을 대체하지 않고 보강한다.

View file

@ -11,7 +11,7 @@ Flutter-first 클라이언트 구조 위에서 기존 Project/Session/Workspace
## 상태 ## 상태
[진행중] [계획]
## 구현 잠금 ## 구현 잠금
@ -66,5 +66,5 @@ Flutter-first 앱에서 project/session/workspace 제어 표면과 desktop/mobil
- 전환 참고 경로: [Legacy/Retired] `apps/web/` (제거됨) - 전환 참고 경로: [Legacy/Retired] `apps/web/` (제거됨)
- 선행 작업: Flutter-first Client Consolidation, Client Integration Standardization, External Integration - 선행 작업: 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 투영 기준 결정이 선행되어야 한다.
- 확인 필요: `구현 잠금`의 결정 필요 항목 참고. - 확인 필요: `구현 잠금`의 결정 필요 항목 참고.

View file

@ -11,7 +11,7 @@ NomadCode Flutter 앱의 상단 titlebar와 우측 icon rail 기반 workbench sh
## 상태 ## 상태
[계획] [진행중]
## 구현 잠금 ## 구현 잠금

View file

@ -0,0 +1,190 @@
<!-- task=m-milestone-work-item-creation-sync/02+01_workspace_slot_store plan=1 tag=API -->
# 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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로 이동한다.

View file

@ -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
- 없음
## 후속 작업
- 없음

View file

@ -1,142 +0,0 @@
<!-- task=m-milestone-work-item-creation-sync/02+01_workspace_slot_store plan=1 tag=API -->
# 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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.

View file

@ -40,3 +40,13 @@ type Task struct {
ExternalMetadata json.RawMessage `json:"external_metadata"` ExternalMetadata json.RawMessage `json:"external_metadata"`
Metadata json.RawMessage `json:"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"`
}

View file

@ -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
}

View file

@ -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 { func ConfigFromDBRecord(r db.ProjectSyncSetting) Config {
return Config{ return Config{
Target: ProviderProjectTarget{ Target: ProviderProjectTarget{

View file

@ -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. // TestConfigToCreateProjectSyncParams verifies the DB params conversion.
func TestConfigToCreateProjectSyncParams(t *testing.T) { func TestConfigToCreateProjectSyncParams(t *testing.T) {
config := Config{ config := Config{

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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

View file

@ -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;