feat(core): 워크스페이스 슬롯 저장소를 추가한다
Milestone work item 생성 동기화에서 workspace slot 상태를 DB에 저장하고 available slot을 원자적으로 예약할 수 있어야 한다. 리뷰 루프 산출물을 archive하고, 현재 로드맵 우선순위를 Workbench Provider Slot Composition으로 조정한다.
This commit is contained in:
parent
488a2e6c6a
commit
74ba7ebd76
18 changed files with 908 additions and 159 deletions
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
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의 기본 외부 실행 호출 표면으로 쓰지 않는다.
|
||||
|
||||
|
|
@ -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 제어 표면을 다루는 계획 후보다.
|
||||
|
||||
## 로딩 정책
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ Plane-origin 경로로 Milestone을 생성하고, `develop` branch의 agent-road
|
|||
|
||||
## 상태
|
||||
|
||||
[진행중]
|
||||
[계획]
|
||||
|
||||
## 구현 잠금
|
||||
|
||||
|
|
|
|||
|
|
@ -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 방향을 대체하지 않고 보강한다.
|
||||
|
|
|
|||
|
|
@ -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 투영 기준 결정이 선행되어야 한다.
|
||||
- 확인 필요: `구현 잠금`의 결정 필요 항목 참고.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ NomadCode Flutter 앱의 상단 titlebar와 우측 icon rail 기반 workbench sh
|
|||
|
||||
## 상태
|
||||
|
||||
[계획]
|
||||
[진행중]
|
||||
|
||||
## 구현 잠금
|
||||
|
||||
|
|
|
|||
|
|
@ -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로 이동한다.
|
||||
|
|
@ -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
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
129
services/core/internal/db/workspace_slots.sql.go
Normal file
129
services/core/internal/db/workspace_slots.sql.go
Normal 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
|
||||
}
|
||||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
50
services/core/internal/storage/workspace_slots.go
Normal file
50
services/core/internal/storage/workspace_slots.go
Normal 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)
|
||||
}
|
||||
307
services/core/internal/storage/workspace_slots_test.go
Normal file
307
services/core/internal/storage/workspace_slots_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
25
services/core/migrations/00005_create_workspace_slots.sql
Normal file
25
services/core/migrations/00005_create_workspace_slots.sql
Normal 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
|
||||
30
services/core/queries/workspace_slots.sql
Normal file
30
services/core/queries/workspace_slots.sql
Normal 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;
|
||||
Loading…
Reference in a new issue