fix(architect): handle missing projects in repo, add cookbook hooks/validation
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

The architect API returned "failed to start conversation" because
projectRepo.Get() failed — the in-memory K8s repo watches the rdev
namespace but projects deploy to the projects namespace. Made project
lookup non-fatal with fallback to default pod. Added error logging to
all architect handler methods (were silently swallowing errors).

Also adds setup-hooks, commit-after-qa, and pre-merge-validate steps
to the foundary cookbook tree for git hooks and code quality gates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-02-11 02:25:40 -07:00
parent c68fadbccd
commit 542bc722ab
7 changed files with 374 additions and 89 deletions

View File

@ -847,16 +847,34 @@ cmd_teardown() {
local action description local action description
action=$(echo "$step" | jq -r '.action // "unknown"') action=$(echo "$step" | jq -r '.action // "unknown"')
description=$(echo "$step" | jq -r '.description // "Teardown step $i"') description=$(echo "$step" | jq -r ".description // \"Teardown step $i\"")
echo -e "${CYAN}Teardown $i:${NC} $description" echo -e "${CYAN}Teardown $i:${NC} $description"
local response=""
case "$action" in case "$action" in
api) api)
execute_api_step "$step" > /dev/null && print_success "Done" || print_warning "Failed (continuing)" if response=$(execute_api_step "$step" 2>/dev/null); then
# Check for error in response body
local api_error
api_error=$(echo "$response" | jq -r '.error // empty' 2>/dev/null)
if [[ -n "$api_error" ]]; then
print_warning "API error (continuing): $api_error"
else
local api_status
api_status=$(echo "$response" | jq -r '.data.status // "ok"' 2>/dev/null)
print_success "Done ($api_status)"
fi
else
print_warning "Failed (continuing)"
fi
;; ;;
shell) shell)
execute_shell_step "$step" > /dev/null && print_success "Done" || print_warning "Failed (continuing)" if response=$(execute_shell_step "$step" 2>/dev/null); then
print_success "Done"
else
print_warning "Failed (continuing)"
fi
;; ;;
*) *)
print_warning "Skipping unknown action: $action" print_warning "Skipping unknown action: $action"

View File

@ -36,9 +36,31 @@ steps:
max_attempts: 720 max_attempts: 720
poll_interval: 5 poll_interval: 5
setup-hooks:
description: "Configure git hooks in project workspace"
depends_on: [wait-bootstrap]
action: api
method: POST
endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds"
body:
prompt: "Run ./scripts/setup-hooks.sh to configure git hooks. Then verify with: git config core.hooksPath"
auto_commit: false
auto_push: false
git_clone_url: "{{ .outputs.create-project.git_clone_http }}"
outputs:
- build_id: .data.task_id
wait-setup-hooks:
description: "Wait for git hooks setup to complete"
depends_on: [setup-hooks]
action: wait_build
build_id: "{{ .outputs.setup-hooks.build_id }}"
max_attempts: 120
poll_interval: 5
add-components: add-components:
description: "Add React frontend, API service, and Postgres database" description: "Add React frontend, API service, and Postgres database"
depends_on: [wait-bootstrap] depends_on: [wait-setup-hooks]
action: api action: api
method: POST method: POST
endpoint: "/projects/{{ .outputs.create-project.project_id }}/components/batch" endpoint: "/projects/{{ .outputs.create-project.project_id }}/components/batch"
@ -255,13 +277,68 @@ steps:
project_id: "{{ .outputs.create-project.project_id }}" project_id: "{{ .outputs.create-project.project_id }}"
max_attempts: 720 max_attempts: 720
# --- Commit QA artifacts and validate pre-merge hooks ---
commit-after-qa:
description: "Commit any remaining changes after QA"
depends_on: [wait-deploy-polish]
action: api
method: POST
endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds"
body:
prompt: '/commit-all "chore: commit QA artifacts and fixes"'
auto_commit: false
auto_push: false
git_clone_url: "{{ .outputs.create-project.git_clone_http }}"
outputs:
- build_id: .data.task_id
wait-commit-after-qa:
description: "Wait for QA commit to complete"
depends_on: [commit-after-qa]
action: wait_build
build_id: "{{ .outputs.commit-after-qa.build_id }}"
max_attempts: 120
poll_interval: 5
pre-merge-validate:
description: "Run pre-commit hooks and fix any failures"
depends_on: [wait-commit-after-qa]
action: api
method: POST
endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds"
body:
prompt: |
Run ./.githooks/pre-commit to check code quality.
If it fails, run /fix-all to fix all issues, then re-run
./.githooks/pre-commit. Repeat until it passes.
auto_commit: true
auto_push: true
git_clone_url: "{{ .outputs.create-project.git_clone_http }}"
outputs:
- build_id: .data.task_id
wait-pre-merge-validate:
description: "Wait for pre-merge validation to complete"
depends_on: [pre-merge-validate]
action: wait_build
build_id: "{{ .outputs.pre-merge-validate.build_id }}"
max_attempts: 720
poll_interval: 5
wait-deploy-final:
description: "Wait for final deployment pipeline after validation fixes"
depends_on: [wait-pre-merge-validate]
action: wait_pipeline
project_id: "{{ .outputs.create-project.project_id }}"
max_attempts: 720
# ============================================================ # ============================================================
# SECTION 3: VERIFY # SECTION 3: VERIFY
# Confirm site is live and API responds # Confirm site is live and API responds
# ============================================================ # ============================================================
verify-site-live: verify-site-live:
description: "Verify site is live after all builds" description: "Verify site is live after all builds"
depends_on: [wait-deploy-polish] depends_on: [wait-deploy-final]
action: wait_site action: wait_site
domain: "{{ .outputs.create-project.domain }}" domain: "{{ .outputs.create-project.domain }}"
max_attempts: 120 max_attempts: 120

View File

@ -1,3 +1,4 @@
import { Routes, Route, useLocation, useNavigate } from 'react-router-dom';
import { DashboardShell, Sidebar, Header, type NavItem } from '@{{PROJECT_NAME}}/layout'; import { DashboardShell, Sidebar, Header, type NavItem } from '@{{PROJECT_NAME}}/layout';
import { import {
Button, Button,
@ -14,38 +15,22 @@ import {
} from '@{{PROJECT_NAME}}/ui'; } from '@{{PROJECT_NAME}}/ui';
const navItems: NavItem[] = [ const navItems: NavItem[] = [
{ label: 'Dashboard', href: '/', icon: Home, active: true }, { label: 'Dashboard', href: '/', icon: Home },
{ label: 'Analytics', href: '/analytics', icon: BarChart3 }, { label: 'Analytics', href: '/analytics', icon: BarChart3 },
{ label: 'Users', href: '/users', icon: Users, badge: '12' }, { label: 'Users', href: '/users', icon: Users, badge: '12' },
{ label: 'Settings', href: '/settings', icon: Settings }, { label: 'Settings', href: '/settings', icon: Settings },
]; ];
function App() { const pageTitles: Record<string, string> = {
'/': 'Dashboard',
'/analytics': 'Analytics',
'/users': 'Users',
'/settings': 'Settings',
};
function DashboardPage() {
return ( return (
<DashboardShell
sidebar={
<Sidebar
logo={
<span className="font-semibold text-lg">{{PROJECT_NAME}}</span>
}
items={navItems}
footer={
<div className="text-sm text-[var(--text-muted)]">
v0.0.1
</div>
}
/>
}
header={
<Header
title="Dashboard"
showSearch
searchPlaceholder="Search..."
/>
}
>
<div className="space-y-6"> <div className="space-y-6">
{/* Welcome card */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Welcome to {{COMPONENT_NAME}}</CardTitle> <CardTitle>Welcome to {{COMPONENT_NAME}}</CardTitle>
@ -65,7 +50,6 @@ function App() {
</CardContent> </CardContent>
</Card> </Card>
{/* Stats cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
@ -98,7 +82,6 @@ function App() {
</Card> </Card>
</div> </div>
{/* Edit hint */}
<p className="text-sm text-[var(--text-muted)]"> <p className="text-sm text-[var(--text-muted)]">
Edit this file at{' '} Edit this file at{' '}
<code className="bg-[var(--surface-200)] px-1.5 py-0.5 rounded"> <code className="bg-[var(--surface-200)] px-1.5 py-0.5 rounded">
@ -106,6 +89,195 @@ function App() {
</code> </code>
</p> </p>
</div> </div>
);
}
function UsersPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">All Users</h2>
<p className="text-sm text-[var(--text-muted)]">Manage your team members and their roles.</p>
</div>
<Button>Add User</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[
{ name: 'Alice Chen', role: 'Admin', status: 'Active' },
{ name: 'Bob Martinez', role: 'Editor', status: 'Active' },
{ name: 'Carol Singh', role: 'Viewer', status: 'Invited' },
].map((user) => (
<Card key={user.name}>
<CardHeader>
<CardTitle className="text-base">{user.name}</CardTitle>
<CardDescription>{user.role}</CardDescription>
</CardHeader>
<CardContent>
<Badge variant={user.status === 'Active' ? 'success' : 'info'}>
{user.status}
</Badge>
</CardContent>
</Card>
))}
</div>
</div>
);
}
function AnalyticsPage() {
return (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardHeader className="pb-2">
<CardDescription>Page Views</CardDescription>
<CardTitle className="text-3xl">24.5k</CardTitle>
</CardHeader>
<CardContent>
<Badge variant="success">+8% this week</Badge>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Bounce Rate</CardDescription>
<CardTitle className="text-3xl">32%</CardTitle>
</CardHeader>
<CardContent>
<Badge variant="success">-3% improvement</Badge>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Avg. Session</CardDescription>
<CardTitle className="text-3xl">4m 12s</CardTitle>
</CardHeader>
<CardContent>
<Badge variant="info">Stable</Badge>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Traffic Sources</CardTitle>
<CardDescription>Where your visitors are coming from this month.</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[
{ source: 'Direct', visits: '8,421', pct: 34 },
{ source: 'Search', visits: '6,312', pct: 26 },
{ source: 'Social', visits: '5,105', pct: 21 },
{ source: 'Referral', visits: '4,662', pct: 19 },
].map((row) => (
<div key={row.source} className="flex items-center justify-between">
<span className="text-sm font-medium text-[var(--text-primary)]">{row.source}</span>
<div className="flex items-center gap-3">
<div className="w-32 h-2 rounded-full bg-[var(--surface-200)] overflow-hidden">
<div
className="h-full rounded-full bg-[var(--accent)]"
style={{ width: `${row.pct}%` }}
/>
</div>
<span className="text-sm text-[var(--text-muted)] w-16 text-right">{row.visits}</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}
function SettingsPage() {
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>General</CardTitle>
<CardDescription>Manage your application settings and preferences.</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{[
{ label: 'Application Name', value: '{{PROJECT_NAME}}' },
{ label: 'Environment', value: 'Production' },
{ label: 'Region', value: 'US West' },
].map((setting) => (
<div key={setting.label} className="flex items-center justify-between py-2 border-b border-[var(--border-muted)] last:border-0">
<span className="text-sm font-medium text-[var(--text-primary)]">{setting.label}</span>
<Badge variant="outline">{setting.value}</Badge>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Danger Zone</CardTitle>
<CardDescription>Irreversible actions for your application.</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">Delete Application</p>
<p className="text-sm text-[var(--text-muted)]">Permanently remove this application and all its data.</p>
</div>
<Button variant="destructive">Delete</Button>
</div>
</CardContent>
</Card>
</div>
);
}
function App() {
const location = useLocation();
const navigate = useNavigate();
const itemsWithActive = navItems.map((item) => ({
...item,
active: location.pathname === item.href,
}));
const pageTitle = pageTitles[location.pathname] || 'Dashboard';
return (
<DashboardShell
sidebar={
<Sidebar
logo={
<span className="font-semibold text-lg">{{PROJECT_NAME}}</span>
}
items={itemsWithActive}
onNavigate={(href) => navigate(href)}
footer={
<div className="text-sm text-[var(--text-muted)]">
v0.0.1
</div>
}
/>
}
header={
<Header
title={pageTitle}
showSearch
searchPlaceholder="Search..."
/>
}
>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</DashboardShell> </DashboardShell>
); );
} }

View File

@ -1,11 +1,14 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.tsx'; import App from './App.tsx';
import './index.css'; import './index.css';
import './lib/logger'; import './lib/logger';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter>
<App /> <App />
</BrowserRouter>
</React.StrictMode> </React.StrictMode>
); );

View File

@ -7,6 +7,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/auth" "github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/logging"
"github.com/orchard9/rdev/internal/service" "github.com/orchard9/rdev/internal/service"
"github.com/orchard9/rdev/internal/validate" "github.com/orchard9/rdev/internal/validate"
"github.com/orchard9/rdev/pkg/api" "github.com/orchard9/rdev/pkg/api"
@ -62,6 +63,8 @@ func (h *ArchitectHandler) StartConversation(w http.ResponseWriter, r *http.Requ
conv, err := h.architectService.StartConversation(r.Context(), projectID, req.Prompt) conv, err := h.architectService.StartConversation(r.Context(), projectID, req.Prompt)
if err != nil { if err != nil {
log := logging.FromContext(r.Context()).WithHandler("StartConversation")
log.Error("failed to start conversation", logging.FieldError, err, logging.FieldProjectID, projectID)
api.WriteInternalError(w, r, "failed to start conversation") api.WriteInternalError(w, r, "failed to start conversation")
return return
} }
@ -100,6 +103,8 @@ func (h *ArchitectHandler) ContinueConversation(w http.ResponseWriter, r *http.R
api.WriteNotFound(w, r, "conversation not found") api.WriteNotFound(w, r, "conversation not found")
return return
} }
log := logging.FromContext(r.Context()).WithHandler("ContinueConversation")
log.Error("failed to continue conversation", logging.FieldError, err, "conversation_id", conversationID)
api.WriteInternalError(w, r, "failed to continue conversation") api.WriteInternalError(w, r, "failed to continue conversation")
return return
} }
@ -138,6 +143,8 @@ func (h *ArchitectHandler) GenerateBlueprint(w http.ResponseWriter, r *http.Requ
api.WriteNotFound(w, r, "conversation not found") api.WriteNotFound(w, r, "conversation not found")
return return
} }
log := logging.FromContext(r.Context()).WithHandler("GenerateBlueprint")
log.Error("failed to generate blueprint", logging.FieldError, err, "conversation_id", conversationID)
api.WriteInternalError(w, r, "failed to generate blueprint") api.WriteInternalError(w, r, "failed to generate blueprint")
return return
} }

View File

@ -150,9 +150,20 @@ func (s *ArchitectService) askArchitect(ctx context.Context, projectID string, c
return "", fmt.Errorf("no agent available") return "", fmt.Errorf("no agent available")
} }
// Resolve project pod name. The in-memory project repo may not have the
// project (it discovers pods in the rdev namespace, but projects deploy to
// the projects namespace). Fall back to defaults when not found.
podName := s.defaultPodName
project, err := s.projectRepo.Get(ctx, domain.ProjectID(projectID)) project, err := s.projectRepo.Get(ctx, domain.ProjectID(projectID))
if err != nil { if err != nil {
return "", fmt.Errorf("resolve project: %w", err) log := logging.FromContext(ctx)
log.Warn("project not found in repo, using default pod",
logging.FieldProjectID, projectID,
"default_pod", podName,
logging.FieldError, err,
)
} else if project.PodName != "" {
podName = project.PodName
} }
// Prepare architect-specific system prompt // Prepare architect-specific system prompt
@ -175,15 +186,9 @@ Current conversation context:`
fullPrompt := systemPrompt + "\n\n" + prompt fullPrompt := systemPrompt + "\n\n" + prompt
// Resolve pod: use project's pod if set, otherwise fall back to default.
podName := project.PodName
if podName == "" {
podName = s.defaultPodName
}
agentReq := &domain.AgentRequest{ agentReq := &domain.AgentRequest{
Prompt: fullPrompt, Prompt: fullPrompt,
ProjectID: project.ID, ProjectID: domain.ProjectID(projectID),
Timeout: 2 * time.Minute, Timeout: 2 * time.Minute,
Metadata: map[string]string{ Metadata: map[string]string{
"conversation_id": string(conversationID), "conversation_id": string(conversationID),
@ -224,9 +229,17 @@ func (s *ArchitectService) extractSpecFromMessages(ctx context.Context, projectI
return nil, fmt.Errorf("no agent available") return nil, fmt.Errorf("no agent available")
} }
// Resolve project pod name with fallback (same as askArchitect).
podName := s.defaultPodName
project, err := s.projectRepo.Get(ctx, domain.ProjectID(projectID)) project, err := s.projectRepo.Get(ctx, domain.ProjectID(projectID))
if err != nil { if err != nil {
return nil, fmt.Errorf("resolve project: %w", err) log := logging.FromContext(ctx)
log.Warn("project not found in repo for spec extraction, using default pod",
logging.FieldProjectID, projectID,
logging.FieldError, err,
)
} else if project.PodName != "" {
podName = project.PodName
} }
// Build conversation transcript // Build conversation transcript
@ -267,15 +280,9 @@ Extract and return ONLY a valid JSON object with this structure:
Return ONLY the JSON, no other text.`, transcript) Return ONLY the JSON, no other text.`, transcript)
// Resolve pod: use project's pod if set, otherwise fall back to default.
podName := project.PodName
if podName == "" {
podName = s.defaultPodName
}
agentReq := &domain.AgentRequest{ agentReq := &domain.AgentRequest{
Prompt: extractionPrompt, Prompt: extractionPrompt,
ProjectID: project.ID, ProjectID: domain.ProjectID(projectID),
Timeout: 2 * time.Minute, Timeout: 2 * time.Minute,
Metadata: map[string]string{ Metadata: map[string]string{
"purpose": "spec-extraction", "purpose": "spec-extraction",

View File

@ -691,8 +691,9 @@ func (s *ProjectInfraService) DeleteProject(ctx context.Context, projectID strin
return err return err
} }
// 1. Undeploy if deployed // 1. Undeploy all K8s resources (always attempt — component deployments
if s.deployer != nil && status.DeploymentStatus != "none" { // may exist even when the main deployment status is "none")
if s.deployer != nil {
if err := s.deployer.UndeployAll(ctx, projectID); err != nil { if err := s.deployer.UndeployAll(ctx, projectID); err != nil {
log.Warn("failed to undeploy", logging.FieldError, err) log.Warn("failed to undeploy", logging.FieldError, err)
} }