diff --git a/README.md b/README.md index 24d2095..8986f47 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,10 @@ but makes Hop's workflow the primary product model: - a **proposal** is a reviewable candidate outcome; and - an **accepted state** is the current shared truth. +The repository **Prompts** view is the review surface for that causal record: +the requested work, agent identity and runtime metadata, response summary, and +links to the task, attempt, and immutable state that produced it. + The product thesis is simple: > GitHub organizes collaboration around commits and pull requests. HopWeb @@ -54,4 +58,3 @@ single-installation forge where a user can: Everything else should be inherited from Gitea until the Hop workflow proves that it needs a different abstraction. - diff --git a/deploy/gitea/install-hop-native.sh b/deploy/gitea/install-hop-native.sh index af36a66..c4043c7 100755 --- a/deploy/gitea/install-hop-native.sh +++ b/deploy/gitea/install-hop-native.sh @@ -29,6 +29,9 @@ docker compose --env-file "$env_file" cp \ docker compose --env-file "$env_file" cp \ deploy/gitea/public/assets/css/hop-home.css \ gitea:/data/gitea/public/assets/css/hop-home.css +docker compose --env-file "$env_file" cp \ + deploy/gitea/public/assets/css/hop-prompts.css \ + gitea:/data/gitea/public/assets/css/hop-prompts.css docker compose --env-file "$env_file" cp \ deploy/gitea/public/assets/img/hop.svg \ gitea:/data/gitea/public/assets/img/hop.svg diff --git a/deploy/gitea/public/assets/css/hop-prompts.css b/deploy/gitea/public/assets/css/hop-prompts.css new file mode 100644 index 0000000..45fa9e7 --- /dev/null +++ b/deploy/gitea/public/assets/css/hop-prompts.css @@ -0,0 +1,194 @@ +.hop-prompts { + margin: 0 auto; + max-width: 1080px; + padding: 2.25rem clamp(1rem, 3vw, 2.5rem) 4rem; +} + +.hop-prompts__header { + align-items: end; + border-bottom: 1px solid var(--color-secondary); + display: flex; + gap: 1.5rem; + justify-content: space-between; + padding-bottom: 1.5rem; +} + +.hop-prompts__eyebrow { + color: var(--color-text-light-2); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.1em; + margin: 0 0 0.4rem; + text-transform: uppercase; +} + +.hop-prompts h1 { + color: var(--color-text); + font-size: 1.55rem; + letter-spacing: -0.025em; + margin: 0; +} + +.hop-prompts__lede { + color: var(--color-text-light-2); + line-height: 1.55; + margin: 0.65rem 0 0; + max-width: 62ch; +} + +.hop-prompts__refresh { + align-items: center; + display: inline-flex; + gap: 0.45rem; + white-space: nowrap; +} + +.hop-prompts__status { + color: var(--color-text-light-2); + font-size: 0.9rem; + margin: 1.25rem 0; +} + +.hop-prompts__empty { + border: 1px dashed var(--color-secondary); + color: var(--color-text-light-2); + line-height: 1.6; + margin-top: 1.25rem; + max-width: 720px; + padding: 1.5rem; +} + +.hop-prompts__empty strong { + color: var(--color-text); + display: block; + font-size: 1rem; + margin-bottom: 0.35rem; +} + +.hop-prompt-list { + display: grid; + gap: 0; + margin-top: 1.25rem; +} + +.hop-prompt { + border-bottom: 1px solid var(--color-secondary); + border-top: 1px solid var(--color-secondary); + margin-top: -1px; +} + +.hop-prompt summary { + align-items: center; + cursor: pointer; + display: grid; + gap: 0.75rem 1rem; + grid-template-columns: minmax(0, 1fr) auto; + list-style: none; + padding: 1rem 0; +} + +.hop-prompt summary::-webkit-details-marker { display: none; } + +.hop-prompt__request { + color: var(--color-text); + font-size: 1rem; + font-weight: 600; + line-height: 1.45; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hop-prompt__meta { + color: var(--color-text-light-2); + font-size: 0.82rem; + text-align: right; +} + +.hop-prompt__status { + align-items: center; + color: var(--color-text-light-2); + display: inline-flex; + gap: 0.4rem; + margin-right: 0.55rem; +} + +.hop-prompt__status::before { + background: var(--color-text-light-3); + border-radius: 50%; + content: ""; + height: 0.45rem; + width: 0.45rem; +} + +.hop-prompt__status[data-status="completed"]::before { background: var(--color-green); } +.hop-prompt__status[data-status="failed"]::before { background: var(--color-red); } +.hop-prompt__status[data-status="cancelled"]::before { background: var(--color-orange); } +.hop-prompt__status[data-status="running"]::before { background: var(--color-primary); } + +.hop-prompt__body { + border-top: 1px solid var(--color-secondary); + display: grid; + gap: 1.25rem; + grid-template-columns: minmax(0, 1.45fr) minmax(14rem, 0.55fr); + padding: 1.25rem 0 1.5rem; +} + +.hop-prompt h2, +.hop-prompt h3 { + color: var(--color-text); + font-size: 0.78rem; + letter-spacing: 0.07em; + margin: 0 0 0.55rem; + text-transform: uppercase; +} + +.hop-prompt pre, +.hop-prompt__response { + background: var(--color-box-body); + border: 1px solid var(--color-secondary); + border-radius: var(--radius-medium); + color: var(--color-text); + font: inherit; + line-height: 1.55; + margin: 0; + overflow-wrap: anywhere; + padding: 0.9rem 1rem; + white-space: pre-wrap; +} + +.hop-prompt__response { color: var(--color-text-light-1); } + +.hop-prompt__facts { + display: grid; + gap: 0.6rem; + margin: 0; +} + +.hop-prompt__facts div { + border-bottom: 1px solid var(--color-secondary); + padding-bottom: 0.55rem; +} + +.hop-prompt__facts dt { + color: var(--color-text-light-2); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.hop-prompt__facts dd { + color: var(--color-text); + font-size: 0.88rem; + margin: 0.2rem 0 0; + overflow-wrap: anywhere; +} + +@media (max-width: 700px) { + .hop-prompts__header, .hop-prompt__body { align-items: start; grid-template-columns: 1fr; } + .hop-prompts__header { flex-direction: column; } + .hop-prompt summary { grid-template-columns: 1fr; } + .hop-prompt__meta { text-align: left; } +} diff --git a/deploy/gitea/public/assets/js/hop-native.js b/deploy/gitea/public/assets/js/hop-native.js index b32107a..cab1163 100644 --- a/deploy/gitea/public/assets/js/hop-native.js +++ b/deploy/gitea/public/assets/js/hop-native.js @@ -90,6 +90,44 @@ }) || null; } + function repositoryPath() { + const issues = navigationLink('/issues'); + if (issues) return new URL(issues.href, window.location.origin).pathname.replace(/\/issues$/, ''); + const match = window.location.pathname.match(/^\/[^/]+\/[^/]+/); + return match ? match[0].replace(/\/$/, '') : null; + } + + function promptsHref() { + const path = repositoryPath(); + return path ? `${path}/issues?hop=prompts` : null; + } + + function promptsPage() { + return new URLSearchParams(window.location.search).get('hop') === 'prompts'; + } + + function addPromptsNavigation() { + const navigation = repoNavigation(); + const tasks = navigationLink('/issues'); + const href = promptsHref(); + if (!navigation || !tasks || !href) return; + + let link = navigation.querySelector('.hop-native-prompts-tab'); + if (!link) { + link = document.createElement('a'); + link.className = tasks.className; + link.classList.add('hop-native-prompts-tab'); + const icon = tasks.querySelector('svg'); + if (icon) link.append(icon.cloneNode(true)); + link.append(document.createTextNode(' Prompts')); + navigation.insertBefore(link, tasks); + } + link.href = href; + link.setAttribute('aria-label', 'Prompts'); + link.classList.toggle('active', promptsPage()); + if (promptsPage()) tasks.classList.remove('active'); + } + function workflowLink(source, label) { if (!source) return null; const link = document.createElement('a'); @@ -111,6 +149,7 @@ if (!sidebar || sidebar.querySelector('.hop-native-workflow')) return; const links = [ + workflowLink(document.querySelector('.hop-native-prompts-tab'), 'Prompts'), workflowLink(navigationLink('/issues'), 'Tasks'), workflowLink(navigationLink('/pulls'), 'Proposals'), workflowLink(navigationLink('/actions'), 'Evidence'), @@ -140,6 +179,132 @@ sidebar.prepend(list); } + function makeElement(tag, className, text) { + const element = document.createElement(tag); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + return element; + } + + function formatTime(value) { + if (!value) return ''; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); + } + + function promptFact(list, label, value) { + if (!value) return; + const row = document.createElement('div'); + row.append(makeElement('dt', '', label), makeElement('dd', '', String(value))); + list.append(row); + } + + function promptDetails(prompt) { + const body = makeElement('div', 'hop-prompt__body'); + const record = document.createElement('div'); + record.append(makeElement('h2', '', 'Captured request')); + record.append(makeElement('pre', '', prompt.prompt)); + if (prompt.response_summary) { + record.append(makeElement('h2', '', 'Agent outcome')); + record.append(makeElement('div', 'hop-prompt__response', prompt.response_summary)); + } + + const facts = document.createElement('dl'); + facts.className = 'hop-prompt__facts'; + promptFact(facts, 'State', prompt.state_id); + promptFact(facts, 'Task', prompt.task_id); + promptFact(facts, 'Attempt', prompt.attempt_id); + promptFact(facts, 'Agent', [prompt.agent_name, prompt.agent_model].filter(Boolean).join(' · ')); + promptFact(facts, 'Captured', formatTime(prompt.created_at)); + promptFact(facts, 'Completed', formatTime(prompt.completed_at)); + if (prompt.metadata && typeof prompt.metadata === 'object') { + Object.entries(prompt.metadata).slice(0, 8).forEach(([key, value]) => { + if (value === null || typeof value === 'object') return; + promptFact(facts, key.replace(/_/g, ' '), value); + }); + } + body.append(record, facts); + return body; + } + + function promptRow(prompt) { + const details = document.createElement('details'); + details.className = 'hop-prompt'; + const summary = document.createElement('summary'); + const request = makeElement('div', 'hop-prompt__request', prompt.prompt); + request.title = prompt.prompt; + const meta = makeElement('div', 'hop-prompt__meta'); + const status = makeElement('span', 'hop-prompt__status', prompt.status || 'running'); + status.dataset.status = prompt.status || 'running'; + meta.append(status); + const agent = [prompt.agent_name, prompt.agent_model].filter(Boolean).join(' · '); + meta.append(document.createTextNode(agent || formatTime(prompt.created_at))); + summary.append(request, meta); + details.append(summary, promptDetails(prompt)); + return details; + } + + function renderPromptPage() { + if (!promptsPage()) return; + const main = document.querySelector('[role="main"].page-content.repository.issue-list'); + const path = repositoryPath(); + if (!main || !path || main.dataset.hopPromptsRendered === 'true') return; + main.dataset.hopPromptsRendered = 'true'; + main.setAttribute('aria-label', 'Prompts'); + const navigation = main.querySelector(':scope > .secondary-nav'); + for (const child of [...main.children]) { + if (child !== navigation) child.remove(); + } + + const page = makeElement('section', 'hop-prompts'); + page.setAttribute('aria-labelledby', 'hop-prompts-title'); + const header = makeElement('header', 'hop-prompts__header'); + const heading = document.createElement('div'); + heading.append( + makeElement('p', 'hop-prompts__eyebrow', 'Hop causal record'), + makeElement('h1', '', 'Prompts'), + makeElement('p', 'hop-prompts__lede', 'Review what was requested, which agent acted, and the result it reported. Every entry is tied to the task, attempt, and state that produced it.'), + ); + heading.querySelector('h1').id = 'hop-prompts-title'; + const refresh = makeElement('button', 'ui button hop-prompts__refresh', 'Refresh'); + refresh.type = 'button'; + header.append(heading, refresh); + const status = makeElement('p', 'hop-prompts__status', 'Loading prompt records…'); + status.setAttribute('role', 'status'); + page.append(header, status); + main.append(page); + document.title = `Prompts - ${path.slice(1).replace('/', ' / ')} - Hop`; + + const load = async () => { + status.textContent = 'Loading prompt records…'; + page.querySelector('.hop-prompt-list, .hop-prompts__empty')?.remove(); + try { + const [owner, repo] = path.slice(1).split('/'); + const response = await fetch(`/hop/api/v1/prompts?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`, {credentials: 'same-origin'}); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload?.error?.message || 'Could not load prompt records'); + const prompts = Array.isArray(payload.prompts) ? payload.prompts : []; + status.textContent = prompts.length === 1 ? '1 captured request' : `${prompts.length} captured requests`; + if (!prompts.length) { + const empty = makeElement('div', 'hop-prompts__empty'); + empty.append( + makeElement('strong', '', 'No prompts have been reported yet.'), + document.createTextNode('When an agent starts work through Hop, its request, execution metadata, and final outcome will appear here.'), + ); + page.append(empty); + return; + } + const list = makeElement('div', 'hop-prompt-list'); + prompts.forEach((prompt) => list.append(promptRow(prompt))); + page.append(list); + } catch (error) { + status.textContent = error.message || 'Could not load prompt records.'; + } + }; + refresh.addEventListener('click', load); + load(); + } + function replaceBrandImages() { const upstreamBrandAsset = /\/img\/(?:avatar_default\.png|logo\.png|logo\.svg)(?:\?.*)?$/; for (const image of document.querySelectorAll('img[src]')) { @@ -154,7 +319,9 @@ replaceBrandImages(); for (const root of document.querySelectorAll(semanticRoots)) replaceExactText(root); replaceAccessibleLabels(document); + addPromptsNavigation(); addWorkflowSummary(); + renderPromptPage(); const title = replacePhrases(document.title); if (title !== document.title) document.title = title; document.documentElement.dataset.hopNative = 'true'; diff --git a/deploy/gitea/templates/custom/footer.tmpl b/deploy/gitea/templates/custom/footer.tmpl index d14de33..f63a23f 100644 --- a/deploy/gitea/templates/custom/footer.tmpl +++ b/deploy/gitea/templates/custom/footer.tmpl @@ -1,2 +1,3 @@ - + + diff --git a/docs/gitea-hop-native.md b/docs/gitea-hop-native.md index 295dcc5..d133302 100644 --- a/docs/gitea-hop-native.md +++ b/docs/gitea-hop-native.md @@ -27,6 +27,7 @@ asset or replacing the Gitea volume. | Native route and behavior | Hop label | | --- | --- | | Repository code | Files | +| — | Prompts (Hop-native causal record) | | Issues | Tasks | | Branches | Attempts | | Commits | Checkpoints | @@ -47,7 +48,7 @@ Gitea sidebar. It uses Gitea's own classes, links, icons, and live counts. After changing the pinned Gitea version: 1. run the installer; -2. confirm Tasks, Proposals, and Evidence in repository navigation; +2. confirm Prompts, Tasks, Proposals, and Evidence in repository navigation; 3. confirm Attempts and Checkpoints in the Files view; 4. confirm proposal creation and acceptance labels; 5. inspect browser console errors; and diff --git a/docs/production-deployment.md b/docs/production-deployment.md index 1cd0c0f..a304238 100644 --- a/docs/production-deployment.md +++ b/docs/production-deployment.md @@ -27,5 +27,20 @@ docker compose --env-file .env up --build -d The reverse proxy should forward the public hostname to the configured `GITEA_HTTP_BIND:GITEA_HTTP_PORT`, preserve the `Host` header, and set -`X-Forwarded-Proto` to `https`. Persistent repository and database data live in -the named Compose volumes. +`X-Forwarded-Proto` to `https`. It must also proxy `/hop/` to the Hop control +plane, preserving the browser's Gitea session cookie. This lets the Prompts +view check the viewer's repository access before returning potentially +sensitive prompt text. + +For nginx, the control-plane location should precede the Gitea catch-all: + +```nginx +location /hop/ { + proxy_pass http://127.0.0.1:8080/; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Cookie $http_cookie; +} +``` + +Persistent repository and database data live in the named Compose volumes. diff --git a/services/control-plane/internal/gitea/client.go b/services/control-plane/internal/gitea/client.go index 4df0da2..c8f076a 100644 --- a/services/control-plane/internal/gitea/client.go +++ b/services/control-plane/internal/gitea/client.go @@ -78,3 +78,33 @@ func (c *Client) Repository(ctx context.Context, owner, name string) (Repository } return repo, nil } + +// ViewerCanReadRepository checks a browser's existing Gitea session before +// returning prompt data. Prompts may contain sensitive intent, so the Hop API +// must never turn a private repository into a public metadata feed. +func (c *Client) ViewerCanReadRepository(ctx context.Context, cookie, owner, name string) (bool, error) { + if strings.TrimSpace(cookie) == "" { + return false, nil + } + u := c.baseURL.JoinPath("api", "v1", "repos", owner, name) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return false, err + } + req.Header.Set("Cookie", cookie) + req.Header.Set("Accept", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return false, fmt.Errorf("check viewer repository access: %w", err) + } + defer resp.Body.Close() + switch resp.StatusCode { + case http.StatusOK: + return true, nil + case http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound: + return false, nil + default: + return false, fmt.Errorf("gitea viewer repository check returned %s", resp.Status) + } +} diff --git a/services/control-plane/internal/gitea/client_test.go b/services/control-plane/internal/gitea/client_test.go index 39c41be..1657b09 100644 --- a/services/control-plane/internal/gitea/client_test.go +++ b/services/control-plane/internal/gitea/client_test.go @@ -42,3 +42,41 @@ func TestRepositoryRequiresToken(t *testing.T) { t.Fatalf("error = %v, want %v", err, ErrTokenNotConfigured) } } + +func TestViewerCanReadRepositoryForwardsSessionCookie(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/repos/hop/private" { + t.Fatalf("path = %q", r.URL.Path) + } + if got := r.Header.Get("Cookie"); got != "i_like_gitea=session" { + t.Fatalf("cookie = %q", got) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client, err := NewClient(server.URL, "") + if err != nil { + t.Fatal(err) + } + allowed, err := client.ViewerCanReadRepository(context.Background(), "i_like_gitea=session", "hop", "private") + if err != nil || !allowed { + t.Fatalf("allowed = %t, err = %v", allowed, err) + } +} + +func TestViewerCannotReadMissingRepository(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + client, err := NewClient(server.URL, "") + if err != nil { + t.Fatal(err) + } + allowed, err := client.ViewerCanReadRepository(context.Background(), "i_like_gitea=session", "hop", "missing") + if err != nil || allowed { + t.Fatalf("allowed = %t, err = %v", allowed, err) + } +} diff --git a/services/control-plane/internal/httpapi/server.go b/services/control-plane/internal/httpapi/server.go index 0679b09..9b33574 100644 --- a/services/control-plane/internal/httpapi/server.go +++ b/services/control-plane/internal/httpapi/server.go @@ -21,6 +21,7 @@ const maxWebhookBody = 2 << 20 type giteaClient interface { Repository(context.Context, string, string) (gitea.Repository, error) + ViewerCanReadRepository(context.Context, string, string, string) (bool, error) } type server struct { @@ -38,10 +39,90 @@ func New(data store.Store, client giteaClient, adminToken, webhookSecret string, mux.HandleFunc("GET /readyz", s.ready) mux.Handle("GET /api/v1/repositories", s.requireAdmin(http.HandlerFunc(s.listRepositories))) mux.Handle("POST /api/v1/repositories/link", s.requireAdmin(http.HandlerFunc(s.linkRepository))) + mux.Handle("GET /api/v1/prompts", http.HandlerFunc(s.listPrompts)) + mux.Handle("POST /api/v1/repositories/{owner}/{name}/prompts", s.requireAdmin(http.HandlerFunc(s.createPrompt))) mux.HandleFunc("POST /api/v1/gitea/webhooks", s.giteaWebhook) return requestLogger(logger, mux) } +func (s *server) listPrompts(w http.ResponseWriter, r *http.Request) { + owner, name := strings.TrimSpace(r.URL.Query().Get("owner")), strings.TrimSpace(r.URL.Query().Get("repo")) + if !validSlug(owner) || !validSlug(name) { + writeError(w, http.StatusBadRequest, "owner and repo are required") + return + } + allowed, err := s.gitea.ViewerCanReadRepository(r.Context(), r.Header.Get("Cookie"), owner, name) + if err != nil { + s.logger.Error("check prompt viewer access", "owner", owner, "name", name, "error", err) + writeError(w, http.StatusBadGateway, "could not verify repository access") + return + } + if !allowed { + writeError(w, http.StatusUnauthorized, "sign in to view this repository's prompts") + return + } + prompts, err := s.store.ListPrompts(r.Context(), owner, name, 100) + if err != nil { + s.logger.Error("list prompts", "owner", owner, "name", name, "error", err) + writeError(w, http.StatusInternalServerError, "could not list prompts") + return + } + writeJSON(w, http.StatusOK, map[string]any{"prompts": prompts}) +} + +func (s *server) createPrompt(w http.ResponseWriter, r *http.Request) { + owner, name := r.PathValue("owner"), r.PathValue("name") + if !validSlug(owner) || !validSlug(name) { + writeError(w, http.StatusBadRequest, "owner and name are required") + return + } + var input struct { + TaskID string `json:"task_id"` + AttemptID string `json:"attempt_id"` + StateID string `json:"state_id"` + Prompt string `json:"prompt"` + AgentName string `json:"agent_name"` + AgentModel string `json:"agent_model"` + Status string `json:"status"` + ResponseSummary string `json:"response_summary"` + Metadata json.RawMessage `json:"metadata"` + CompletedAt *time.Time `json:"completed_at"` + } + if err := decodeJSON(w, r, &input); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + input.Prompt = strings.TrimSpace(input.Prompt) + if input.Prompt == "" || len(input.Prompt) > 32<<10 { + writeError(w, http.StatusBadRequest, "prompt is required and must be at most 32 KiB") + return + } + if input.Status == "" { + input.Status = "running" + } + switch input.Status { + case "running", "completed", "failed", "cancelled": + default: + writeError(w, http.StatusBadRequest, "invalid prompt status") + return + } + if len(input.Metadata) > 16<<10 || (len(input.Metadata) > 0 && !json.Valid(input.Metadata)) { + writeError(w, http.StatusBadRequest, "metadata must be valid JSON and at most 16 KiB") + return + } + saved, err := s.store.CreatePrompt(r.Context(), store.CreatePromptInput{ + Owner: owner, Repository: name, TaskID: input.TaskID, AttemptID: input.AttemptID, StateID: input.StateID, + Prompt: input.Prompt, AgentName: input.AgentName, AgentModel: input.AgentModel, Status: input.Status, + ResponseSummary: input.ResponseSummary, Metadata: input.Metadata, CompletedAt: input.CompletedAt, + }) + if err != nil { + s.logger.Error("create prompt", "owner", owner, "name", name, "error", err) + writeError(w, http.StatusInternalServerError, "could not create prompt") + return + } + writeJSON(w, http.StatusCreated, saved) +} + func (s *server) health(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } diff --git a/services/control-plane/internal/httpapi/server_test.go b/services/control-plane/internal/httpapi/server_test.go index 915f5ec..bafb090 100644 --- a/services/control-plane/internal/httpapi/server_test.go +++ b/services/control-plane/internal/httpapi/server_test.go @@ -19,6 +19,7 @@ import ( type fakeStore struct { repositories []store.Repository + prompts []store.Prompt delivery store.WebhookDelivery duplicate bool } @@ -32,19 +33,31 @@ func (f *fakeStore) UpsertRepository(_ context.Context, repository gitea.Reposit f.repositories = append(f.repositories, saved) return saved, nil } +func (f *fakeStore) ListPrompts(context.Context, string, string, int) ([]store.Prompt, error) { + return f.prompts, nil +} +func (f *fakeStore) CreatePrompt(_ context.Context, input store.CreatePromptInput) (store.Prompt, error) { + prompt := store.Prompt{ID: "PM_TEST", Prompt: input.Prompt, Status: input.Status, AgentName: input.AgentName, AgentModel: input.AgentModel, Metadata: input.Metadata} + f.prompts = append(f.prompts, prompt) + return prompt, nil +} func (f *fakeStore) RecordWebhook(_ context.Context, delivery store.WebhookDelivery) (bool, error) { f.delivery = delivery return f.duplicate, nil } type fakeGitea struct { - repository gitea.Repository - err error + repository gitea.Repository + err error + viewerAllowed bool } func (f fakeGitea) Repository(context.Context, string, string) (gitea.Repository, error) { return f.repository, f.err } +func (f fakeGitea) ViewerCanReadRepository(context.Context, string, string, string) (bool, error) { + return f.viewerAllowed, f.err +} func testServer(data store.Store, client giteaClient) http.Handler { return New(data, client, "admin-secret", "webhook-secret", slog.New(slog.NewTextHandler(io.Discard, nil))) @@ -114,3 +127,43 @@ func TestWebhookRejectsInvalidSignature(t *testing.T) { t.Fatalf("status = %d, want %d", response.Code, http.StatusUnauthorized) } } + +func TestListPromptsRequiresGiteaSession(t *testing.T) { + data := &fakeStore{prompts: []store.Prompt{{ID: "PM_TEST", Prompt: "Ship the prompt view", Status: "completed"}}} + handler := testServer(data, fakeGitea{viewerAllowed: true}) + req := httptest.NewRequest(http.MethodGet, "/api/v1/prompts?owner=hop&repo=demo", nil) + req.Header.Set("Cookie", "i_like_gitea=session") + response := httptest.NewRecorder() + handler.ServeHTTP(response, req) + if response.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusOK, response.Body.String()) + } + if !bytes.Contains(response.Body.Bytes(), []byte("Ship the prompt view")) { + t.Fatalf("prompt missing from response: %s", response.Body.String()) + } +} + +func TestListPromptsRejectsUnauthenticatedViewer(t *testing.T) { + handler := testServer(&fakeStore{}, fakeGitea{}) + req := httptest.NewRequest(http.MethodGet, "/api/v1/prompts?owner=hop&repo=demo", nil) + response := httptest.NewRecorder() + handler.ServeHTTP(response, req) + if response.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusUnauthorized, response.Body.String()) + } +} + +func TestCreatePrompt(t *testing.T) { + data := &fakeStore{} + handler := testServer(data, fakeGitea{}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/repositories/hop/demo/prompts", bytes.NewBufferString(`{"prompt":"Ship the prompt view","agent_name":"codex","status":"completed","metadata":{"duration_ms":1200}}`)) + req.Header.Set("Authorization", "Bearer admin-secret") + response := httptest.NewRecorder() + handler.ServeHTTP(response, req) + if response.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusCreated, response.Body.String()) + } + if len(data.prompts) != 1 || data.prompts[0].Prompt != "Ship the prompt view" { + t.Fatalf("prompt not created: %#v", data.prompts) + } +} diff --git a/services/control-plane/internal/ids/ids.go b/services/control-plane/internal/ids/ids.go index 4c4ddb8..795cb77 100644 --- a/services/control-plane/internal/ids/ids.go +++ b/services/control-plane/internal/ids/ids.go @@ -9,6 +9,14 @@ import ( var encoding = base32.NewEncoding("0123456789ABCDEFGHJKMNPQRSTVWXYZ").WithPadding(base32.NoPadding) func Repository() (string, error) { + return newID("RP_") +} + +func Prompt() (string, error) { + return newID("PM_") +} + +func newID(prefix string) (string, error) { value := make([]byte, 16) milliseconds := uint64(time.Now().UnixMilli()) value[0] = byte(milliseconds >> 40) @@ -20,5 +28,5 @@ func Repository() (string, error) { if _, err := rand.Read(value[6:]); err != nil { return "", err } - return "RP_" + encoding.EncodeToString(value), nil + return prefix + encoding.EncodeToString(value), nil } diff --git a/services/control-plane/internal/ids/ids_test.go b/services/control-plane/internal/ids/ids_test.go index 10622ff..6fa8970 100644 --- a/services/control-plane/internal/ids/ids_test.go +++ b/services/control-plane/internal/ids/ids_test.go @@ -21,3 +21,13 @@ func TestRepository(t *testing.T) { t.Fatal("generated duplicate IDs") } } + +func TestPrompt(t *testing.T) { + id, err := Prompt() + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(id, "PM_") || len(id) != 29 { + t.Fatalf("unexpected prompt ID %q", id) + } +} diff --git a/services/control-plane/internal/store/migrations/002_prompts.sql b/services/control-plane/internal/store/migrations/002_prompts.sql new file mode 100644 index 0000000..50d3685 --- /dev/null +++ b/services/control-plane/internal/store/migrations/002_prompts.sql @@ -0,0 +1,17 @@ +CREATE TABLE prompts ( + id text PRIMARY KEY, + repository_id text NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + task_id text NOT NULL DEFAULT '', + attempt_id text NOT NULL DEFAULT '', + state_id text NOT NULL DEFAULT '', + prompt text NOT NULL, + agent_name text NOT NULL DEFAULT '', + agent_model text NOT NULL DEFAULT '', + status text NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'completed', 'failed', 'cancelled')), + response_summary text NOT NULL DEFAULT '', + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + completed_at timestamptz +); + +CREATE INDEX prompts_repository_created_at_idx ON prompts (repository_id, created_at DESC); diff --git a/services/control-plane/internal/store/store.go b/services/control-plane/internal/store/store.go index d080a2e..ca47f7e 100644 --- a/services/control-plane/internal/store/store.go +++ b/services/control-plane/internal/store/store.go @@ -3,6 +3,7 @@ package store import ( "context" "embed" + "encoding/json" "fmt" "sort" "time" @@ -37,10 +38,42 @@ type WebhookDelivery struct { Repository *gitea.Repository } +type Prompt struct { + ID string `json:"id"` + TaskID string `json:"task_id,omitempty"` + AttemptID string `json:"attempt_id,omitempty"` + StateID string `json:"state_id,omitempty"` + Prompt string `json:"prompt"` + AgentName string `json:"agent_name,omitempty"` + AgentModel string `json:"agent_model,omitempty"` + Status string `json:"status"` + ResponseSummary string `json:"response_summary,omitempty"` + Metadata json.RawMessage `json:"metadata"` + CreatedAt time.Time `json:"created_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` +} + +type CreatePromptInput struct { + Owner string + Repository string + TaskID string + AttemptID string + StateID string + Prompt string + AgentName string + AgentModel string + Status string + ResponseSummary string + Metadata json.RawMessage + CompletedAt *time.Time +} + type Store interface { Ping(context.Context) error ListRepositories(context.Context) ([]Repository, error) UpsertRepository(context.Context, gitea.Repository) (Repository, error) + ListPrompts(context.Context, string, string, int) ([]Prompt, error) + CreatePrompt(context.Context, CreatePromptInput) (Prompt, error) RecordWebhook(context.Context, WebhookDelivery) (bool, error) } @@ -129,6 +162,53 @@ func (p *Postgres) UpsertRepository(ctx context.Context, repository gitea.Reposi return upsertRepository(ctx, p.pool, id, repository) } +func (p *Postgres) ListPrompts(ctx context.Context, owner, name string, limit int) ([]Prompt, error) { + rows, err := p.pool.Query(ctx, ` + SELECT p.id, p.task_id, p.attempt_id, p.state_id, p.prompt, p.agent_name, p.agent_model, + p.status, p.response_summary, p.metadata, p.created_at, p.completed_at + FROM prompts p + JOIN repositories r ON r.id = p.repository_id + WHERE r.owner=$1 AND r.name=$2 + ORDER BY p.created_at DESC + LIMIT $3`, owner, name, limit) + if err != nil { + return nil, err + } + defer rows.Close() + prompts := make([]Prompt, 0) + for rows.Next() { + var prompt Prompt + if err := rows.Scan(&prompt.ID, &prompt.TaskID, &prompt.AttemptID, &prompt.StateID, &prompt.Prompt, &prompt.AgentName, &prompt.AgentModel, &prompt.Status, &prompt.ResponseSummary, &prompt.Metadata, &prompt.CreatedAt, &prompt.CompletedAt); err != nil { + return nil, err + } + prompts = append(prompts, prompt) + } + return prompts, rows.Err() +} + +func (p *Postgres) CreatePrompt(ctx context.Context, input CreatePromptInput) (Prompt, error) { + id, err := ids.Prompt() + if err != nil { + return Prompt{}, err + } + metadata := input.Metadata + if len(metadata) == 0 { + metadata = json.RawMessage(`{}`) + } + var prompt Prompt + err = p.pool.QueryRow(ctx, ` + INSERT INTO prompts (id, repository_id, task_id, attempt_id, state_id, prompt, agent_name, agent_model, status, response_summary, metadata, completed_at) + SELECT $1, r.id, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 + FROM repositories r WHERE r.owner=$2 AND r.name=$3 + RETURNING id, task_id, attempt_id, state_id, prompt, agent_name, agent_model, status, response_summary, metadata, created_at, completed_at`, + id, input.Owner, input.Repository, input.TaskID, input.AttemptID, input.StateID, input.Prompt, input.AgentName, input.AgentModel, input.Status, input.ResponseSummary, metadata, input.CompletedAt, + ).Scan(&prompt.ID, &prompt.TaskID, &prompt.AttemptID, &prompt.StateID, &prompt.Prompt, &prompt.AgentName, &prompt.AgentModel, &prompt.Status, &prompt.ResponseSummary, &prompt.Metadata, &prompt.CreatedAt, &prompt.CompletedAt) + if err != nil { + return Prompt{}, err + } + return prompt, nil +} + type querier interface { QueryRow(context.Context, string, ...any) pgx.Row }