feat(foundary): implement complete backend for conversational project design
Implements all 5 phases of Foundary Studio backend:
Phase 1: Chat Persistence (8 API endpoints)
- Conversations and messages with proper cascading deletes
- PostgreSQL schema with auto-update triggers
- Full CRUD operations with structured logging
Phase 2: Blueprint Entity (5 API endpoints)
- JSONB spec storage with GIN indexes
- Flexible structured data for project specifications
- Version-controlled blueprint management
Phase 3: Architect Service (3 API endpoints)
- Conversational AI orchestration with Claude
- Multi-turn dialogue with context building
- Blueprint spec extraction from conversations
Phase 4: Work Queue Integration
- Verified existing endpoint compatibility
Phase 5: Structured Questions (6 API endpoints)
- Four question types: text, choice, multichoice, yesno
- Answer validation with proper constraints
- Conversation-linked Q&A flow
Architecture:
- Textbook hexagonal architecture (domain → port → adapter → service → handler)
- Zero external dependencies in domain layer
- Consistent error handling with proper wrapping
- Auth scopes on all routes (projects:read, projects:execute)
- Structured logging with operation context and duration tracking
- NULL-safe DTO converters throughout
Database:
- 3 new migrations (019, 020, 021)
- UUIDs for all primary keys
- Proper foreign key constraints with ON DELETE CASCADE
- Optimized indexes including partial index for unanswered questions
- Auto-update triggers for timestamps
OpenAPI Documentation:
- Complete API documentation under 'Foundary' tag
- 22 new endpoints documented with examples
- Request/response schemas for all operations
Logging Improvements:
- Added operation field to all service logs
- Added duration_ms tracking for performance monitoring
- Log response_length instead of full response content
- Consistent use of logging field constants
- Execute-then-log pattern for delete operations
Files: 32 changed, 2800+ lines added
- 7 domain models
- 3 database migrations
- 3 port interfaces
- 3 postgres adapters
- 4 services (conversation, blueprint, question, architect)
- 4 handlers with DTOs
- OpenAPI documentation
- Integration in main.go
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
adcea2fc1f
commit
a69eb7e587
@ -262,6 +262,26 @@ func main() {
|
|||||||
// Create work service (for worker pool task management)
|
// Create work service (for worker pool task management)
|
||||||
workService := service.NewWorkService(workQueueRepo).WithWebhookDispatcher(webhookDispatcher)
|
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)
|
// Initialize operation tracking (for debugging project failures)
|
||||||
operationRepo := postgres.NewOperationRepository(database.DB)
|
operationRepo := postgres.NewOperationRepository(database.DB)
|
||||||
operationService := service.NewOperationService(operationRepo)
|
operationService := service.NewOperationService(operationRepo)
|
||||||
@ -358,6 +378,10 @@ func main() {
|
|||||||
queueHandler := handlers.NewQueueHandler(commandQueue, projectRepo)
|
queueHandler := handlers.NewQueueHandler(commandQueue, projectRepo)
|
||||||
webhookHandler := handlers.NewWebhookHandler(webhookRepo, projectRepo)
|
webhookHandler := handlers.NewWebhookHandler(webhookRepo, projectRepo)
|
||||||
workHandler := handlers.NewWorkHandler(workService)
|
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
|
// Initialize domain and slug repositories
|
||||||
projectDomainRepo := postgres.NewProjectDomainRepository(database.DB)
|
projectDomainRepo := postgres.NewProjectDomainRepository(database.DB)
|
||||||
@ -543,6 +567,10 @@ func main() {
|
|||||||
queueHandler.Mount(app.Router())
|
queueHandler.Mount(app.Router())
|
||||||
webhookHandler.Mount(app.Router())
|
webhookHandler.Mount(app.Router())
|
||||||
workHandler.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())
|
infraHandler.Mount(app.Router())
|
||||||
projectMgmtHandler.Mount(app.Router())
|
projectMgmtHandler.Mount(app.Router())
|
||||||
if componentsHandler != nil {
|
if componentsHandler != nil {
|
||||||
|
|||||||
@ -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("Webhooks", "External webhook receivers")
|
||||||
spec.WithTag("Infrastructure", "Git, deployment, DNS, and CI pipeline management")
|
spec.WithTag("Infrastructure", "Git, deployment, DNS, and CI pipeline management")
|
||||||
spec.WithTag("Sagas", "Distributed workflow orchestration with compensation")
|
spec.WithTag("Sagas", "Distributed workflow orchestration with compensation")
|
||||||
|
spec.WithTag("Foundary", "Conversational project design and specification")
|
||||||
|
|
||||||
// Register all path operations
|
// Register all path operations
|
||||||
registerSystemPaths(spec)
|
registerSystemPaths(spec)
|
||||||
@ -87,6 +88,10 @@ Command output is streamed via Server-Sent Events (SSE) at /projects/{id}/events
|
|||||||
registerWebhookPaths(spec)
|
registerWebhookPaths(spec)
|
||||||
registerInfrastructurePaths(spec)
|
registerInfrastructurePaths(spec)
|
||||||
registerSagaPaths(spec)
|
registerSagaPaths(spec)
|
||||||
|
registerConversationPaths(spec)
|
||||||
|
registerBlueprintPaths(spec)
|
||||||
|
registerArchitectPaths(spec)
|
||||||
|
registerQuestionPaths(spec)
|
||||||
|
|
||||||
return spec
|
return spec
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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},
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|||||||
998
cookbooks/trees/foundary.yaml
Normal file
998
cookbooks/trees/foundary.yaml
Normal file
@ -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 }}"
|
||||||
@ -1,4 +1,4 @@
|
|||||||
# Orchard Studio UI - ASCII Screens
|
# Foundary Studio UI - ASCII Screens
|
||||||
|
|
||||||
## 1. Project Dashboard (The "Lobby")
|
## 1. Project Dashboard (The "Lobby")
|
||||||
|
|
||||||
@ -6,7 +6,7 @@ Entry point for the Product Owner.
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
+-----------------------------------------------------------------------------+
|
+-----------------------------------------------------------------------------+
|
||||||
| ORCHARD STUDIO [ New Project ] |
|
| Foundary Studio [ New Project ] |
|
||||||
+-----------------------------------------------------------------------------+
|
+-----------------------------------------------------------------------------+
|
||||||
| |
|
| |
|
||||||
| Active Projects |
|
| Active Projects |
|
||||||
|
|||||||
@ -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
|
## 1. Architecture Overview
|
||||||
|
|
||||||
|
|||||||
@ -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
|
## Phase 1: Foundation & Read-Only UI
|
||||||
**Goal:** Visualize the current state of `rdev` projects and work queues.
|
**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.
|
**Goal:** Production readiness.
|
||||||
|
|
||||||
1. **Real-time Polish**: Replace polling with SSE/WebSockets for all status updates.
|
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.
|
3. **Mobile Responsiveness**: Ensure critical flows work on tablet/mobile.
|
||||||
|
|||||||
176
internal/adapter/postgres/blueprint_repository.go
Normal file
176
internal/adapter/postgres/blueprint_repository.go
Normal file
@ -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
|
||||||
|
}
|
||||||
229
internal/adapter/postgres/conversation_repository.go
Normal file
229
internal/adapter/postgres/conversation_repository.go
Normal file
@ -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
|
||||||
|
}
|
||||||
249
internal/adapter/postgres/question_repository.go
Normal file
249
internal/adapter/postgres/question_repository.go
Normal file
@ -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
|
||||||
|
}
|
||||||
58
internal/db/migrations/019_conversations.sql
Normal file
58
internal/db/migrations/019_conversations.sql
Normal file
@ -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';
|
||||||
35
internal/db/migrations/020_blueprints.sql
Normal file
35
internal/db/migrations/020_blueprints.sql
Normal file
@ -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.';
|
||||||
29
internal/db/migrations/021_questions.sql
Normal file
29
internal/db/migrations/021_questions.sql
Normal file
@ -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';
|
||||||
17
internal/domain/blueprint.go
Normal file
17
internal/domain/blueprint.go
Normal file
@ -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
|
||||||
|
}
|
||||||
43
internal/domain/conversation.go
Normal file
43
internal/domain/conversation.go
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -78,6 +78,16 @@ var (
|
|||||||
// Operation errors
|
// Operation errors
|
||||||
ErrOperationNotFound = errors.New("operation not found")
|
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)
|
// Infrastructure errors (should typically be wrapped)
|
||||||
ErrDatabaseConnection = errors.New("database connection error")
|
ErrDatabaseConnection = errors.New("database connection error")
|
||||||
ErrKubernetesError = errors.New("kubernetes error")
|
ErrKubernetesError = errors.New("kubernetes error")
|
||||||
|
|||||||
31
internal/domain/question.go
Normal file
31
internal/domain/question.go
Normal file
@ -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
|
||||||
|
}
|
||||||
148
internal/handlers/architect.go
Normal file
148
internal/handlers/architect.go
Normal file
@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
16
internal/handlers/architect_dto.go
Normal file
16
internal/handlers/architect_dto.go
Normal file
@ -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"`
|
||||||
|
}
|
||||||
29
internal/handlers/blueprint_dto.go
Normal file
29
internal/handlers/blueprint_dto.go
Normal file
@ -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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
169
internal/handlers/blueprints.go
Normal file
169
internal/handlers/blueprints.go
Normal file
@ -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",
|
||||||
|
})
|
||||||
|
}
|
||||||
76
internal/handlers/conversation_dto.go
Normal file
76
internal/handlers/conversation_dto.go
Normal file
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
235
internal/handlers/conversations.go
Normal file
235
internal/handlers/conversations.go
Normal file
@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
41
internal/handlers/question_dto.go
Normal file
41
internal/handlers/question_dto.go
Normal file
@ -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
|
||||||
|
}
|
||||||
211
internal/handlers/questions.go
Normal file
211
internal/handlers/questions.go
Normal file
@ -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",
|
||||||
|
})
|
||||||
|
}
|
||||||
25
internal/port/blueprint_repository.go
Normal file
25
internal/port/blueprint_repository.go
Normal file
@ -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
|
||||||
|
}
|
||||||
34
internal/port/conversation_repository.go
Normal file
34
internal/port/conversation_repository.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
28
internal/port/question_repository.go
Normal file
28
internal/port/question_repository.go
Normal file
@ -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
|
||||||
|
}
|
||||||
303
internal/service/architect_service.go
Normal file
303
internal/service/architect_service.go
Normal file
@ -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
|
||||||
|
}
|
||||||
119
internal/service/blueprint_service.go
Normal file
119
internal/service/blueprint_service.go
Normal file
@ -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
|
||||||
|
}
|
||||||
117
internal/service/conversation_service.go
Normal file
117
internal/service/conversation_service.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
170
internal/service/question_service.go
Normal file
170
internal/service/question_service.go
Normal file
@ -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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user