diff --git a/cmd/rdev-api/main.go b/cmd/rdev-api/main.go index 5379d81..788d898 100644 --- a/cmd/rdev-api/main.go +++ b/cmd/rdev-api/main.go @@ -262,6 +262,26 @@ func main() { // Create work service (for worker pool task management) workService := service.NewWorkService(workQueueRepo).WithWebhookDispatcher(webhookDispatcher) + // Create conversation service (for Foundary chat persistence) + conversationRepo := postgres.NewConversationRepository(database.DB) + conversationService := service.NewConversationService(conversationRepo) + + // Create blueprint service (for Foundary structured specs) + blueprintRepo := postgres.NewBlueprintRepository(database.DB) + blueprintService := service.NewBlueprintService(blueprintRepo) + + // Create architect service (for Foundary conversational orchestration) + architectService := service.NewArchitectService( + conversationService, + blueprintService, + agentRegistry, + projectRepo, + ) + + // Create question service (for Foundary structured questions) + questionRepo := postgres.NewQuestionRepository(database.DB) + questionService := service.NewQuestionService(questionRepo) + // Initialize operation tracking (for debugging project failures) operationRepo := postgres.NewOperationRepository(database.DB) operationService := service.NewOperationService(operationRepo) @@ -358,6 +378,10 @@ func main() { queueHandler := handlers.NewQueueHandler(commandQueue, projectRepo) webhookHandler := handlers.NewWebhookHandler(webhookRepo, projectRepo) workHandler := handlers.NewWorkHandler(workService) + conversationsHandler := handlers.NewConversationsHandler(conversationService) + blueprintsHandler := handlers.NewBlueprintsHandler(blueprintService) + architectHandler := handlers.NewArchitectHandler(architectService) + questionsHandler := handlers.NewQuestionsHandler(questionService) // Initialize domain and slug repositories projectDomainRepo := postgres.NewProjectDomainRepository(database.DB) @@ -543,6 +567,10 @@ func main() { queueHandler.Mount(app.Router()) webhookHandler.Mount(app.Router()) workHandler.Mount(app.Router()) + conversationsHandler.Mount(app.Router()) + blueprintsHandler.Mount(app.Router()) + architectHandler.Mount(app.Router()) + questionsHandler.Mount(app.Router()) infraHandler.Mount(app.Router()) projectMgmtHandler.Mount(app.Router()) if componentsHandler != nil { diff --git a/cmd/rdev-api/openapi.go b/cmd/rdev-api/openapi.go index 27a4380..e2d18d3 100644 --- a/cmd/rdev-api/openapi.go +++ b/cmd/rdev-api/openapi.go @@ -67,6 +67,7 @@ Command output is streamed via Server-Sent Events (SSE) at /projects/{id}/events spec.WithTag("Webhooks", "External webhook receivers") spec.WithTag("Infrastructure", "Git, deployment, DNS, and CI pipeline management") spec.WithTag("Sagas", "Distributed workflow orchestration with compensation") + spec.WithTag("Foundary", "Conversational project design and specification") // Register all path operations registerSystemPaths(spec) @@ -87,6 +88,10 @@ Command output is streamed via Server-Sent Events (SSE) at /projects/{id}/events registerWebhookPaths(spec) registerInfrastructurePaths(spec) registerSagaPaths(spec) + registerConversationPaths(spec) + registerBlueprintPaths(spec) + registerArchitectPaths(spec) + registerQuestionPaths(spec) return spec } diff --git a/cmd/rdev-api/openapi_ext.go b/cmd/rdev-api/openapi_ext.go index f461f84..77ed54a 100644 --- a/cmd/rdev-api/openapi_ext.go +++ b/cmd/rdev-api/openapi_ext.go @@ -1503,3 +1503,304 @@ Marks step as skipped and allows dependent steps to proceed.`, }, )) } + +func registerConversationPaths(spec *api.OpenAPISpec) { + spec.AddPath("/projects/{id}/conversations", "post", withAuthBodyAndParams( + "Create conversation", + `Creates a new conversation for conversational project design. + +Part of the Foundary Studio system for interactive requirements gathering.`, + "Foundary", + "projects:execute", + []param{{Name: "id", In: "path", Description: "Project ID", Required: true}}, + `{"title": "Landing page design discussion"}`, + `{ + "id": "conv-abc123", + "project_id": "my-project", + "title": "Landing page design discussion", + "created_at": "2026-02-09T00:00:00Z", + "updated_at": "2026-02-09T00:00:00Z" +}`, + )) + + spec.AddPath("/projects/{id}/conversations", "get", withAuthAndParams( + "List conversations", + `Returns all conversations for a project.`, + "Foundary", + "projects:read", + []param{{Name: "id", In: "path", Description: "Project ID", Required: true}}, + )) + + spec.AddPath("/projects/{id}/conversations/{conversationId}", "get", withAuthAndParams( + "Get conversation", + `Returns a single conversation with all messages.`, + "Foundary", + "projects:read", + []param{ + {Name: "id", In: "path", Description: "Project ID", Required: true}, + {Name: "conversationId", In: "path", Description: "Conversation ID", Required: true}, + }, + )) + + spec.AddPath("/projects/{id}/conversations/{conversationId}/messages", "post", withAuthBodyAndParams( + "Add message", + `Adds a message to a conversation.`, + "Foundary", + "projects:execute", + []param{ + {Name: "id", In: "path", Description: "Project ID", Required: true}, + {Name: "conversationId", In: "path", Description: "Conversation ID", Required: true}, + }, + `{"role": "user", "content": "I need a modern landing page with dark mode"}`, + `{ + "id": "msg-abc123", + "conversation_id": "conv-abc123", + "role": "user", + "content": "I need a modern landing page with dark mode", + "created_at": "2026-02-09T00:00:00Z" +}`, + )) + + spec.AddPath("/projects/{id}/conversations/{conversationId}/messages", "get", withAuthAndParams( + "List messages", + `Returns all messages in a conversation.`, + "Foundary", + "projects:read", + []param{ + {Name: "id", In: "path", Description: "Project ID", Required: true}, + {Name: "conversationId", In: "path", Description: "Conversation ID", Required: true}, + }, + )) + + spec.AddPath("/projects/{id}/conversations/{conversationId}", "delete", withAuthAndParams( + "Delete conversation", + `Deletes a conversation and all its messages.`, + "Foundary", + "projects:execute", + []param{ + {Name: "id", In: "path", Description: "Project ID", Required: true}, + {Name: "conversationId", In: "path", Description: "Conversation ID", Required: true}, + }, + )) +} + +func registerBlueprintPaths(spec *api.OpenAPISpec) { + spec.AddPath("/projects/{id}/blueprints", "post", withAuthBodyAndParams( + "Create blueprint", + `Creates a structured project blueprint with JSONB spec storage. + +Blueprints capture the technical specification extracted from conversations.`, + "Foundary", + "projects:execute", + []param{{Name: "id", In: "path", Description: "Project ID", Required: true}}, + `{ + "name": "Landing Page v1", + "description": "Modern landing page with Astro and Tailwind", + "spec": { + "stack": ["astro", "tailwind", "typescript"], + "components": ["hero", "features", "cta"], + "features": {"dark_mode": true, "responsive": true} + } +}`, + `{ + "id": "bp-abc123", + "project_id": "my-project", + "name": "Landing Page v1", + "description": "Modern landing page with Astro and Tailwind", + "spec": { + "stack": ["astro", "tailwind", "typescript"], + "components": ["hero", "features", "cta"], + "features": {"dark_mode": true, "responsive": true} + }, + "created_at": "2026-02-09T00:00:00Z", + "updated_at": "2026-02-09T00:00:00Z" +}`, + )) + + spec.AddPath("/projects/{id}/blueprints", "get", withAuthAndParams( + "List blueprints", + `Returns all blueprints for a project.`, + "Foundary", + "projects:read", + []param{{Name: "id", In: "path", Description: "Project ID", Required: true}}, + )) + + spec.AddPath("/projects/{id}/blueprints/{blueprintId}", "get", withAuthAndParams( + "Get blueprint", + `Returns a single blueprint with full spec.`, + "Foundary", + "projects:read", + []param{ + {Name: "id", In: "path", Description: "Project ID", Required: true}, + {Name: "blueprintId", In: "path", Description: "Blueprint ID", Required: true}, + }, + )) + + spec.AddPath("/projects/{id}/blueprints/{blueprintId}", "put", withAuthBodyAndParams( + "Update blueprint", + `Updates a blueprint's metadata or spec.`, + "Foundary", + "projects:execute", + []param{ + {Name: "id", In: "path", Description: "Project ID", Required: true}, + {Name: "blueprintId", In: "path", Description: "Blueprint ID", Required: true}, + }, + `{ + "name": "Landing Page v2", + "spec": { + "stack": ["astro", "tailwind", "typescript"], + "features": {"dark_mode": true, "animations": true} + } +}`, + `{"message": "blueprint updated"}`, + )) + + spec.AddPath("/projects/{id}/blueprints/{blueprintId}", "delete", withAuthAndParams( + "Delete blueprint", + `Deletes a blueprint permanently.`, + "Foundary", + "projects:execute", + []param{ + {Name: "id", In: "path", Description: "Project ID", Required: true}, + {Name: "blueprintId", In: "path", Description: "Blueprint ID", Required: true}, + }, + )) +} + +func registerArchitectPaths(spec *api.OpenAPISpec) { + spec.AddPath("/projects/{id}/architect/start", "post", withAuthBodyAndParams( + "Start architect conversation", + `Starts a new conversational design session with the AI architect. + +The architect asks clarifying questions and guides requirements gathering.`, + "Foundary", + "projects:execute", + []param{{Name: "id", In: "path", Description: "Project ID", Required: true}}, + `{ + "title": "E-commerce platform", + "initial_message": "I want to build a marketplace for handmade goods" +}`, + `{ + "conversation_id": "conv-abc123", + "title": "E-commerce platform", + "response": "Great! A marketplace for handmade goods. Let me ask some questions to understand your vision better. What scale are you targeting? Will this be for local artisans or a global marketplace?" +}`, + )) + + spec.AddPath("/projects/{id}/architect/continue/{conversationId}", "post", withAuthBodyAndParams( + "Continue architect conversation", + `Continues an existing conversation with the AI architect.`, + "Foundary", + "projects:execute", + []param{ + {Name: "id", In: "path", Description: "Project ID", Required: true}, + {Name: "conversationId", In: "path", Description: "Conversation ID", Required: true}, + }, + `{"message": "Global marketplace, starting with 100 sellers"}`, + `{ + "conversation_id": "conv-abc123", + "response": "Understood. For a global marketplace with 100 sellers initially, we'll need to consider international payments, multi-currency support, and shipping logistics. What's your timeline for launch?" +}`, + )) + + spec.AddPath("/projects/{id}/architect/generate-blueprint/{conversationId}", "post", withAuthAndParams( + "Generate blueprint from conversation", + `Extracts a structured blueprint from the conversation history. + +Uses AI to parse requirements into a technical specification.`, + "Foundary", + "projects:execute", + []param{ + {Name: "id", In: "path", Description: "Project ID", Required: true}, + {Name: "conversationId", In: "path", Description: "Conversation ID", Required: true}, + }, + )) +} + +func registerQuestionPaths(spec *api.OpenAPISpec) { + spec.AddPath("/projects/{id}/questions", "post", withAuthBodyAndParams( + "Create question", + `Creates a structured question for the architect to ask the user. + +Supports text, choice, multichoice, and yes/no question types.`, + "Foundary", + "projects:execute", + []param{{Name: "id", In: "path", Description: "Project ID", Required: true}}, + `{ + "conversation_id": "conv-abc123", + "type": "choice", + "text": "What authentication method would you prefer?", + "choices": ["Email/Password", "OAuth (Google, GitHub)", "Magic Link", "Phone/SMS"], + "metadata": {"category": "auth"} +}`, + `{ + "id": "q-abc123", + "conversation_id": "conv-abc123", + "project_id": "my-project", + "type": "choice", + "text": "What authentication method would you prefer?", + "choices": ["Email/Password", "OAuth (Google, GitHub)", "Magic Link", "Phone/SMS"], + "metadata": {"category": "auth"}, + "created_at": "2026-02-09T00:00:00Z" +}`, + )) + + spec.AddPath("/projects/{id}/questions", "get", withAuthAndParams( + "List unanswered questions", + `Returns all unanswered questions for a project.`, + "Foundary", + "projects:read", + []param{{Name: "id", In: "path", Description: "Project ID", Required: true}}, + )) + + spec.AddPath("/projects/{id}/questions/{questionId}", "get", withAuthAndParams( + "Get question", + `Returns a single question by ID.`, + "Foundary", + "projects:read", + []param{ + {Name: "id", In: "path", Description: "Project ID", Required: true}, + {Name: "questionId", In: "path", Description: "Question ID", Required: true}, + }, + )) + + spec.AddPath("/projects/{id}/questions/conversation/{conversationId}", "get", withAuthAndParams( + "List questions by conversation", + `Returns all questions (answered and unanswered) for a conversation.`, + "Foundary", + "projects:read", + []param{ + {Name: "id", In: "path", Description: "Project ID", Required: true}, + {Name: "conversationId", In: "path", Description: "Conversation ID", Required: true}, + }, + )) + + spec.AddPath("/projects/{id}/questions/{questionId}/answer", "post", withAuthBodyAndParams( + "Answer question", + `Records an answer to a question. + +Answer format depends on question type: +- text/yesno: single string in "answer" field +- choice: single string in "answer" field (must match a choice) +- multichoice: array of strings in "answer_choices" field`, + "Foundary", + "projects:execute", + []param{ + {Name: "id", In: "path", Description: "Project ID", Required: true}, + {Name: "questionId", In: "path", Description: "Question ID", Required: true}, + }, + `{"answer": "OAuth (Google, GitHub)"}`, + `{"message": "question answered"}`, + )) + + spec.AddPath("/projects/{id}/questions/{questionId}", "delete", withAuthAndParams( + "Delete question", + `Deletes a question permanently.`, + "Foundary", + "projects:execute", + []param{ + {Name: "id", In: "path", Description: "Project ID", Required: true}, + {Name: "questionId", In: "path", Description: "Question ID", Required: true}, + }, + )) +} diff --git a/cookbooks/trees/foundary.yaml b/cookbooks/trees/foundary.yaml new file mode 100644 index 0000000..65462f7 --- /dev/null +++ b/cookbooks/trees/foundary.yaml @@ -0,0 +1,998 @@ +name: foundary +description: "Foundary Studio: Conversational product development lifecycle. Bootstraps a React+API+DB project, specs two features via SDLC, implements with build agents, reviews, merges, and releases." +version: 1 + +vars: + project_name: "" + feature_1_slug: "data-models" + feature_1_title: "Core Data Models & Persistence" + feature_2_slug: "task-management-ui" + feature_2_title: "Task Management UI" + +steps: + # ============================================================ + # SECTION 1: INFRASTRUCTURE + # Create project with React app, API service, and database + # ============================================================ + create-project: + description: "Create project with bootstrap build" + action: api + method: POST + endpoint: /project/create-and-build + body: + name: "{{ .vars.project_name }}" + description: "Foundary Studio: Conversational product development" + template: "default" + prompt: "Set up the monorepo workspace. Ensure the root README describes a product studio for conversational product development." + auto_commit: true + auto_push: true + outputs: + - project_id: .data.project_id + - domain: .data.domain + - git_clone_http: .data.git.clone_http + - bootstrap_task_id: .data.task_id + + wait-bootstrap: + description: "Wait for bootstrap build to complete" + depends_on: [create-project] + action: wait_build + build_id: "{{ .outputs.create-project.bootstrap_task_id }}" + max_attempts: 720 + poll_interval: 5 + + add-components: + description: "Add React frontend, API service, and Postgres database" + depends_on: [wait-bootstrap] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/components/batch" + body: + components: + - type: app-react + name: studio-ui + - type: service + name: studio-api + - type: postgres + name: studio-db + + wait-components: + description: "Wait for component scaffolding pipeline" + depends_on: [add-components] + action: wait_pipeline + project_id: "{{ .outputs.create-project.project_id }}" + max_attempts: 720 + on_error: continue + + verify-site: + description: "Verify site is live after component scaffolding" + depends_on: [wait-components] + action: wait_site + domain: "{{ .outputs.create-project.domain }}" + max_attempts: 60 + + verify-sdlc: + description: "Verify SDLC state is initialized" + depends_on: [verify-site] + action: api + method: GET + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/state" + outputs: + - sdlc_initialized: .data.initialized + + # ============================================================ + # SECTION 2: ARCHITECT (Shell Stubs for Future Chat/Blueprint APIs) + # Documents the conversational architect flow that will use + # POST /projects/{id}/chat and GET /projects/{id}/blueprints + # ============================================================ + architect-session: + description: "Simulate architect conversation (stub for future chat API)" + depends_on: [verify-sdlc] + action: shell + command: | + cat <<'ARCHITECT' + ============================================================ + FOUNDARY ARCHITECT SESSION (Future: POST /projects/{id}/chat) + ============================================================ + The architect conversation would: + 1. Discuss product goals for a task management studio + 2. Identify core entities: projects, tasks, labels, assignments + 3. Propose two features for MVP: + - Feature 1: data-models (Core Data Models & Persistence) + - Feature 2: task-management-ui (Task Management UI) + 4. Generate blueprint (Future: GET /projects/{id}/blueprints) + + Feature definitions for downstream SDLC: + { + "features": [ + { + "slug": "data-models", + "title": "Core Data Models & Persistence", + "requirements": "Define Task, Project, Label, and Assignment entities with full CRUD. Postgres storage via studio-db. REST endpoints on studio-api. Include migrations, repository layer, service layer, and handler tests." + }, + { + "slug": "task-management-ui", + "title": "Task Management UI", + "requirements": "React UI in studio-ui for managing tasks. Kanban board view with drag-and-drop columns (To Do, In Progress, Done). Task creation/edit modal. Filter by label and assignee. Connects to studio-api REST endpoints." + } + ] + } + ARCHITECT + echo "Architect session complete — feature definitions ready" + + # ============================================================ + # SECTION 3: FEATURE 1 — Core Data Models (draft → released) + # Full 10-phase SDLC lifecycle + # ============================================================ + + # --- Phase 1: Draft --- + f1-create-feature: + description: "Create data-models feature in draft phase" + depends_on: [architect-session] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features" + body: + slug: "{{ .vars.feature_1_slug }}" + title: "{{ .vars.feature_1_title }}" + outputs: + - feature_phase: .data.phase + + f1-verify-draft: + description: "Verify feature 1 is in draft phase" + depends_on: [f1-create-feature] + action: shell + command: | + PHASE="{{ .outputs.f1-create-feature.feature_phase }}" + if [ "$PHASE" == "draft" ]; then + echo "Feature 1 created in draft phase" + exit 0 + else + echo "Expected draft, got $PHASE" + exit 1 + fi + + # --- Phase 2: Draft → Specified --- + f1-write-spec: + description: "Agent writes spec for data models" + depends_on: [f1-verify-draft] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" + body: + prompt: "/spec-feature {{ .vars.feature_1_slug }} --requirements 'Define Task, Project, Label, and Assignment entities with full CRUD. Postgres storage via studio-db. REST endpoints on studio-api. Include migrations, repository layer, service layer, and handler tests.'" + auto_commit: true + auto_push: true + git_clone_url: "{{ .outputs.create-project.git_clone_http }}" + outputs: + - build_id: .data.task_id + + f1-wait-spec: + depends_on: [f1-write-spec] + action: wait_build + build_id: "{{ .outputs.f1-write-spec.build_id }}" + max_attempts: 720 + poll_interval: 5 + + f1-approve-spec: + description: "Approve spec artifact" + depends_on: [f1-wait-spec] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_1_slug }}/artifacts/spec/approve" + body: + comment: "Spec approved by foundary automation" + + f1-transition-to-specified: + description: "Transition from draft to specified" + depends_on: [f1-approve-spec] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_1_slug }}/transition" + body: + phase: "specified" + outputs: + - new_phase: .data.phase + + # --- Phase 3: Specified → Planned --- + f1-write-design: + description: "Agent writes design for data models" + depends_on: [f1-transition-to-specified] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" + body: + prompt: "/design-feature {{ .vars.feature_1_slug }}" + auto_commit: true + auto_push: true + git_clone_url: "{{ .outputs.create-project.git_clone_http }}" + outputs: + - build_id: .data.task_id + + f1-wait-design: + depends_on: [f1-write-design] + action: wait_build + build_id: "{{ .outputs.f1-write-design.build_id }}" + max_attempts: 720 + poll_interval: 5 + + f1-approve-design: + description: "Approve design artifact" + depends_on: [f1-wait-design] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_1_slug }}/artifacts/design/approve" + body: + comment: "Design approved by foundary automation" + + f1-write-tasks: + description: "Agent breaks down into tasks" + depends_on: [f1-approve-design] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" + body: + prompt: "/breakdown-feature {{ .vars.feature_1_slug }}" + auto_commit: true + auto_push: true + git_clone_url: "{{ .outputs.create-project.git_clone_http }}" + outputs: + - build_id: .data.task_id + + f1-wait-tasks: + depends_on: [f1-write-tasks] + action: wait_build + build_id: "{{ .outputs.f1-write-tasks.build_id }}" + max_attempts: 720 + poll_interval: 5 + + f1-approve-tasks: + description: "Approve tasks artifact" + depends_on: [f1-wait-tasks] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_1_slug }}/artifacts/tasks/approve" + body: + comment: "Tasks approved by foundary automation" + + f1-write-qa-plan: + description: "Agent writes QA plan" + depends_on: [f1-approve-tasks] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" + body: + prompt: "/create-qa-plan {{ .vars.feature_1_slug }}" + auto_commit: true + auto_push: true + git_clone_url: "{{ .outputs.create-project.git_clone_http }}" + outputs: + - build_id: .data.task_id + + f1-wait-qa-plan: + depends_on: [f1-write-qa-plan] + action: wait_build + build_id: "{{ .outputs.f1-write-qa-plan.build_id }}" + max_attempts: 720 + poll_interval: 5 + + f1-approve-qa-plan: + description: "Approve QA plan artifact" + depends_on: [f1-wait-qa-plan] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_1_slug }}/artifacts/qa_plan/approve" + body: + comment: "QA plan approved by foundary automation" + + f1-transition-to-planned: + description: "Transition from specified to planned" + depends_on: [f1-approve-qa-plan] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_1_slug }}/transition" + body: + phase: "planned" + outputs: + - new_phase: .data.phase + + # --- Phase 4: Planned → Ready --- + f1-create-branch: + description: "Create feature branch for data-models" + depends_on: [f1-transition-to-planned] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_1_slug }}/branches" + outputs: + - branch_name: .data.name + + f1-transition-to-ready: + description: "Transition from planned to ready" + depends_on: [f1-create-branch] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_1_slug }}/transition" + body: + phase: "ready" + outputs: + - new_phase: .data.phase + + # --- Phase 5: Ready → Implementation --- + f1-implement: + description: "Agent implements data models feature" + depends_on: [f1-transition-to-ready] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" + body: + prompt: "/implement-feature {{ .vars.feature_1_slug }}" + auto_commit: true + auto_push: true + git_clone_url: "{{ .outputs.create-project.git_clone_http }}" + outputs: + - build_id: .data.task_id + + f1-wait-implement: + depends_on: [f1-implement] + action: wait_build + build_id: "{{ .outputs.f1-implement.build_id }}" + max_attempts: 720 + poll_interval: 5 + + f1-wait-deploy-impl: + description: "Wait for implementation to deploy" + depends_on: [f1-wait-implement] + action: wait_pipeline + project_id: "{{ .outputs.create-project.project_id }}" + max_attempts: 720 + + f1-transition-to-implementation: + description: "Transition to implementation phase" + depends_on: [f1-wait-deploy-impl] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_1_slug }}/transition" + body: + phase: "implementation" + outputs: + - new_phase: .data.phase + + # --- Phase 6: Implementation → Review --- + f1-write-review: + description: "Agent writes code review" + depends_on: [f1-transition-to-implementation] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" + body: + prompt: "/review-feature {{ .vars.feature_1_slug }}" + auto_commit: true + auto_push: true + git_clone_url: "{{ .outputs.create-project.git_clone_http }}" + outputs: + - build_id: .data.task_id + + f1-wait-review: + depends_on: [f1-write-review] + action: wait_build + build_id: "{{ .outputs.f1-write-review.build_id }}" + max_attempts: 720 + poll_interval: 5 + + f1-pass-review: + description: "Mark review as passed" + depends_on: [f1-wait-review] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_1_slug }}/artifacts/review/pass" + + f1-transition-to-review: + description: "Transition to review phase" + depends_on: [f1-pass-review] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_1_slug }}/transition" + body: + phase: "review" + outputs: + - new_phase: .data.phase + + # --- Phase 7: Review → Audit --- + f1-write-audit: + description: "Agent writes security audit" + depends_on: [f1-transition-to-review] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" + body: + prompt: "/audit-feature {{ .vars.feature_1_slug }}" + auto_commit: true + auto_push: true + git_clone_url: "{{ .outputs.create-project.git_clone_http }}" + outputs: + - build_id: .data.task_id + + f1-wait-audit: + depends_on: [f1-write-audit] + action: wait_build + build_id: "{{ .outputs.f1-write-audit.build_id }}" + max_attempts: 720 + poll_interval: 5 + + f1-pass-audit: + description: "Mark audit as passed" + depends_on: [f1-wait-audit] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_1_slug }}/artifacts/audit/pass" + + f1-transition-to-audit: + description: "Transition to audit phase" + depends_on: [f1-pass-audit] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_1_slug }}/transition" + body: + phase: "audit" + outputs: + - new_phase: .data.phase + + # --- Phase 8: Audit → QA --- + f1-run-qa: + description: "Agent runs QA plan" + depends_on: [f1-transition-to-audit] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" + body: + prompt: "/run-qa {{ .vars.feature_1_slug }}" + auto_commit: true + auto_push: true + git_clone_url: "{{ .outputs.create-project.git_clone_http }}" + outputs: + - build_id: .data.task_id + + f1-wait-qa: + depends_on: [f1-run-qa] + action: wait_build + build_id: "{{ .outputs.f1-run-qa.build_id }}" + max_attempts: 720 + poll_interval: 5 + + f1-pass-qa: + description: "Mark QA as passed" + depends_on: [f1-wait-qa] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_1_slug }}/artifacts/qa_results/pass" + + f1-transition-to-qa: + description: "Transition to QA phase" + depends_on: [f1-pass-qa] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_1_slug }}/transition" + body: + phase: "qa" + outputs: + - new_phase: .data.phase + + # --- Phase 9: QA → Merge --- + f1-transition-to-merge: + description: "Transition to merge phase" + depends_on: [f1-transition-to-qa] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_1_slug }}/transition" + body: + phase: "merge" + outputs: + - new_phase: .data.phase + + f1-merge: + description: "Merge data-models feature branch to main" + depends_on: [f1-transition-to-merge] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_1_slug }}/merge" + body: + strategy: "squash" + outputs: + - merge_commit: .data.commit_sha + + f1-wait-merge-deploy: + description: "Wait for merged code to deploy" + depends_on: [f1-merge] + action: wait_pipeline + project_id: "{{ .outputs.create-project.project_id }}" + max_attempts: 720 + + # --- Phase 10: Merge → Released --- + f1-archive: + description: "Archive data-models feature" + depends_on: [f1-wait-merge-deploy] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_1_slug }}/archive" + + f1-transition-to-released: + description: "Transition to released phase" + depends_on: [f1-archive] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_1_slug }}/transition" + body: + phase: "released" + outputs: + - final_phase: .data.phase + + # ============================================================ + # SECTION 4: FEATURE 2 — Task Management UI (draft → released) + # Spec starts after Feature 1 spec (independent) + # Design depends on Feature 1 reaching planned (schema defined) + # Implementation depends on Feature 1 released (schema in main) + # ============================================================ + + # --- Phase 1: Draft --- + # Feature 2 creation can start after Feature 1 spec is approved (parallel speccing) + f2-create-feature: + description: "Create task-management-ui feature in draft phase" + depends_on: [f1-approve-spec] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features" + body: + slug: "{{ .vars.feature_2_slug }}" + title: "{{ .vars.feature_2_title }}" + outputs: + - feature_phase: .data.phase + + f2-verify-draft: + description: "Verify feature 2 is in draft phase" + depends_on: [f2-create-feature] + action: shell + command: | + PHASE="{{ .outputs.f2-create-feature.feature_phase }}" + if [ "$PHASE" == "draft" ]; then + echo "Feature 2 created in draft phase" + exit 0 + else + echo "Expected draft, got $PHASE" + exit 1 + fi + + # --- Phase 2: Draft → Specified --- + f2-write-spec: + description: "Agent writes spec for task management UI" + depends_on: [f2-verify-draft] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" + body: + prompt: "/spec-feature {{ .vars.feature_2_slug }} --requirements 'React UI in studio-ui for managing tasks. Kanban board view with drag-and-drop columns (To Do, In Progress, Done). Task creation/edit modal with title, description, label, assignee fields. Filter by label and assignee. Connects to studio-api REST endpoints for Task CRUD.'" + auto_commit: true + auto_push: true + git_clone_url: "{{ .outputs.create-project.git_clone_http }}" + outputs: + - build_id: .data.task_id + + f2-wait-spec: + depends_on: [f2-write-spec] + action: wait_build + build_id: "{{ .outputs.f2-write-spec.build_id }}" + max_attempts: 720 + poll_interval: 5 + + f2-approve-spec: + description: "Approve spec artifact" + depends_on: [f2-wait-spec] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_2_slug }}/artifacts/spec/approve" + body: + comment: "Spec approved by foundary automation" + + f2-transition-to-specified: + description: "Transition from draft to specified" + depends_on: [f2-approve-spec] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_2_slug }}/transition" + body: + phase: "specified" + outputs: + - new_phase: .data.phase + + # --- Phase 3: Specified → Planned --- + # Design depends on Feature 1 reaching planned (schema must be defined first) + f2-write-design: + description: "Agent writes design for task management UI (after F1 schema planned)" + depends_on: [f2-transition-to-specified, f1-transition-to-planned] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" + body: + prompt: "/design-feature {{ .vars.feature_2_slug }}" + auto_commit: true + auto_push: true + git_clone_url: "{{ .outputs.create-project.git_clone_http }}" + outputs: + - build_id: .data.task_id + + f2-wait-design: + depends_on: [f2-write-design] + action: wait_build + build_id: "{{ .outputs.f2-write-design.build_id }}" + max_attempts: 720 + poll_interval: 5 + + f2-approve-design: + description: "Approve design artifact" + depends_on: [f2-wait-design] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_2_slug }}/artifacts/design/approve" + body: + comment: "Design approved by foundary automation" + + f2-write-tasks: + description: "Agent breaks down into tasks" + depends_on: [f2-approve-design] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" + body: + prompt: "/breakdown-feature {{ .vars.feature_2_slug }}" + auto_commit: true + auto_push: true + git_clone_url: "{{ .outputs.create-project.git_clone_http }}" + outputs: + - build_id: .data.task_id + + f2-wait-tasks: + depends_on: [f2-write-tasks] + action: wait_build + build_id: "{{ .outputs.f2-write-tasks.build_id }}" + max_attempts: 720 + poll_interval: 5 + + f2-approve-tasks: + description: "Approve tasks artifact" + depends_on: [f2-wait-tasks] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_2_slug }}/artifacts/tasks/approve" + body: + comment: "Tasks approved by foundary automation" + + f2-write-qa-plan: + description: "Agent writes QA plan" + depends_on: [f2-approve-tasks] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" + body: + prompt: "/create-qa-plan {{ .vars.feature_2_slug }}" + auto_commit: true + auto_push: true + git_clone_url: "{{ .outputs.create-project.git_clone_http }}" + outputs: + - build_id: .data.task_id + + f2-wait-qa-plan: + depends_on: [f2-write-qa-plan] + action: wait_build + build_id: "{{ .outputs.f2-write-qa-plan.build_id }}" + max_attempts: 720 + poll_interval: 5 + + f2-approve-qa-plan: + description: "Approve QA plan artifact" + depends_on: [f2-wait-qa-plan] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_2_slug }}/artifacts/qa_plan/approve" + body: + comment: "QA plan approved by foundary automation" + + f2-transition-to-planned: + description: "Transition from specified to planned" + depends_on: [f2-approve-qa-plan] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_2_slug }}/transition" + body: + phase: "planned" + outputs: + - new_phase: .data.phase + + # --- Phase 4: Planned → Ready --- + f2-create-branch: + description: "Create feature branch for task-management-ui" + depends_on: [f2-transition-to-planned] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_2_slug }}/branches" + outputs: + - branch_name: .data.name + + f2-transition-to-ready: + description: "Transition from planned to ready" + depends_on: [f2-create-branch] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_2_slug }}/transition" + body: + phase: "ready" + outputs: + - new_phase: .data.phase + + # --- Phase 5: Ready → Implementation --- + # Implementation depends on Feature 1 being released (schema in main) + f2-implement: + description: "Agent implements task management UI (after F1 released to main)" + depends_on: [f2-transition-to-ready, f1-transition-to-released] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" + body: + prompt: "/implement-feature {{ .vars.feature_2_slug }}" + auto_commit: true + auto_push: true + git_clone_url: "{{ .outputs.create-project.git_clone_http }}" + outputs: + - build_id: .data.task_id + + f2-wait-implement: + depends_on: [f2-implement] + action: wait_build + build_id: "{{ .outputs.f2-implement.build_id }}" + max_attempts: 720 + poll_interval: 5 + + f2-wait-deploy-impl: + description: "Wait for implementation to deploy" + depends_on: [f2-wait-implement] + action: wait_pipeline + project_id: "{{ .outputs.create-project.project_id }}" + max_attempts: 720 + + f2-transition-to-implementation: + description: "Transition to implementation phase" + depends_on: [f2-wait-deploy-impl] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_2_slug }}/transition" + body: + phase: "implementation" + outputs: + - new_phase: .data.phase + + # --- Phase 6: Implementation → Review --- + f2-write-review: + description: "Agent writes code review" + depends_on: [f2-transition-to-implementation] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" + body: + prompt: "/review-feature {{ .vars.feature_2_slug }}" + auto_commit: true + auto_push: true + git_clone_url: "{{ .outputs.create-project.git_clone_http }}" + outputs: + - build_id: .data.task_id + + f2-wait-review: + depends_on: [f2-write-review] + action: wait_build + build_id: "{{ .outputs.f2-write-review.build_id }}" + max_attempts: 720 + poll_interval: 5 + + f2-pass-review: + description: "Mark review as passed" + depends_on: [f2-wait-review] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_2_slug }}/artifacts/review/pass" + + f2-transition-to-review: + description: "Transition to review phase" + depends_on: [f2-pass-review] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_2_slug }}/transition" + body: + phase: "review" + outputs: + - new_phase: .data.phase + + # --- Phase 7: Review → Audit --- + f2-write-audit: + description: "Agent writes security audit" + depends_on: [f2-transition-to-review] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" + body: + prompt: "/audit-feature {{ .vars.feature_2_slug }}" + auto_commit: true + auto_push: true + git_clone_url: "{{ .outputs.create-project.git_clone_http }}" + outputs: + - build_id: .data.task_id + + f2-wait-audit: + depends_on: [f2-write-audit] + action: wait_build + build_id: "{{ .outputs.f2-write-audit.build_id }}" + max_attempts: 720 + poll_interval: 5 + + f2-pass-audit: + description: "Mark audit as passed" + depends_on: [f2-wait-audit] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_2_slug }}/artifacts/audit/pass" + + f2-transition-to-audit: + description: "Transition to audit phase" + depends_on: [f2-pass-audit] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_2_slug }}/transition" + body: + phase: "audit" + outputs: + - new_phase: .data.phase + + # --- Phase 8: Audit → QA --- + f2-run-qa: + description: "Agent runs QA plan" + depends_on: [f2-transition-to-audit] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" + body: + prompt: "/run-qa {{ .vars.feature_2_slug }}" + auto_commit: true + auto_push: true + git_clone_url: "{{ .outputs.create-project.git_clone_http }}" + outputs: + - build_id: .data.task_id + + f2-wait-qa: + depends_on: [f2-run-qa] + action: wait_build + build_id: "{{ .outputs.f2-run-qa.build_id }}" + max_attempts: 720 + poll_interval: 5 + + f2-pass-qa: + description: "Mark QA as passed" + depends_on: [f2-wait-qa] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_2_slug }}/artifacts/qa_results/pass" + + f2-transition-to-qa: + description: "Transition to QA phase" + depends_on: [f2-pass-qa] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_2_slug }}/transition" + body: + phase: "qa" + outputs: + - new_phase: .data.phase + + # --- Phase 9: QA → Merge --- + f2-transition-to-merge: + description: "Transition to merge phase" + depends_on: [f2-transition-to-qa] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_2_slug }}/transition" + body: + phase: "merge" + outputs: + - new_phase: .data.phase + + f2-merge: + description: "Merge task-management-ui feature branch to main" + depends_on: [f2-transition-to-merge] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_2_slug }}/merge" + body: + strategy: "squash" + outputs: + - merge_commit: .data.commit_sha + + f2-wait-merge-deploy: + description: "Wait for merged code to deploy" + depends_on: [f2-merge] + action: wait_pipeline + project_id: "{{ .outputs.create-project.project_id }}" + max_attempts: 720 + + # --- Phase 10: Merge → Released --- + f2-archive: + description: "Archive task-management-ui feature" + depends_on: [f2-wait-merge-deploy] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_2_slug }}/archive" + + f2-transition-to-released: + description: "Transition to released phase" + depends_on: [f2-archive] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_2_slug }}/transition" + body: + phase: "released" + outputs: + - final_phase: .data.phase + + # ============================================================ + # SECTION 5: VERIFICATION + # ============================================================ + verify-site-live: + description: "Verify frontend and API are live after both features" + depends_on: [f2-transition-to-released] + action: shell + command: | + DOMAIN="{{ .outputs.create-project.domain }}" + + # Check frontend + UI_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://$DOMAIN") + echo "Frontend status: $UI_STATUS" + + # Check API health + API_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://$DOMAIN/api/studio-api/health") + echo "API status: $API_STATUS" + + if [ "$UI_STATUS" -ge 200 ] && [ "$UI_STATUS" -lt 400 ] && [ "$API_STATUS" -ge 200 ] && [ "$API_STATUS" -lt 400 ]; then + echo "Site is live: frontend and API responding" + exit 0 + else + echo "Site check failed" + exit 1 + fi + + verify-sdlc-complete: + description: "Verify both features reached released phase" + depends_on: [verify-site-live] + action: shell + command: | + F1_PHASE="{{ .outputs.f1-transition-to-released.final_phase }}" + F2_PHASE="{{ .outputs.f2-transition-to-released.final_phase }}" + + echo "Feature 1 (data-models): $F1_PHASE" + echo "Feature 2 (task-management-ui): $F2_PHASE" + + if [ "$F1_PHASE" == "released" ] && [ "$F2_PHASE" == "released" ]; then + echo "" + echo "============================================================" + echo "SUCCESS: Foundary Studio lifecycle complete" + echo "============================================================" + echo "Both features traversed all 10 SDLC phases:" + echo " draft → specified → planned → ready → implementation →" + echo " review → audit → qa → merge → released" + echo "" + echo "Infrastructure: React + API + Postgres (batch provisioned)" + echo "Feature 1: Core Data Models & Persistence" + echo "Feature 2: Task Management UI (dependent on F1 schema)" + echo "============================================================" + exit 0 + else + echo "FAIL: Expected both features at released" + exit 1 + fi + +teardown: + - action: api + method: DELETE + endpoint: "/project/{{ .outputs.create-project.project_id }}" diff --git a/docs/ui/ideation/ascii_screens.md b/docs/ui/ideation/ascii_screens.md index 58ad8f1..a45c6c7 100644 --- a/docs/ui/ideation/ascii_screens.md +++ b/docs/ui/ideation/ascii_screens.md @@ -1,4 +1,4 @@ -# Orchard Studio UI - ASCII Screens +# Foundary Studio UI - ASCII Screens ## 1. Project Dashboard (The "Lobby") @@ -6,7 +6,7 @@ Entry point for the Product Owner. ```text +-----------------------------------------------------------------------------+ -| ORCHARD STUDIO [ New Project ] | +| Foundary Studio [ New Project ] | +-----------------------------------------------------------------------------+ | | | Active Projects | diff --git a/docs/ui/ideation/reference.md b/docs/ui/ideation/reference.md index abb5545..dc93dcb 100644 --- a/docs/ui/ideation/reference.md +++ b/docs/ui/ideation/reference.md @@ -1,6 +1,6 @@ -# Technical Reference: Orchard Studio UI +# Technical Reference: Foundary Studio UI -This document maps the Orchard Studio UI requirements to the existing `rdev` backend architecture and identifies necessary enhancements. +This document maps the Foundary Studio UI requirements to the existing `rdev` backend architecture and identifies necessary enhancements. ## 1. Architecture Overview diff --git a/docs/ui/ideation/roadmap.md b/docs/ui/ideation/roadmap.md index 49387b1..fca227d 100644 --- a/docs/ui/ideation/roadmap.md +++ b/docs/ui/ideation/roadmap.md @@ -1,6 +1,6 @@ -# Roadmap: Orchard Studio UI Implementation +# Roadmap: Foundary Studio UI Implementation -This roadmap outlines the steps to move from the current `rdev` backend to the fully realized Orchard Studio UI. +This roadmap outlines the steps to move from the current `rdev` backend to the fully realized Foundary Studio UI. ## Phase 1: Foundation & Read-Only UI **Goal:** Visualize the current state of `rdev` projects and work queues. @@ -57,5 +57,5 @@ This roadmap outlines the steps to move from the current `rdev` backend to the f **Goal:** Production readiness. 1. **Real-time Polish**: Replace polling with SSE/WebSockets for all status updates. -2. **Visual Design**: Apply "Orchard" branding (dark mode, crisp typography). +2. **Visual Design**: Apply "Foundary" branding (dark mode, crisp typography). 3. **Mobile Responsiveness**: Ensure critical flows work on tablet/mobile. diff --git a/internal/adapter/postgres/blueprint_repository.go b/internal/adapter/postgres/blueprint_repository.go new file mode 100644 index 0000000..2f25832 --- /dev/null +++ b/internal/adapter/postgres/blueprint_repository.go @@ -0,0 +1,176 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" +) + +// BlueprintRepository implements port.BlueprintRepository using PostgreSQL. +type BlueprintRepository struct { + db *sql.DB +} + +// NewBlueprintRepository creates a new PostgreSQL blueprint repository. +func NewBlueprintRepository(db *sql.DB) *BlueprintRepository { + return &BlueprintRepository{db: db} +} + +// Ensure BlueprintRepository implements port.BlueprintRepository at compile time. +var _ port.BlueprintRepository = (*BlueprintRepository)(nil) + +// CreateBlueprint creates a new blueprint. +func (r *BlueprintRepository) CreateBlueprint(ctx context.Context, blueprint *domain.Blueprint) error { + specJSON, err := json.Marshal(blueprint.Spec) + if err != nil { + return fmt.Errorf("marshal spec: %w", err) + } + + err = r.db.QueryRowContext(ctx, ` + INSERT INTO blueprints (project_id, name, description, spec) + VALUES ($1, $2, $3, $4) + RETURNING id, created_at, updated_at + `, blueprint.ProjectID, blueprint.Name, blueprint.Description, specJSON).Scan( + &blueprint.ID, + &blueprint.CreatedAt, + &blueprint.UpdatedAt, + ) + if err != nil { + return fmt.Errorf("create blueprint: %w", err) + } + + return nil +} + +// GetBlueprint retrieves a blueprint by ID. +func (r *BlueprintRepository) GetBlueprint(ctx context.Context, id domain.BlueprintID) (*domain.Blueprint, error) { + var blueprint domain.Blueprint + var specJSON []byte + var description sql.NullString + + err := r.db.QueryRowContext(ctx, ` + SELECT id, project_id, name, description, spec, created_at, updated_at + FROM blueprints + WHERE id = $1 + `, id).Scan( + &blueprint.ID, + &blueprint.ProjectID, + &blueprint.Name, + &description, + &specJSON, + &blueprint.CreatedAt, + &blueprint.UpdatedAt, + ) + if err == sql.ErrNoRows { + return nil, domain.ErrBlueprintNotFound + } + if err != nil { + return nil, fmt.Errorf("get blueprint: %w", err) + } + + if description.Valid { + blueprint.Description = description.String + } + + if err := json.Unmarshal(specJSON, &blueprint.Spec); err != nil { + return nil, fmt.Errorf("unmarshal spec: %w", err) + } + + return &blueprint, nil +} + +// ListBlueprints returns all blueprints for a project. +func (r *BlueprintRepository) ListBlueprints(ctx context.Context, projectID string) ([]*domain.Blueprint, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, project_id, name, description, spec, created_at, updated_at + FROM blueprints + WHERE project_id = $1 + ORDER BY created_at DESC + `, projectID) + if err != nil { + return nil, fmt.Errorf("list blueprints: %w", err) + } + defer rows.Close() + + var blueprints []*domain.Blueprint + for rows.Next() { + var blueprint domain.Blueprint + var specJSON []byte + var description sql.NullString + + if err := rows.Scan( + &blueprint.ID, + &blueprint.ProjectID, + &blueprint.Name, + &description, + &specJSON, + &blueprint.CreatedAt, + &blueprint.UpdatedAt, + ); err != nil { + return nil, fmt.Errorf("scan blueprint: %w", err) + } + + if description.Valid { + blueprint.Description = description.String + } + + if err := json.Unmarshal(specJSON, &blueprint.Spec); err != nil { + return nil, fmt.Errorf("unmarshal spec: %w", err) + } + + blueprints = append(blueprints, &blueprint) + } + + return blueprints, rows.Err() +} + +// UpdateBlueprint updates a blueprint's metadata and spec. +func (r *BlueprintRepository) UpdateBlueprint(ctx context.Context, blueprint *domain.Blueprint) error { + specJSON, err := json.Marshal(blueprint.Spec) + if err != nil { + return fmt.Errorf("marshal spec: %w", err) + } + + result, err := r.db.ExecContext(ctx, ` + UPDATE blueprints + SET name = $1, description = $2, spec = $3 + WHERE id = $4 + `, blueprint.Name, blueprint.Description, specJSON, blueprint.ID) + if err != nil { + return fmt.Errorf("update blueprint: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("rows affected: %w", err) + } + if rows == 0 { + return domain.ErrBlueprintNotFound + } + + return nil +} + +// DeleteBlueprint deletes a blueprint. +func (r *BlueprintRepository) DeleteBlueprint(ctx context.Context, id domain.BlueprintID) error { + result, err := r.db.ExecContext(ctx, ` + DELETE FROM blueprints WHERE id = $1 + `, id) + if err != nil { + return fmt.Errorf("delete blueprint: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("rows affected: %w", err) + } + if rows == 0 { + return domain.ErrBlueprintNotFound + } + + return nil +} diff --git a/internal/adapter/postgres/conversation_repository.go b/internal/adapter/postgres/conversation_repository.go new file mode 100644 index 0000000..8410fe9 --- /dev/null +++ b/internal/adapter/postgres/conversation_repository.go @@ -0,0 +1,229 @@ +package postgres + +import ( + "context" + "database/sql" + "fmt" + + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" +) + +// ConversationRepository implements port.ConversationRepository using PostgreSQL. +type ConversationRepository struct { + db *sql.DB +} + +// NewConversationRepository creates a new PostgreSQL conversation repository. +func NewConversationRepository(db *sql.DB) *ConversationRepository { + return &ConversationRepository{db: db} +} + +// Ensure ConversationRepository implements port.ConversationRepository at compile time. +var _ port.ConversationRepository = (*ConversationRepository)(nil) + +// CreateConversation creates a new conversation. +func (r *ConversationRepository) CreateConversation(ctx context.Context, projectID, title string) (*domain.Conversation, error) { + var conv domain.Conversation + var lastMessage sql.NullTime + + err := r.db.QueryRowContext(ctx, ` + INSERT INTO conversations (project_id, title) + VALUES ($1, $2) + RETURNING id, project_id, title, created_at, updated_at, last_message_at + `, projectID, title).Scan( + &conv.ID, + &conv.ProjectID, + &conv.Title, + &conv.CreatedAt, + &conv.UpdatedAt, + &lastMessage, + ) + if err != nil { + return nil, fmt.Errorf("create conversation: %w", err) + } + + if lastMessage.Valid { + conv.LastMessage = &lastMessage.Time + } + + return &conv, nil +} + +// GetConversation retrieves a conversation by ID. +func (r *ConversationRepository) GetConversation(ctx context.Context, id domain.ConversationID) (*domain.Conversation, error) { + var conv domain.Conversation + var lastMessage sql.NullTime + + err := r.db.QueryRowContext(ctx, ` + SELECT id, project_id, title, created_at, updated_at, last_message_at + FROM conversations + WHERE id = $1 + `, id).Scan( + &conv.ID, + &conv.ProjectID, + &conv.Title, + &conv.CreatedAt, + &conv.UpdatedAt, + &lastMessage, + ) + if err == sql.ErrNoRows { + return nil, domain.ErrConversationNotFound + } + if err != nil { + return nil, fmt.Errorf("get conversation: %w", err) + } + + if lastMessage.Valid { + conv.LastMessage = &lastMessage.Time + } + + return &conv, nil +} + +// ListConversations returns all conversations for a project. +func (r *ConversationRepository) ListConversations(ctx context.Context, projectID string) ([]*domain.Conversation, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, project_id, title, created_at, updated_at, last_message_at + FROM conversations + WHERE project_id = $1 + ORDER BY last_message_at DESC NULLS LAST, created_at DESC + `, projectID) + if err != nil { + return nil, fmt.Errorf("list conversations: %w", err) + } + defer rows.Close() + + var convs []*domain.Conversation + for rows.Next() { + var conv domain.Conversation + var lastMessage sql.NullTime + if err := rows.Scan( + &conv.ID, + &conv.ProjectID, + &conv.Title, + &conv.CreatedAt, + &conv.UpdatedAt, + &lastMessage, + ); err != nil { + return nil, fmt.Errorf("scan conversation: %w", err) + } + if lastMessage.Valid { + conv.LastMessage = &lastMessage.Time + } + convs = append(convs, &conv) + } + + return convs, rows.Err() +} + +// UpdateConversationTitle updates the conversation title. +func (r *ConversationRepository) UpdateConversationTitle(ctx context.Context, id domain.ConversationID, title string) error { + result, err := r.db.ExecContext(ctx, ` + UPDATE conversations + SET title = $1 + WHERE id = $2 + `, title, id) + if err != nil { + return fmt.Errorf("update conversation title: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("rows affected: %w", err) + } + if rows == 0 { + return domain.ErrConversationNotFound + } + + return nil +} + +// DeleteConversation deletes a conversation and all its messages. +func (r *ConversationRepository) DeleteConversation(ctx context.Context, id domain.ConversationID) error { + result, err := r.db.ExecContext(ctx, ` + DELETE FROM conversations WHERE id = $1 + `, id) + if err != nil { + return fmt.Errorf("delete conversation: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("rows affected: %w", err) + } + if rows == 0 { + return domain.ErrConversationNotFound + } + + return nil +} + +// AddMessage adds a message to a conversation. +func (r *ConversationRepository) AddMessage(ctx context.Context, conversationID domain.ConversationID, role domain.MessageRole, content string) (*domain.Message, error) { + var msg domain.Message + err := r.db.QueryRowContext(ctx, ` + INSERT INTO messages (conversation_id, role, content) + VALUES ($1, $2, $3) + RETURNING id, conversation_id, role, content, created_at + `, conversationID, role, content).Scan( + &msg.ID, + &msg.ConversationID, + &msg.Role, + &msg.Content, + &msg.CreatedAt, + ) + if err != nil { + return nil, fmt.Errorf("add message: %w", err) + } + return &msg, nil +} + +// GetMessages retrieves all messages for a conversation. +func (r *ConversationRepository) GetMessages(ctx context.Context, conversationID domain.ConversationID) ([]*domain.Message, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, conversation_id, role, content, created_at + FROM messages + WHERE conversation_id = $1 + ORDER BY created_at ASC + `, conversationID) + if err != nil { + return nil, fmt.Errorf("get messages: %w", err) + } + defer rows.Close() + + var messages []*domain.Message + for rows.Next() { + var msg domain.Message + if err := rows.Scan( + &msg.ID, + &msg.ConversationID, + &msg.Role, + &msg.Content, + &msg.CreatedAt, + ); err != nil { + return nil, fmt.Errorf("scan message: %w", err) + } + messages = append(messages, &msg) + } + + return messages, rows.Err() +} + +// GetConversationWithMessages retrieves a conversation with all messages. +func (r *ConversationRepository) GetConversationWithMessages(ctx context.Context, id domain.ConversationID) (*domain.ConversationWithMessages, error) { + conv, err := r.GetConversation(ctx, id) + if err != nil { + return nil, err + } + + messages, err := r.GetMessages(ctx, id) + if err != nil { + return nil, err + } + + return &domain.ConversationWithMessages{ + Conversation: *conv, + Messages: messages, + }, nil +} diff --git a/internal/adapter/postgres/question_repository.go b/internal/adapter/postgres/question_repository.go new file mode 100644 index 0000000..7584995 --- /dev/null +++ b/internal/adapter/postgres/question_repository.go @@ -0,0 +1,249 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/lib/pq" + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" +) + +// QuestionRepository implements port.QuestionRepository using PostgreSQL. +type QuestionRepository struct { + db *sql.DB +} + +// NewQuestionRepository creates a new PostgreSQL question repository. +func NewQuestionRepository(db *sql.DB) *QuestionRepository { + return &QuestionRepository{db: db} +} + +// Ensure QuestionRepository implements port.QuestionRepository at compile time. +var _ port.QuestionRepository = (*QuestionRepository)(nil) + +// CreateQuestion creates a new question. +func (r *QuestionRepository) CreateQuestion(ctx context.Context, question *domain.Question) error { + metadataJSON, err := json.Marshal(question.Metadata) + if err != nil { + return fmt.Errorf("marshal metadata: %w", err) + } + + err = r.db.QueryRowContext(ctx, ` + INSERT INTO questions (conversation_id, project_id, question_type, text, choices, metadata) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, created_at + `, question.ConversationID, question.ProjectID, question.Type, question.Text, pq.Array(question.Choices), metadataJSON).Scan( + &question.ID, + &question.CreatedAt, + ) + if err != nil { + return fmt.Errorf("create question: %w", err) + } + + return nil +} + +// GetQuestion retrieves a question by ID. +func (r *QuestionRepository) GetQuestion(ctx context.Context, id domain.QuestionID) (*domain.Question, error) { + var question domain.Question + var answer sql.NullString + var answeredAt sql.NullTime + var metadataJSON []byte + + err := r.db.QueryRowContext(ctx, ` + SELECT id, conversation_id, project_id, question_type, text, choices, answer, answer_choices, metadata, created_at, answered_at + FROM questions + WHERE id = $1 + `, id).Scan( + &question.ID, + &question.ConversationID, + &question.ProjectID, + &question.Type, + &question.Text, + pq.Array(&question.Choices), + &answer, + pq.Array(&question.AnswerChoices), + &metadataJSON, + &question.CreatedAt, + &answeredAt, + ) + if err == sql.ErrNoRows { + return nil, domain.ErrQuestionNotFound + } + if err != nil { + return nil, fmt.Errorf("get question: %w", err) + } + + if answer.Valid { + question.Answer = &answer.String + } + if answeredAt.Valid { + question.AnsweredAt = &answeredAt.Time + } + + if len(metadataJSON) > 0 { + if err := json.Unmarshal(metadataJSON, &question.Metadata); err != nil { + return nil, fmt.Errorf("unmarshal metadata: %w", err) + } + } + + return &question, nil +} + +// ListUnansweredQuestions returns all unanswered questions for a project. +func (r *QuestionRepository) ListUnansweredQuestions(ctx context.Context, projectID string) ([]*domain.Question, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, conversation_id, project_id, question_type, text, choices, answer, answer_choices, metadata, created_at, answered_at + FROM questions + WHERE project_id = $1 AND answered_at IS NULL + ORDER BY created_at DESC + `, projectID) + if err != nil { + return nil, fmt.Errorf("list unanswered questions: %w", err) + } + defer rows.Close() + + var questions []*domain.Question + for rows.Next() { + var question domain.Question + var answer sql.NullString + var answeredAt sql.NullTime + var metadataJSON []byte + + if err := rows.Scan( + &question.ID, + &question.ConversationID, + &question.ProjectID, + &question.Type, + &question.Text, + pq.Array(&question.Choices), + &answer, + pq.Array(&question.AnswerChoices), + &metadataJSON, + &question.CreatedAt, + &answeredAt, + ); err != nil { + return nil, fmt.Errorf("scan question: %w", err) + } + + if answer.Valid { + question.Answer = &answer.String + } + if answeredAt.Valid { + question.AnsweredAt = &answeredAt.Time + } + + if len(metadataJSON) > 0 { + if err := json.Unmarshal(metadataJSON, &question.Metadata); err != nil { + return nil, fmt.Errorf("unmarshal metadata: %w", err) + } + } + + questions = append(questions, &question) + } + + return questions, rows.Err() +} + +// ListQuestionsByConversation returns all questions for a conversation. +func (r *QuestionRepository) ListQuestionsByConversation(ctx context.Context, conversationID domain.ConversationID) ([]*domain.Question, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, conversation_id, project_id, question_type, text, choices, answer, answer_choices, metadata, created_at, answered_at + FROM questions + WHERE conversation_id = $1 + ORDER BY created_at DESC + `, conversationID) + if err != nil { + return nil, fmt.Errorf("list questions by conversation: %w", err) + } + defer rows.Close() + + var questions []*domain.Question + for rows.Next() { + var question domain.Question + var answer sql.NullString + var answeredAt sql.NullTime + var metadataJSON []byte + + if err := rows.Scan( + &question.ID, + &question.ConversationID, + &question.ProjectID, + &question.Type, + &question.Text, + pq.Array(&question.Choices), + &answer, + pq.Array(&question.AnswerChoices), + &metadataJSON, + &question.CreatedAt, + &answeredAt, + ); err != nil { + return nil, fmt.Errorf("scan question: %w", err) + } + + if answer.Valid { + question.Answer = &answer.String + } + if answeredAt.Valid { + question.AnsweredAt = &answeredAt.Time + } + + if len(metadataJSON) > 0 { + if err := json.Unmarshal(metadataJSON, &question.Metadata); err != nil { + return nil, fmt.Errorf("unmarshal metadata: %w", err) + } + } + + questions = append(questions, &question) + } + + return questions, rows.Err() +} + +// AnswerQuestion records an answer to a question. +func (r *QuestionRepository) AnswerQuestion(ctx context.Context, id domain.QuestionID, answer *string, answerChoices []string) error { + now := time.Now() + + result, err := r.db.ExecContext(ctx, ` + UPDATE questions + SET answer = $1, answer_choices = $2, answered_at = $3 + WHERE id = $4 + `, answer, pq.Array(answerChoices), now, id) + if err != nil { + return fmt.Errorf("answer question: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("rows affected: %w", err) + } + if rows == 0 { + return domain.ErrQuestionNotFound + } + + return nil +} + +// DeleteQuestion deletes a question. +func (r *QuestionRepository) DeleteQuestion(ctx context.Context, id domain.QuestionID) error { + result, err := r.db.ExecContext(ctx, ` + DELETE FROM questions WHERE id = $1 + `, id) + if err != nil { + return fmt.Errorf("delete question: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("rows affected: %w", err) + } + if rows == 0 { + return domain.ErrQuestionNotFound + } + + return nil +} diff --git a/internal/db/migrations/019_conversations.sql b/internal/db/migrations/019_conversations.sql new file mode 100644 index 0000000..0669d04 --- /dev/null +++ b/internal/db/migrations/019_conversations.sql @@ -0,0 +1,58 @@ +-- Conversations table for chat persistence +CREATE TABLE IF NOT EXISTS conversations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id TEXT NOT NULL, + title TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_message_at TIMESTAMPTZ +); + +-- Messages table for conversation history +CREATE TABLE IF NOT EXISTS messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + role VARCHAR(20) NOT NULL, -- 'user', 'assistant', 'system' + content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Indexes for efficient querying +CREATE INDEX IF NOT EXISTS idx_conversations_project + ON conversations(project_id, last_message_at DESC NULLS LAST); + +CREATE INDEX IF NOT EXISTS idx_messages_conversation + ON messages(conversation_id, created_at ASC); + +-- Update trigger for conversations.updated_at +CREATE OR REPLACE FUNCTION update_conversations_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER conversations_updated_at + BEFORE UPDATE ON conversations + FOR EACH ROW + EXECUTE FUNCTION update_conversations_updated_at(); + +-- Trigger to update last_message_at when a message is added +CREATE OR REPLACE FUNCTION update_conversation_last_message() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE conversations + SET last_message_at = NEW.created_at + WHERE id = NEW.conversation_id; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER message_updates_conversation + AFTER INSERT ON messages + FOR EACH ROW + EXECUTE FUNCTION update_conversation_last_message(); + +COMMENT ON TABLE conversations IS 'Chat conversations for project architect service'; +COMMENT ON TABLE messages IS 'Messages within conversations'; diff --git a/internal/db/migrations/020_blueprints.sql b/internal/db/migrations/020_blueprints.sql new file mode 100644 index 0000000..2ce1d5d --- /dev/null +++ b/internal/db/migrations/020_blueprints.sql @@ -0,0 +1,35 @@ +-- Blueprints table for structured project specifications +CREATE TABLE IF NOT EXISTS blueprints ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + spec JSONB NOT NULL DEFAULT '{}', -- Structured specification + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Index for project lookups +CREATE INDEX IF NOT EXISTS idx_blueprints_project + ON blueprints(project_id, created_at DESC); + +-- GIN index for efficient JSONB queries +CREATE INDEX IF NOT EXISTS idx_blueprints_spec_gin + ON blueprints USING GIN (spec); + +-- Update trigger for blueprints.updated_at +CREATE OR REPLACE FUNCTION update_blueprints_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER blueprints_updated_at + BEFORE UPDATE ON blueprints + FOR EACH ROW + EXECUTE FUNCTION update_blueprints_updated_at(); + +COMMENT ON TABLE blueprints IS 'Project blueprints with structured specifications'; +COMMENT ON COLUMN blueprints.spec IS 'JSONB specification with project requirements, architecture, components, etc.'; diff --git a/internal/db/migrations/021_questions.sql b/internal/db/migrations/021_questions.sql new file mode 100644 index 0000000..24477b9 --- /dev/null +++ b/internal/db/migrations/021_questions.sql @@ -0,0 +1,29 @@ +-- Questions table for structured architect questions +CREATE TABLE IF NOT EXISTS questions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE, + project_id TEXT NOT NULL, + question_type VARCHAR(20) NOT NULL, -- 'text', 'choice', 'multichoice', 'yesno' + text TEXT NOT NULL, + choices TEXT[], -- For choice/multichoice types + answer TEXT, -- User's text answer + answer_choices TEXT[], -- For multichoice answers + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + answered_at TIMESTAMPTZ +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_questions_conversation + ON questions(conversation_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_questions_project + ON questions(project_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_questions_unanswered + ON questions(project_id, answered_at) WHERE answered_at IS NULL; + +COMMENT ON TABLE questions IS 'Structured questions for architect service'; +COMMENT ON COLUMN questions.question_type IS 'Question type: text, choice, multichoice, yesno'; +COMMENT ON COLUMN questions.choices IS 'Available choices for choice/multichoice questions'; +COMMENT ON COLUMN questions.answer_choices IS 'Selected choices for multichoice answers'; diff --git a/internal/domain/blueprint.go b/internal/domain/blueprint.go new file mode 100644 index 0000000..21f61b9 --- /dev/null +++ b/internal/domain/blueprint.go @@ -0,0 +1,17 @@ +package domain + +import "time" + +// BlueprintID is a strongly-typed identifier for blueprints. +type BlueprintID string + +// Blueprint represents a structured project specification. +type Blueprint struct { + ID BlueprintID + ProjectID string + Name string + Description string + Spec map[string]any // JSONB - flexible structured data + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/internal/domain/conversation.go b/internal/domain/conversation.go new file mode 100644 index 0000000..a2c5c09 --- /dev/null +++ b/internal/domain/conversation.go @@ -0,0 +1,43 @@ +package domain + +import "time" + +// ConversationID is a strongly-typed identifier for conversations. +type ConversationID string + +// MessageID is a strongly-typed identifier for messages. +type MessageID string + +// MessageRole represents who sent the message. +type MessageRole string + +const ( + MessageRoleUser MessageRole = "user" + MessageRoleAssistant MessageRole = "assistant" + MessageRoleSystem MessageRole = "system" +) + +// Conversation represents a chat conversation for a project. +type Conversation struct { + ID ConversationID + ProjectID string + Title string + CreatedAt time.Time + UpdatedAt time.Time + LastMessage *time.Time +} + +// Message represents a single message in a conversation. +type Message struct { + ID MessageID + ConversationID ConversationID + Role MessageRole + Content string + CreatedAt time.Time +} + +// ConversationWithMessages combines a conversation with its messages. +type ConversationWithMessages struct { + Conversation + Messages []*Message +} diff --git a/internal/domain/errors.go b/internal/domain/errors.go index b2134f6..75e5267 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -78,6 +78,16 @@ var ( // Operation errors ErrOperationNotFound = errors.New("operation not found") + // Conversation errors + ErrConversationNotFound = errors.New("conversation not found") + ErrMessageNotFound = errors.New("message not found") + + // Blueprint errors + ErrBlueprintNotFound = errors.New("blueprint not found") + + // Question errors + ErrQuestionNotFound = errors.New("question not found") + // Infrastructure errors (should typically be wrapped) ErrDatabaseConnection = errors.New("database connection error") ErrKubernetesError = errors.New("kubernetes error") diff --git a/internal/domain/question.go b/internal/domain/question.go new file mode 100644 index 0000000..087d790 --- /dev/null +++ b/internal/domain/question.go @@ -0,0 +1,31 @@ +package domain + +import "time" + +// QuestionID is a strongly-typed identifier for questions. +type QuestionID string + +// QuestionType represents different types of questions. +type QuestionType string + +const ( + QuestionTypeText QuestionType = "text" + QuestionTypeChoice QuestionType = "choice" // Single choice + QuestionTypeMultiChoice QuestionType = "multichoice" // Multiple choices + QuestionTypeYesNo QuestionType = "yesno" +) + +// Question represents a structured question in the architect flow. +type Question struct { + ID QuestionID + ConversationID ConversationID + ProjectID string + Type QuestionType + Text string + Choices []string // For choice/multichoice types + Answer *string // User's text answer + AnswerChoices []string // For multichoice type + Metadata map[string]string // Additional context + CreatedAt time.Time + AnsweredAt *time.Time +} diff --git a/internal/handlers/architect.go b/internal/handlers/architect.go new file mode 100644 index 0000000..fcc2c44 --- /dev/null +++ b/internal/handlers/architect.go @@ -0,0 +1,148 @@ +package handlers + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/orchard9/rdev/internal/auth" + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/service" + "github.com/orchard9/rdev/internal/validate" + "github.com/orchard9/rdev/pkg/api" +) + +// ArchitectHandler handles architect orchestration endpoints. +type ArchitectHandler struct { + architectService *service.ArchitectService +} + +// NewArchitectHandler creates a new architect handler. +func NewArchitectHandler(architectService *service.ArchitectService) *ArchitectHandler { + return &ArchitectHandler{ + architectService: architectService, + } +} + +// Mount registers architect routes. +func (h *ArchitectHandler) Mount(r api.Router) { + r.Route("/projects/{id}/architect", func(r chi.Router) { + // All architect operations require execute scope + r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)). + Post("/start", h.StartConversation) + r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)). + Post("/continue/{conversationId}", h.ContinueConversation) + r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)). + Post("/generate-blueprint/{conversationId}", h.GenerateBlueprint) + }) +} + +// StartConversationRequest is the request body for POST /projects/{id}/architect/start. +type StartConversationRequest struct { + Prompt string `json:"prompt"` +} + +// StartConversation begins a new architectural conversation. +// POST /projects/{id}/architect/start +func (h *ArchitectHandler) StartConversation(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + + var req StartConversationRequest + if err := api.DecodeJSON(r, &req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + v := validate.New() + v.Required(req.Prompt, "prompt") + if err := v.Error(); err != nil { + api.WriteBadRequest(w, r, err.Error()) + return + } + + conv, err := h.architectService.StartConversation(r.Context(), projectID, req.Prompt) + if err != nil { + api.WriteInternalError(w, r, "failed to start conversation") + return + } + + api.WriteCreated(w, r, StartConversationResponse{ + ConversationWithMessagesDTO: *toConversationWithMessagesDTO(conv), + }) +} + +// ContinueConversationRequest is the request body for POST /projects/{id}/architect/continue/{conversationId}. +type ContinueConversationRequest struct { + Message string `json:"message"` +} + +// ContinueConversation continues an existing architectural conversation. +// POST /projects/{id}/architect/continue/{conversationId} +func (h *ArchitectHandler) ContinueConversation(w http.ResponseWriter, r *http.Request) { + conversationID := domain.ConversationID(chi.URLParam(r, "conversationId")) + + var req ContinueConversationRequest + if err := api.DecodeJSON(r, &req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + v := validate.New() + v.Required(req.Message, "message") + if err := v.Error(); err != nil { + api.WriteBadRequest(w, r, err.Error()) + return + } + + msg, err := h.architectService.ContinueConversation(r.Context(), conversationID, req.Message) + if err != nil { + if errors.Is(err, domain.ErrConversationNotFound) { + api.WriteNotFound(w, r, "conversation not found") + return + } + api.WriteInternalError(w, r, "failed to continue conversation") + return + } + + api.WriteCreated(w, r, ContinueConversationResponse{ + Message: toMessageDTO(msg), + }) +} + +// GenerateBlueprintRequest is the request body for POST /projects/{id}/architect/generate-blueprint/{conversationId}. +type GenerateBlueprintRequest struct { + Name string `json:"name"` +} + +// GenerateBlueprint generates a structured blueprint from a conversation. +// POST /projects/{id}/architect/generate-blueprint/{conversationId} +func (h *ArchitectHandler) GenerateBlueprint(w http.ResponseWriter, r *http.Request) { + conversationID := domain.ConversationID(chi.URLParam(r, "conversationId")) + + var req GenerateBlueprintRequest + if err := api.DecodeJSON(r, &req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + v := validate.New() + v.Required(req.Name, "name") + if err := v.Error(); err != nil { + api.WriteBadRequest(w, r, err.Error()) + return + } + + blueprint, err := h.architectService.GenerateBlueprint(r.Context(), conversationID, req.Name) + if err != nil { + if errors.Is(err, domain.ErrConversationNotFound) { + api.WriteNotFound(w, r, "conversation not found") + return + } + api.WriteInternalError(w, r, "failed to generate blueprint") + return + } + + api.WriteCreated(w, r, GenerateBlueprintResponse{ + Blueprint: toBlueprintDTO(blueprint), + }) +} diff --git a/internal/handlers/architect_dto.go b/internal/handlers/architect_dto.go new file mode 100644 index 0000000..6f0e768 --- /dev/null +++ b/internal/handlers/architect_dto.go @@ -0,0 +1,16 @@ +package handlers + +// StartConversationResponse is the response for starting an architectural conversation. +type StartConversationResponse struct { + ConversationWithMessagesDTO +} + +// ContinueConversationResponse is the response for continuing a conversation. +type ContinueConversationResponse struct { + Message *MessageDTO `json:"message"` +} + +// GenerateBlueprintResponse is the response for generating a blueprint from a conversation. +type GenerateBlueprintResponse struct { + Blueprint *BlueprintDTO `json:"blueprint"` +} diff --git a/internal/handlers/blueprint_dto.go b/internal/handlers/blueprint_dto.go new file mode 100644 index 0000000..82fb671 --- /dev/null +++ b/internal/handlers/blueprint_dto.go @@ -0,0 +1,29 @@ +package handlers + +import "github.com/orchard9/rdev/internal/domain" + +// BlueprintDTO is the data transfer object for blueprints. +type BlueprintDTO struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Spec map[string]any `json:"spec"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func toBlueprintDTO(b *domain.Blueprint) *BlueprintDTO { + if b == nil { + return nil + } + return &BlueprintDTO{ + ID: string(b.ID), + ProjectID: b.ProjectID, + Name: b.Name, + Description: b.Description, + Spec: b.Spec, + CreatedAt: b.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: b.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + } +} diff --git a/internal/handlers/blueprints.go b/internal/handlers/blueprints.go new file mode 100644 index 0000000..5694c67 --- /dev/null +++ b/internal/handlers/blueprints.go @@ -0,0 +1,169 @@ +package handlers + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/orchard9/rdev/internal/auth" + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/service" + "github.com/orchard9/rdev/internal/validate" + "github.com/orchard9/rdev/pkg/api" +) + +// BlueprintsHandler handles blueprint endpoints. +type BlueprintsHandler struct { + blueprintService *service.BlueprintService +} + +// NewBlueprintsHandler creates a new blueprints handler. +func NewBlueprintsHandler(blueprintService *service.BlueprintService) *BlueprintsHandler { + return &BlueprintsHandler{ + blueprintService: blueprintService, + } +} + +// Mount registers blueprint routes. +func (h *BlueprintsHandler) Mount(r api.Router) { + r.Route("/projects/{id}/blueprints", func(r chi.Router) { + // Read operations + r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)). + Get("/", h.ListBlueprints) + r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)). + Get("/{blueprintId}", h.GetBlueprint) + + // Write operations + r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)). + Post("/", h.CreateBlueprint) + r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)). + Put("/{blueprintId}", h.UpdateBlueprint) + r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)). + Delete("/{blueprintId}", h.DeleteBlueprint) + }) +} + +// CreateBlueprintRequest is the request body for POST /projects/{id}/blueprints. +type CreateBlueprintRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Spec map[string]any `json:"spec"` +} + +// CreateBlueprint creates a new blueprint. +// POST /projects/{id}/blueprints +func (h *BlueprintsHandler) CreateBlueprint(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + + var req CreateBlueprintRequest + if err := api.DecodeJSON(r, &req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + v := validate.New() + v.Required(req.Name, "name") + if err := v.Error(); err != nil { + api.WriteBadRequest(w, r, err.Error()) + return + } + + blueprint, err := h.blueprintService.CreateBlueprint(r.Context(), projectID, req.Name, req.Description, req.Spec) + if err != nil { + api.WriteInternalError(w, r, "failed to create blueprint") + return + } + + api.WriteCreated(w, r, toBlueprintDTO(blueprint)) +} + +// ListBlueprints returns all blueprints for a project. +// GET /projects/{id}/blueprints +func (h *BlueprintsHandler) ListBlueprints(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + + blueprints, err := h.blueprintService.ListBlueprints(r.Context(), projectID) + if err != nil { + api.WriteInternalError(w, r, "failed to list blueprints") + return + } + + dtos := make([]*BlueprintDTO, len(blueprints)) + for i, b := range blueprints { + dtos[i] = toBlueprintDTO(b) + } + + api.WriteSuccess(w, r, map[string]any{ + "blueprints": dtos, + "total": len(dtos), + }) +} + +// GetBlueprint retrieves a blueprint by ID. +// GET /projects/{id}/blueprints/{blueprintId} +func (h *BlueprintsHandler) GetBlueprint(w http.ResponseWriter, r *http.Request) { + blueprintID := domain.BlueprintID(chi.URLParam(r, "blueprintId")) + + blueprint, err := h.blueprintService.GetBlueprint(r.Context(), blueprintID) + if err != nil { + if errors.Is(err, domain.ErrBlueprintNotFound) { + api.WriteNotFound(w, r, "blueprint not found") + return + } + api.WriteInternalError(w, r, "failed to get blueprint") + return + } + + api.WriteSuccess(w, r, toBlueprintDTO(blueprint)) +} + +// UpdateBlueprintRequest is the request body for PUT /projects/{id}/blueprints/{blueprintId}. +type UpdateBlueprintRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Spec map[string]any `json:"spec"` +} + +// UpdateBlueprint updates a blueprint. +// PUT /projects/{id}/blueprints/{blueprintId} +func (h *BlueprintsHandler) UpdateBlueprint(w http.ResponseWriter, r *http.Request) { + blueprintID := domain.BlueprintID(chi.URLParam(r, "blueprintId")) + + var req UpdateBlueprintRequest + if err := api.DecodeJSON(r, &req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + if err := h.blueprintService.UpdateBlueprint(r.Context(), blueprintID, req.Name, req.Description, req.Spec); err != nil { + if errors.Is(err, domain.ErrBlueprintNotFound) { + api.WriteNotFound(w, r, "blueprint not found") + return + } + api.WriteInternalError(w, r, "failed to update blueprint") + return + } + + api.WriteSuccess(w, r, map[string]any{ + "message": "blueprint updated", + }) +} + +// DeleteBlueprint deletes a blueprint. +// DELETE /projects/{id}/blueprints/{blueprintId} +func (h *BlueprintsHandler) DeleteBlueprint(w http.ResponseWriter, r *http.Request) { + blueprintID := domain.BlueprintID(chi.URLParam(r, "blueprintId")) + + if err := h.blueprintService.DeleteBlueprint(r.Context(), blueprintID); err != nil { + if errors.Is(err, domain.ErrBlueprintNotFound) { + api.WriteNotFound(w, r, "blueprint not found") + return + } + api.WriteInternalError(w, r, "failed to delete blueprint") + return + } + + api.WriteSuccess(w, r, map[string]any{ + "message": "blueprint deleted", + }) +} diff --git a/internal/handlers/conversation_dto.go b/internal/handlers/conversation_dto.go new file mode 100644 index 0000000..8b64a15 --- /dev/null +++ b/internal/handlers/conversation_dto.go @@ -0,0 +1,76 @@ +package handlers + +import "github.com/orchard9/rdev/internal/domain" + +// ConversationDTO is the data transfer object for conversations. +type ConversationDTO struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Title string `json:"title"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + LastMessageAt *string `json:"last_message_at,omitempty"` +} + +// MessageDTO is the data transfer object for messages. +type MessageDTO struct { + ID string `json:"id"` + ConversationID string `json:"conversation_id"` + Role string `json:"role"` + Content string `json:"content"` + CreatedAt string `json:"created_at"` +} + +// ConversationWithMessagesDTO combines a conversation with its messages. +type ConversationWithMessagesDTO struct { + ConversationDTO + Messages []*MessageDTO `json:"messages"` +} + +func toConversationDTO(c *domain.Conversation) *ConversationDTO { + if c == nil { + return nil + } + dto := &ConversationDTO{ + ID: string(c.ID), + ProjectID: c.ProjectID, + Title: c.Title, + CreatedAt: c.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: c.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + if c.LastMessage != nil { + ts := c.LastMessage.Format("2006-01-02T15:04:05Z07:00") + dto.LastMessageAt = &ts + } + return dto +} + +func toMessageDTO(m *domain.Message) *MessageDTO { + if m == nil { + return nil + } + return &MessageDTO{ + ID: string(m.ID), + ConversationID: string(m.ConversationID), + Role: string(m.Role), + Content: m.Content, + CreatedAt: m.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + } +} + +func toConversationWithMessagesDTO(c *domain.ConversationWithMessages) *ConversationWithMessagesDTO { + if c == nil { + return nil + } + + messages := make([]*MessageDTO, len(c.Messages)) + for i, m := range c.Messages { + messages[i] = toMessageDTO(m) + } + + dto := toConversationDTO(&c.Conversation) + return &ConversationWithMessagesDTO{ + ConversationDTO: *dto, + Messages: messages, + } +} diff --git a/internal/handlers/conversations.go b/internal/handlers/conversations.go new file mode 100644 index 0000000..5a9a842 --- /dev/null +++ b/internal/handlers/conversations.go @@ -0,0 +1,235 @@ +package handlers + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/orchard9/rdev/internal/auth" + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/service" + "github.com/orchard9/rdev/internal/validate" + "github.com/orchard9/rdev/pkg/api" +) + +// ConversationsHandler handles conversation endpoints. +type ConversationsHandler struct { + conversationService *service.ConversationService +} + +// NewConversationsHandler creates a new conversations handler. +func NewConversationsHandler(conversationService *service.ConversationService) *ConversationsHandler { + return &ConversationsHandler{ + conversationService: conversationService, + } +} + +// Mount registers conversation routes. +func (h *ConversationsHandler) Mount(r api.Router) { + r.Route("/projects/{id}/conversations", func(r chi.Router) { + // Read operations + r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)). + Get("/", h.ListConversations) + r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)). + Get("/{conversationId}", h.GetConversation) + r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)). + Get("/{conversationId}/messages", h.GetMessages) + + // Write operations + r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)). + Post("/", h.CreateConversation) + r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)). + Patch("/{conversationId}", h.UpdateConversation) + r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)). + Delete("/{conversationId}", h.DeleteConversation) + r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)). + Post("/{conversationId}/messages", h.AddMessage) + }) +} + +// CreateConversationRequest is the request body for POST /projects/{id}/conversations. +type CreateConversationRequest struct { + Title string `json:"title"` +} + +// CreateConversation creates a new conversation. +// POST /projects/{id}/conversations +func (h *ConversationsHandler) CreateConversation(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + + var req CreateConversationRequest + if err := api.DecodeJSON(r, &req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + conv, err := h.conversationService.CreateConversation(r.Context(), projectID, req.Title) + if err != nil { + api.WriteInternalError(w, r, "failed to create conversation") + return + } + + api.WriteCreated(w, r, toConversationDTO(conv)) +} + +// ListConversations returns all conversations for a project. +// GET /projects/{id}/conversations +func (h *ConversationsHandler) ListConversations(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + + convs, err := h.conversationService.ListConversations(r.Context(), projectID) + if err != nil { + api.WriteInternalError(w, r, "failed to list conversations") + return + } + + dtos := make([]*ConversationDTO, len(convs)) + for i, c := range convs { + dtos[i] = toConversationDTO(c) + } + + api.WriteSuccess(w, r, map[string]any{ + "conversations": dtos, + "total": len(dtos), + }) +} + +// GetConversation retrieves a conversation with all messages. +// GET /projects/{id}/conversations/{conversationId} +func (h *ConversationsHandler) GetConversation(w http.ResponseWriter, r *http.Request) { + conversationID := domain.ConversationID(chi.URLParam(r, "conversationId")) + + conv, err := h.conversationService.GetConversationWithMessages(r.Context(), conversationID) + if err != nil { + if errors.Is(err, domain.ErrConversationNotFound) { + api.WriteNotFound(w, r, "conversation not found") + return + } + api.WriteInternalError(w, r, "failed to get conversation") + return + } + + api.WriteSuccess(w, r, toConversationWithMessagesDTO(conv)) +} + +// UpdateConversationRequest is the request body for PATCH /projects/{id}/conversations/{conversationId}. +type UpdateConversationRequest struct { + Title string `json:"title"` +} + +// UpdateConversation updates conversation metadata. +// PATCH /projects/{id}/conversations/{conversationId} +func (h *ConversationsHandler) UpdateConversation(w http.ResponseWriter, r *http.Request) { + conversationID := domain.ConversationID(chi.URLParam(r, "conversationId")) + + var req UpdateConversationRequest + if err := api.DecodeJSON(r, &req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + v := validate.New() + v.Required(req.Title, "title") + if err := v.Error(); err != nil { + api.WriteBadRequest(w, r, err.Error()) + return + } + + if err := h.conversationService.UpdateTitle(r.Context(), conversationID, req.Title); err != nil { + if errors.Is(err, domain.ErrConversationNotFound) { + api.WriteNotFound(w, r, "conversation not found") + return + } + api.WriteInternalError(w, r, "failed to update conversation") + return + } + + api.WriteSuccess(w, r, map[string]any{ + "message": "conversation updated", + }) +} + +// DeleteConversation deletes a conversation. +// DELETE /projects/{id}/conversations/{conversationId} +func (h *ConversationsHandler) DeleteConversation(w http.ResponseWriter, r *http.Request) { + conversationID := domain.ConversationID(chi.URLParam(r, "conversationId")) + + if err := h.conversationService.DeleteConversation(r.Context(), conversationID); err != nil { + if errors.Is(err, domain.ErrConversationNotFound) { + api.WriteNotFound(w, r, "conversation not found") + return + } + api.WriteInternalError(w, r, "failed to delete conversation") + return + } + + api.WriteSuccess(w, r, map[string]any{ + "message": "conversation deleted", + }) +} + +// AddMessageRequest is the request body for POST /projects/{id}/conversations/{conversationId}/messages. +type AddMessageRequest struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// AddMessage adds a message to a conversation. +// POST /projects/{id}/conversations/{conversationId}/messages +func (h *ConversationsHandler) AddMessage(w http.ResponseWriter, r *http.Request) { + conversationID := domain.ConversationID(chi.URLParam(r, "conversationId")) + + var req AddMessageRequest + if err := api.DecodeJSON(r, &req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + v := validate.New() + v.Required(req.Role, "role") + v.Required(req.Content, "content") + if err := v.Error(); err != nil { + api.WriteBadRequest(w, r, err.Error()) + return + } + + role := domain.MessageRole(req.Role) + if role != domain.MessageRoleUser && role != domain.MessageRoleAssistant && role != domain.MessageRoleSystem { + api.WriteBadRequest(w, r, "role must be 'user', 'assistant', or 'system'") + return + } + + msg, err := h.conversationService.AddMessage(r.Context(), conversationID, role, req.Content) + if err != nil { + if errors.Is(err, domain.ErrConversationNotFound) { + api.WriteNotFound(w, r, "conversation not found") + return + } + api.WriteInternalError(w, r, "failed to add message") + return + } + + api.WriteCreated(w, r, toMessageDTO(msg)) +} + +// GetMessages retrieves all messages for a conversation. +// GET /projects/{id}/conversations/{conversationId}/messages +func (h *ConversationsHandler) GetMessages(w http.ResponseWriter, r *http.Request) { + conversationID := domain.ConversationID(chi.URLParam(r, "conversationId")) + + messages, err := h.conversationService.GetMessages(r.Context(), conversationID) + if err != nil { + api.WriteInternalError(w, r, "failed to get messages") + return + } + + dtos := make([]*MessageDTO, len(messages)) + for i, m := range messages { + dtos[i] = toMessageDTO(m) + } + + api.WriteSuccess(w, r, map[string]any{ + "messages": dtos, + "total": len(dtos), + }) +} diff --git a/internal/handlers/question_dto.go b/internal/handlers/question_dto.go new file mode 100644 index 0000000..ebec1ea --- /dev/null +++ b/internal/handlers/question_dto.go @@ -0,0 +1,41 @@ +package handlers + +import "github.com/orchard9/rdev/internal/domain" + +// QuestionDTO is the data transfer object for questions. +type QuestionDTO struct { + ID string `json:"id"` + ConversationID string `json:"conversation_id"` + ProjectID string `json:"project_id"` + Type string `json:"type"` + Text string `json:"text"` + Choices []string `json:"choices,omitempty"` + Answer *string `json:"answer,omitempty"` + AnswerChoices []string `json:"answer_choices,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + CreatedAt string `json:"created_at"` + AnsweredAt *string `json:"answered_at,omitempty"` +} + +func toQuestionDTO(q *domain.Question) *QuestionDTO { + if q == nil { + return nil + } + dto := &QuestionDTO{ + ID: string(q.ID), + ConversationID: string(q.ConversationID), + ProjectID: q.ProjectID, + Type: string(q.Type), + Text: q.Text, + Choices: q.Choices, + Answer: q.Answer, + AnswerChoices: q.AnswerChoices, + Metadata: q.Metadata, + CreatedAt: q.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + if q.AnsweredAt != nil { + ts := q.AnsweredAt.Format("2006-01-02T15:04:05Z07:00") + dto.AnsweredAt = &ts + } + return dto +} diff --git a/internal/handlers/questions.go b/internal/handlers/questions.go new file mode 100644 index 0000000..cf54441 --- /dev/null +++ b/internal/handlers/questions.go @@ -0,0 +1,211 @@ +package handlers + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/orchard9/rdev/internal/auth" + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/service" + "github.com/orchard9/rdev/internal/validate" + "github.com/orchard9/rdev/pkg/api" +) + +// QuestionsHandler handles question endpoints. +type QuestionsHandler struct { + questionService *service.QuestionService +} + +// NewQuestionsHandler creates a new questions handler. +func NewQuestionsHandler(questionService *service.QuestionService) *QuestionsHandler { + return &QuestionsHandler{ + questionService: questionService, + } +} + +// Mount registers question routes. +func (h *QuestionsHandler) Mount(r api.Router) { + r.Route("/projects/{id}/questions", func(r chi.Router) { + // Read operations + r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)). + Get("/", h.ListUnansweredQuestions) + r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)). + Get("/{questionId}", h.GetQuestion) + r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)). + Get("/conversation/{conversationId}", h.ListQuestionsByConversation) + + // Write operations + r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)). + Post("/", h.CreateQuestion) + r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)). + Post("/{questionId}/answer", h.AnswerQuestion) + r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)). + Delete("/{questionId}", h.DeleteQuestion) + }) +} + +// CreateQuestionRequest is the request body for POST /projects/{id}/questions. +type CreateQuestionRequest struct { + ConversationID string `json:"conversation_id"` + Type string `json:"type"` + Text string `json:"text"` + Choices []string `json:"choices,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// CreateQuestion creates a new question. +// POST /projects/{id}/questions +func (h *QuestionsHandler) CreateQuestion(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + + var req CreateQuestionRequest + if err := api.DecodeJSON(r, &req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + v := validate.New() + v.Required(req.ConversationID, "conversation_id") + v.Required(req.Type, "type") + v.Required(req.Text, "text") + if err := v.Error(); err != nil { + api.WriteBadRequest(w, r, err.Error()) + return + } + + questionType := domain.QuestionType(req.Type) + if questionType != domain.QuestionTypeText && questionType != domain.QuestionTypeChoice && + questionType != domain.QuestionTypeMultiChoice && questionType != domain.QuestionTypeYesNo { + api.WriteBadRequest(w, r, "type must be 'text', 'choice', 'multichoice', or 'yesno'") + return + } + + question, err := h.questionService.CreateQuestion( + r.Context(), + domain.ConversationID(req.ConversationID), + projectID, + questionType, + req.Text, + req.Choices, + req.Metadata, + ) + if err != nil { + api.WriteInternalError(w, r, "failed to create question") + return + } + + api.WriteCreated(w, r, toQuestionDTO(question)) +} + +// ListUnansweredQuestions returns all unanswered questions for a project. +// GET /projects/{id}/questions +func (h *QuestionsHandler) ListUnansweredQuestions(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + + questions, err := h.questionService.ListUnansweredQuestions(r.Context(), projectID) + if err != nil { + api.WriteInternalError(w, r, "failed to list questions") + return + } + + dtos := make([]*QuestionDTO, len(questions)) + for i, q := range questions { + dtos[i] = toQuestionDTO(q) + } + + api.WriteSuccess(w, r, map[string]any{ + "questions": dtos, + "total": len(dtos), + }) +} + +// GetQuestion retrieves a question by ID. +// GET /projects/{id}/questions/{questionId} +func (h *QuestionsHandler) GetQuestion(w http.ResponseWriter, r *http.Request) { + questionID := domain.QuestionID(chi.URLParam(r, "questionId")) + + question, err := h.questionService.GetQuestion(r.Context(), questionID) + if err != nil { + if errors.Is(err, domain.ErrQuestionNotFound) { + api.WriteNotFound(w, r, "question not found") + return + } + api.WriteInternalError(w, r, "failed to get question") + return + } + + api.WriteSuccess(w, r, toQuestionDTO(question)) +} + +// ListQuestionsByConversation returns all questions for a conversation. +// GET /projects/{id}/questions/conversation/{conversationId} +func (h *QuestionsHandler) ListQuestionsByConversation(w http.ResponseWriter, r *http.Request) { + conversationID := domain.ConversationID(chi.URLParam(r, "conversationId")) + + questions, err := h.questionService.ListQuestionsByConversation(r.Context(), conversationID) + if err != nil { + api.WriteInternalError(w, r, "failed to list questions") + return + } + + dtos := make([]*QuestionDTO, len(questions)) + for i, q := range questions { + dtos[i] = toQuestionDTO(q) + } + + api.WriteSuccess(w, r, map[string]any{ + "questions": dtos, + "total": len(dtos), + }) +} + +// AnswerQuestionRequest is the request body for POST /projects/{id}/questions/{questionId}/answer. +type AnswerQuestionRequest struct { + Answer *string `json:"answer,omitempty"` + AnswerChoices []string `json:"answer_choices,omitempty"` +} + +// AnswerQuestion records an answer to a question. +// POST /projects/{id}/questions/{questionId}/answer +func (h *QuestionsHandler) AnswerQuestion(w http.ResponseWriter, r *http.Request) { + questionID := domain.QuestionID(chi.URLParam(r, "questionId")) + + var req AnswerQuestionRequest + if err := api.DecodeJSON(r, &req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + if err := h.questionService.AnswerQuestion(r.Context(), questionID, req.Answer, req.AnswerChoices); err != nil { + if errors.Is(err, domain.ErrQuestionNotFound) { + api.WriteNotFound(w, r, "question not found") + return + } + api.WriteBadRequest(w, r, err.Error()) + return + } + + api.WriteSuccess(w, r, map[string]any{ + "message": "question answered", + }) +} + +// DeleteQuestion deletes a question. +// DELETE /projects/{id}/questions/{questionId} +func (h *QuestionsHandler) DeleteQuestion(w http.ResponseWriter, r *http.Request) { + questionID := domain.QuestionID(chi.URLParam(r, "questionId")) + + if err := h.questionService.DeleteQuestion(r.Context(), questionID); err != nil { + if errors.Is(err, domain.ErrQuestionNotFound) { + api.WriteNotFound(w, r, "question not found") + return + } + api.WriteInternalError(w, r, "failed to delete question") + return + } + + api.WriteSuccess(w, r, map[string]any{ + "message": "question deleted", + }) +} diff --git a/internal/port/blueprint_repository.go b/internal/port/blueprint_repository.go new file mode 100644 index 0000000..9159512 --- /dev/null +++ b/internal/port/blueprint_repository.go @@ -0,0 +1,25 @@ +package port + +import ( + "context" + + "github.com/orchard9/rdev/internal/domain" +) + +// BlueprintRepository defines operations for blueprint persistence. +type BlueprintRepository interface { + // CreateBlueprint creates a new blueprint. + CreateBlueprint(ctx context.Context, blueprint *domain.Blueprint) error + + // GetBlueprint retrieves a blueprint by ID. + GetBlueprint(ctx context.Context, id domain.BlueprintID) (*domain.Blueprint, error) + + // ListBlueprints returns all blueprints for a project. + ListBlueprints(ctx context.Context, projectID string) ([]*domain.Blueprint, error) + + // UpdateBlueprint updates a blueprint's metadata and spec. + UpdateBlueprint(ctx context.Context, blueprint *domain.Blueprint) error + + // DeleteBlueprint deletes a blueprint. + DeleteBlueprint(ctx context.Context, id domain.BlueprintID) error +} diff --git a/internal/port/conversation_repository.go b/internal/port/conversation_repository.go new file mode 100644 index 0000000..b2cb78d --- /dev/null +++ b/internal/port/conversation_repository.go @@ -0,0 +1,34 @@ +package port + +import ( + "context" + + "github.com/orchard9/rdev/internal/domain" +) + +// ConversationRepository defines operations for conversation persistence. +type ConversationRepository interface { + // CreateConversation creates a new conversation. + CreateConversation(ctx context.Context, projectID, title string) (*domain.Conversation, error) + + // GetConversation retrieves a conversation by ID. + GetConversation(ctx context.Context, id domain.ConversationID) (*domain.Conversation, error) + + // ListConversations returns all conversations for a project. + ListConversations(ctx context.Context, projectID string) ([]*domain.Conversation, error) + + // UpdateConversationTitle updates the conversation title. + UpdateConversationTitle(ctx context.Context, id domain.ConversationID, title string) error + + // DeleteConversation deletes a conversation and all its messages. + DeleteConversation(ctx context.Context, id domain.ConversationID) error + + // AddMessage adds a message to a conversation. + AddMessage(ctx context.Context, conversationID domain.ConversationID, role domain.MessageRole, content string) (*domain.Message, error) + + // GetMessages retrieves all messages for a conversation. + GetMessages(ctx context.Context, conversationID domain.ConversationID) ([]*domain.Message, error) + + // GetConversationWithMessages retrieves a conversation with all messages. + GetConversationWithMessages(ctx context.Context, id domain.ConversationID) (*domain.ConversationWithMessages, error) +} diff --git a/internal/port/question_repository.go b/internal/port/question_repository.go new file mode 100644 index 0000000..c7d229a --- /dev/null +++ b/internal/port/question_repository.go @@ -0,0 +1,28 @@ +package port + +import ( + "context" + + "github.com/orchard9/rdev/internal/domain" +) + +// QuestionRepository defines operations for question persistence. +type QuestionRepository interface { + // CreateQuestion creates a new question. + CreateQuestion(ctx context.Context, question *domain.Question) error + + // GetQuestion retrieves a question by ID. + GetQuestion(ctx context.Context, id domain.QuestionID) (*domain.Question, error) + + // ListUnansweredQuestions returns all unanswered questions for a project. + ListUnansweredQuestions(ctx context.Context, projectID string) ([]*domain.Question, error) + + // ListQuestionsByConversation returns all questions for a conversation. + ListQuestionsByConversation(ctx context.Context, conversationID domain.ConversationID) ([]*domain.Question, error) + + // AnswerQuestion records an answer to a question. + AnswerQuestion(ctx context.Context, id domain.QuestionID, answer *string, answerChoices []string) error + + // DeleteQuestion deletes a question. + DeleteQuestion(ctx context.Context, id domain.QuestionID) error +} diff --git a/internal/service/architect_service.go b/internal/service/architect_service.go new file mode 100644 index 0000000..6c88730 --- /dev/null +++ b/internal/service/architect_service.go @@ -0,0 +1,303 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/logging" + "github.com/orchard9/rdev/internal/port" +) + +// ArchitectService orchestrates conversational project design with Claude. +type ArchitectService struct { + conversationService *ConversationService + blueprintService *BlueprintService + agentRegistry port.CodeAgentRegistry + projectRepo port.ProjectRepository +} + +// NewArchitectService creates a new architect service. +func NewArchitectService( + conversationService *ConversationService, + blueprintService *BlueprintService, + agentRegistry port.CodeAgentRegistry, + projectRepo port.ProjectRepository, +) *ArchitectService { + return &ArchitectService{ + conversationService: conversationService, + blueprintService: blueprintService, + agentRegistry: agentRegistry, + projectRepo: projectRepo, + } +} + +// StartConversation begins a new architectural conversation. +func (s *ArchitectService) StartConversation(ctx context.Context, projectID, initialPrompt string) (*domain.ConversationWithMessages, error) { + // Create conversation + conv, err := s.conversationService.CreateConversation(ctx, projectID, "Architectural Design") + if err != nil { + return nil, fmt.Errorf("create conversation: %w", err) + } + + // Add user's initial message + _, err = s.conversationService.AddMessage(ctx, conv.ID, domain.MessageRoleUser, initialPrompt) + if err != nil { + return nil, fmt.Errorf("add user message: %w", err) + } + + // Get agent response + response, err := s.askArchitect(ctx, projectID, conv.ID, initialPrompt) + if err != nil { + return nil, fmt.Errorf("ask architect: %w", err) + } + + // Add assistant's response + _, err = s.conversationService.AddMessage(ctx, conv.ID, domain.MessageRoleAssistant, response) + if err != nil { + return nil, fmt.Errorf("add assistant message: %w", err) + } + + log := logging.FromContext(ctx) + log.Info("architectural conversation started", + "conversation_id", conv.ID, + logging.FieldProjectID, projectID, + logging.FieldOperation, "start_architect_conversation", + ) + + return s.conversationService.GetConversationWithMessages(ctx, conv.ID) +} + +// ContinueConversation adds a message and gets agent response. +func (s *ArchitectService) ContinueConversation(ctx context.Context, conversationID domain.ConversationID, userMessage string) (*domain.Message, error) { + // Get conversation to find project + conv, err := s.conversationService.GetConversation(ctx, conversationID) + if err != nil { + return nil, err + } + + // Add user message + _, err = s.conversationService.AddMessage(ctx, conversationID, domain.MessageRoleUser, userMessage) + if err != nil { + return nil, fmt.Errorf("add user message: %w", err) + } + + // Get conversation history for context + messages, err := s.conversationService.GetMessages(ctx, conversationID) + if err != nil { + return nil, err + } + + // Build context from history + context := s.buildConversationContext(messages) + + // Get agent response + response, err := s.askArchitect(ctx, conv.ProjectID, conversationID, context) + if err != nil { + return nil, fmt.Errorf("ask architect: %w", err) + } + + // Add assistant's response + return s.conversationService.AddMessage(ctx, conversationID, domain.MessageRoleAssistant, response) +} + +// GenerateBlueprint creates a blueprint from a conversation. +func (s *ArchitectService) GenerateBlueprint(ctx context.Context, conversationID domain.ConversationID, blueprintName string) (*domain.Blueprint, error) { + conv, err := s.conversationService.GetConversation(ctx, conversationID) + if err != nil { + return nil, err + } + + messages, err := s.conversationService.GetMessages(ctx, conversationID) + if err != nil { + return nil, err + } + + // Extract structured spec from conversation + spec, err := s.extractSpecFromMessages(ctx, conv.ProjectID, messages) + if err != nil { + return nil, fmt.Errorf("extract spec: %w", err) + } + + // Create blueprint + return s.blueprintService.CreateBlueprint(ctx, conv.ProjectID, blueprintName, "Generated from architectural conversation", spec) +} + +// askArchitect sends a prompt to Claude and gets the response. +func (s *ArchitectService) askArchitect(ctx context.Context, projectID string, conversationID domain.ConversationID, prompt string) (string, error) { + agent := s.agentRegistry.Default() + if agent == nil { + return "", fmt.Errorf("no agent available") + } + + project, err := s.projectRepo.Get(ctx, domain.ProjectID(projectID)) + if err != nil { + return "", fmt.Errorf("resolve project: %w", err) + } + + // Prepare architect-specific system prompt + systemPrompt := `You are an expert software architect helping design a new project. + +Your role is to: +1. Ask clarifying questions to understand requirements deeply +2. Identify technical architecture and stack choices +3. Recommend component structure (monorepo, microservices, etc.) +4. Define infrastructure needs (database, cache, storage, messaging) +5. Consider scalability, maintainability, and team capabilities + +Guidelines: +- Ask one focused question at a time +- Provide specific recommendations with trade-offs +- Think about the full system: data models, APIs, UI components, infrastructure +- When you have enough information, summarize the architecture clearly + +Current conversation context:` + + fullPrompt := systemPrompt + "\n\n" + prompt + + agentReq := &domain.AgentRequest{ + Prompt: fullPrompt, + ProjectID: project.ID, + Timeout: 2 * time.Minute, + Metadata: map[string]string{ + "conversation_id": string(conversationID), + "purpose": "architect", + }, + } + + var output strings.Builder + _, err = agent.Execute(ctx, agentReq, func(event domain.AgentEvent) { + if event.Type == domain.AgentEventOutput { + output.WriteString(event.Content) + } + }) + if err != nil { + return "", fmt.Errorf("agent execution: %w", err) + } + + return output.String(), nil +} + +// buildConversationContext combines message history into a single context string. +func (s *ArchitectService) buildConversationContext(messages []*domain.Message) string { + var context strings.Builder + for _, msg := range messages { + role := string(msg.Role) + context.WriteString(fmt.Sprintf("%s: %s\n\n", role, msg.Content)) + } + return context.String() +} + +// extractSpecFromMessages parses conversation to extract structured blueprint spec. +func (s *ArchitectService) extractSpecFromMessages(ctx context.Context, projectID string, messages []*domain.Message) (map[string]any, error) { + // Use Claude to analyze conversation and extract structured data + agent := s.agentRegistry.Default() + if agent == nil { + return nil, fmt.Errorf("no agent available") + } + + project, err := s.projectRepo.Get(ctx, domain.ProjectID(projectID)) + if err != nil { + return nil, fmt.Errorf("resolve project: %w", err) + } + + // Build conversation transcript + transcript := s.buildConversationContext(messages) + + // Prompt to extract structured spec + extractionPrompt := fmt.Sprintf(`Analyze this architectural conversation and extract a structured JSON specification. + +Conversation transcript: +%s + +Extract and return ONLY a valid JSON object with this structure: +{ + "version": "1.0", + "architecture": { + "type": "monorepo|microservices|monolith", + "description": "...", + "components": [ + {"name": "...", "type": "service|app|worker|cli", "description": "..."} + ] + }, + "data_models": [ + {"name": "...", "description": "...", "fields": [...]} + ], + "api_endpoints": [ + {"path": "...", "method": "GET|POST|PUT|DELETE", "description": "..."} + ], + "infrastructure": { + "database": {"type": "postgres|mysql|mongo", "required": true|false}, + "cache": {"type": "redis|memcached", "required": true|false}, + "storage": {"type": "s3|gcs", "required": true|false}, + "messaging": {"type": "rabbitmq|kafka", "required": true|false} + }, + "features": [ + {"name": "...", "description": "...", "priority": "high|medium|low"} + ] +} + +Return ONLY the JSON, no other text.`, transcript) + + agentReq := &domain.AgentRequest{ + Prompt: extractionPrompt, + ProjectID: project.ID, + Timeout: 2 * time.Minute, + Metadata: map[string]string{ + "purpose": "spec-extraction", + }, + } + + var output strings.Builder + _, err = agent.Execute(ctx, agentReq, func(event domain.AgentEvent) { + if event.Type == domain.AgentEventOutput { + output.WriteString(event.Content) + } + }) + if err != nil { + return nil, fmt.Errorf("agent execution: %w", err) + } + + // Parse JSON response + response := output.String() + + // Extract JSON from markdown code blocks if present + if strings.Contains(response, "```json") { + start := strings.Index(response, "```json") + 7 + end := strings.Index(response[start:], "```") + if end != -1 { + response = response[start : start+end] + } + } else if strings.Contains(response, "```") { + start := strings.Index(response, "```") + 3 + end := strings.Index(response[start:], "```") + if end != -1 { + response = response[start : start+end] + } + } + + response = strings.TrimSpace(response) + + var spec map[string]any + if err := json.Unmarshal([]byte(response), &spec); err != nil { + // Fallback: create basic spec if parsing fails + log := logging.FromContext(ctx) + log.Warn("failed to parse agent spec extraction, using fallback", + logging.FieldError, err, + logging.FieldOperation, "extract_blueprint_spec", + "response_length", len(response), + "message_count", len(messages), + ) + spec = map[string]any{ + "version": "1.0", + "generated_at": time.Now().Format(time.RFC3339), + "message_count": len(messages), + "extraction_failed": true, + } + } + + return spec, nil +} diff --git a/internal/service/blueprint_service.go b/internal/service/blueprint_service.go new file mode 100644 index 0000000..2278ae8 --- /dev/null +++ b/internal/service/blueprint_service.go @@ -0,0 +1,119 @@ +package service + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/logging" + "github.com/orchard9/rdev/internal/port" +) + +// BlueprintService orchestrates blueprint operations. +type BlueprintService struct { + repo port.BlueprintRepository +} + +// NewBlueprintService creates a new blueprint service. +func NewBlueprintService(repo port.BlueprintRepository) *BlueprintService { + return &BlueprintService{repo: repo} +} + +// CreateBlueprint creates a new blueprint. +func (s *BlueprintService) CreateBlueprint(ctx context.Context, projectID, name, description string, spec map[string]any) (*domain.Blueprint, error) { + if projectID == "" { + return nil, fmt.Errorf("project_id is required") + } + if name == "" { + return nil, fmt.Errorf("name is required") + } + if spec == nil { + spec = make(map[string]any) + } + + blueprint := &domain.Blueprint{ + ID: domain.BlueprintID(uuid.New().String()), + ProjectID: projectID, + Name: name, + Description: description, + Spec: spec, + } + + startTime := time.Now() + if err := s.repo.CreateBlueprint(ctx, blueprint); err != nil { + return nil, err + } + + log := logging.FromContext(ctx) + log.Info("blueprint created", + "blueprint_id", blueprint.ID, + logging.FieldProjectID, projectID, + logging.FieldOperation, "create_blueprint", + logging.FieldDuration, time.Since(startTime).Milliseconds(), + "name", name, + "has_spec", len(spec) > 0, + ) + + return blueprint, nil +} + +// GetBlueprint retrieves a blueprint by ID. +func (s *BlueprintService) GetBlueprint(ctx context.Context, id domain.BlueprintID) (*domain.Blueprint, error) { + return s.repo.GetBlueprint(ctx, id) +} + +// ListBlueprints returns all blueprints for a project. +func (s *BlueprintService) ListBlueprints(ctx context.Context, projectID string) ([]*domain.Blueprint, error) { + return s.repo.ListBlueprints(ctx, projectID) +} + +// UpdateBlueprint updates a blueprint's metadata and spec. +func (s *BlueprintService) UpdateBlueprint(ctx context.Context, id domain.BlueprintID, name, description string, spec map[string]any) error { + // Get existing blueprint first + blueprint, err := s.repo.GetBlueprint(ctx, id) + if err != nil { + return err + } + + // Update fields + if name != "" { + blueprint.Name = name + } + blueprint.Description = description + if spec != nil { + blueprint.Spec = spec + } + + startTime := time.Now() + if err := s.repo.UpdateBlueprint(ctx, blueprint); err != nil { + return err + } + + log := logging.FromContext(ctx) + log.Info("blueprint updated", + "blueprint_id", id, + logging.FieldProjectID, blueprint.ProjectID, + logging.FieldOperation, "update_blueprint", + logging.FieldDuration, time.Since(startTime).Milliseconds(), + ) + + return nil +} + +// DeleteBlueprint deletes a blueprint. +func (s *BlueprintService) DeleteBlueprint(ctx context.Context, id domain.BlueprintID) error { + startTime := time.Now() + if err := s.repo.DeleteBlueprint(ctx, id); err != nil { + return err + } + + log := logging.FromContext(ctx) + log.Info("blueprint deleted", + "blueprint_id", id, + logging.FieldOperation, "delete_blueprint", + logging.FieldDuration, time.Since(startTime).Milliseconds(), + ) + return nil +} diff --git a/internal/service/conversation_service.go b/internal/service/conversation_service.go new file mode 100644 index 0000000..de13904 --- /dev/null +++ b/internal/service/conversation_service.go @@ -0,0 +1,117 @@ +package service + +import ( + "context" + "fmt" + "time" + + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/logging" + "github.com/orchard9/rdev/internal/port" +) + +// ConversationService orchestrates conversation operations. +type ConversationService struct { + repo port.ConversationRepository +} + +// NewConversationService creates a new conversation service. +func NewConversationService(repo port.ConversationRepository) *ConversationService { + return &ConversationService{repo: repo} +} + +// CreateConversation creates a new conversation. +func (s *ConversationService) CreateConversation(ctx context.Context, projectID, title string) (*domain.Conversation, error) { + if projectID == "" { + return nil, fmt.Errorf("project_id is required") + } + if title == "" { + title = "New Conversation" + } + + startTime := time.Now() + conv, err := s.repo.CreateConversation(ctx, projectID, title) + if err != nil { + return nil, err + } + + log := logging.FromContext(ctx) + log.Info("conversation created", + "conversation_id", conv.ID, + logging.FieldProjectID, projectID, + logging.FieldOperation, "create_conversation", + logging.FieldDuration, time.Since(startTime).Milliseconds(), + "title", title, + ) + + return conv, nil +} + +// GetConversation retrieves a conversation by ID. +func (s *ConversationService) GetConversation(ctx context.Context, id domain.ConversationID) (*domain.Conversation, error) { + return s.repo.GetConversation(ctx, id) +} + +// ListConversations returns all conversations for a project. +func (s *ConversationService) ListConversations(ctx context.Context, projectID string) ([]*domain.Conversation, error) { + return s.repo.ListConversations(ctx, projectID) +} + +// UpdateTitle updates the conversation title. +func (s *ConversationService) UpdateTitle(ctx context.Context, id domain.ConversationID, title string) error { + if title == "" { + return fmt.Errorf("title is required") + } + return s.repo.UpdateConversationTitle(ctx, id, title) +} + +// DeleteConversation deletes a conversation and all its messages. +func (s *ConversationService) DeleteConversation(ctx context.Context, id domain.ConversationID) error { + startTime := time.Now() + if err := s.repo.DeleteConversation(ctx, id); err != nil { + return err + } + + log := logging.FromContext(ctx) + log.Info("conversation deleted", + "conversation_id", id, + logging.FieldOperation, "delete_conversation", + logging.FieldDuration, time.Since(startTime).Milliseconds(), + ) + return nil +} + +// AddMessage adds a message to a conversation. +func (s *ConversationService) AddMessage(ctx context.Context, conversationID domain.ConversationID, role domain.MessageRole, content string) (*domain.Message, error) { + if content == "" { + return nil, fmt.Errorf("message content is required") + } + + startTime := time.Now() + msg, err := s.repo.AddMessage(ctx, conversationID, role, content) + if err != nil { + return nil, err + } + + log := logging.FromContext(ctx) + log.Info("message added", + "conversation_id", conversationID, + "message_id", msg.ID, + "role", role, + logging.FieldOperation, "add_message", + logging.FieldDuration, time.Since(startTime).Milliseconds(), + "content_length", len(content), + ) + + return msg, nil +} + +// GetMessages retrieves all messages for a conversation. +func (s *ConversationService) GetMessages(ctx context.Context, conversationID domain.ConversationID) ([]*domain.Message, error) { + return s.repo.GetMessages(ctx, conversationID) +} + +// GetConversationWithMessages retrieves a conversation with all messages. +func (s *ConversationService) GetConversationWithMessages(ctx context.Context, id domain.ConversationID) (*domain.ConversationWithMessages, error) { + return s.repo.GetConversationWithMessages(ctx, id) +} diff --git a/internal/service/question_service.go b/internal/service/question_service.go new file mode 100644 index 0000000..1666043 --- /dev/null +++ b/internal/service/question_service.go @@ -0,0 +1,170 @@ +package service + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/logging" + "github.com/orchard9/rdev/internal/port" +) + +// QuestionService orchestrates question operations. +type QuestionService struct { + repo port.QuestionRepository +} + +// NewQuestionService creates a new question service. +func NewQuestionService(repo port.QuestionRepository) *QuestionService { + return &QuestionService{repo: repo} +} + +// CreateQuestion creates a new question. +func (s *QuestionService) CreateQuestion(ctx context.Context, conversationID domain.ConversationID, projectID string, questionType domain.QuestionType, text string, choices []string, metadata map[string]string) (*domain.Question, error) { + if projectID == "" { + return nil, fmt.Errorf("project_id is required") + } + if text == "" { + return nil, fmt.Errorf("text is required") + } + + // Validate choices for choice-based questions + if (questionType == domain.QuestionTypeChoice || questionType == domain.QuestionTypeMultiChoice) && len(choices) == 0 { + return nil, fmt.Errorf("choices are required for choice-based questions") + } + + if metadata == nil { + metadata = make(map[string]string) + } + + question := &domain.Question{ + ID: domain.QuestionID(uuid.New().String()), + ConversationID: conversationID, + ProjectID: projectID, + Type: questionType, + Text: text, + Choices: choices, + Metadata: metadata, + } + + startTime := time.Now() + if err := s.repo.CreateQuestion(ctx, question); err != nil { + return nil, err + } + + log := logging.FromContext(ctx) + log.Info("question created", + "question_id", question.ID, + logging.FieldProjectID, projectID, + logging.FieldOperation, "create_question", + logging.FieldDuration, time.Since(startTime).Milliseconds(), + "question_type", questionType, + "conversation_id", conversationID, + "choice_count", len(choices), + ) + + return question, nil +} + +// GetQuestion retrieves a question by ID. +func (s *QuestionService) GetQuestion(ctx context.Context, id domain.QuestionID) (*domain.Question, error) { + return s.repo.GetQuestion(ctx, id) +} + +// ListUnansweredQuestions returns all unanswered questions for a project. +func (s *QuestionService) ListUnansweredQuestions(ctx context.Context, projectID string) ([]*domain.Question, error) { + return s.repo.ListUnansweredQuestions(ctx, projectID) +} + +// ListQuestionsByConversation returns all questions for a conversation. +func (s *QuestionService) ListQuestionsByConversation(ctx context.Context, conversationID domain.ConversationID) ([]*domain.Question, error) { + return s.repo.ListQuestionsByConversation(ctx, conversationID) +} + +// AnswerQuestion records an answer to a question. +func (s *QuestionService) AnswerQuestion(ctx context.Context, id domain.QuestionID, answer *string, answerChoices []string) error { + // Get question to validate answer type + question, err := s.repo.GetQuestion(ctx, id) + if err != nil { + return err + } + + // Validate answer based on question type + switch question.Type { + case domain.QuestionTypeText: + if answer == nil || *answer == "" { + return fmt.Errorf("text answer is required") + } + case domain.QuestionTypeYesNo: + if answer == nil || (*answer != "yes" && *answer != "no") { + return fmt.Errorf("answer must be 'yes' or 'no'") + } + case domain.QuestionTypeChoice: + if answer == nil || *answer == "" { + return fmt.Errorf("choice answer is required") + } + // Validate choice is in the available choices + valid := false + for _, choice := range question.Choices { + if *answer == choice { + valid = true + break + } + } + if !valid { + return fmt.Errorf("answer must be one of the available choices") + } + case domain.QuestionTypeMultiChoice: + if len(answerChoices) == 0 { + return fmt.Errorf("at least one choice must be selected") + } + // Validate all choices are in the available choices + for _, selected := range answerChoices { + valid := false + for _, choice := range question.Choices { + if selected == choice { + valid = true + break + } + } + if !valid { + return fmt.Errorf("answer '%s' is not one of the available choices", selected) + } + } + } + + startTime := time.Now() + if err := s.repo.AnswerQuestion(ctx, id, answer, answerChoices); err != nil { + return err + } + + log := logging.FromContext(ctx) + log.Info("question answered", + "question_id", id, + logging.FieldProjectID, question.ProjectID, + logging.FieldOperation, "answer_question", + logging.FieldDuration, time.Since(startTime).Milliseconds(), + "question_type", question.Type, + "answer_choice_count", len(answerChoices), + ) + + return nil +} + +// DeleteQuestion deletes a question. +func (s *QuestionService) DeleteQuestion(ctx context.Context, id domain.QuestionID) error { + startTime := time.Now() + if err := s.repo.DeleteQuestion(ctx, id); err != nil { + return err + } + + log := logging.FromContext(ctx) + log.Info("question deleted", + "question_id", id, + logging.FieldOperation, "delete_question", + logging.FieldDuration, time.Since(startTime).Milliseconds(), + ) + return nil +}