Add a secure repository Prompts review surface and prompt metadata API

Hop-State: A_06FN6MRSHG4B64ZV13086G8
Hop-Proposal: R_06FN6MQDE14FFNR65H1D28G
Hop-Task: T_06FN6J8ECE1Z9XQ5PQ3Z3D8
Hop-Attempt: AT_06FN6J8ECC1SC794JMZ0TF8
This commit is contained in:
Hop
2026-07-11 15:36:22 -07:00
parent c0c1161107
commit fee4f6dd31
15 changed files with 709 additions and 8 deletions
+4 -1
View File
@@ -13,6 +13,10 @@ but makes Hop's workflow the primary product model:
- a **proposal** is a reviewable candidate outcome; and - a **proposal** is a reviewable candidate outcome; and
- an **accepted state** is the current shared truth. - 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: The product thesis is simple:
> GitHub organizes collaboration around commits and pull requests. HopWeb > 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 Everything else should be inherited from Gitea until the Hop workflow proves
that it needs a different abstraction. that it needs a different abstraction.
+3
View File
@@ -29,6 +29,9 @@ docker compose --env-file "$env_file" cp \
docker compose --env-file "$env_file" cp \ docker compose --env-file "$env_file" cp \
deploy/gitea/public/assets/css/hop-home.css \ deploy/gitea/public/assets/css/hop-home.css \
gitea:/data/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 \ docker compose --env-file "$env_file" cp \
deploy/gitea/public/assets/img/hop.svg \ deploy/gitea/public/assets/img/hop.svg \
gitea:/data/gitea/public/assets/img/hop.svg gitea:/data/gitea/public/assets/img/hop.svg
@@ -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; }
}
+167
View File
@@ -90,6 +90,44 @@
}) || null; }) || 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) { function workflowLink(source, label) {
if (!source) return null; if (!source) return null;
const link = document.createElement('a'); const link = document.createElement('a');
@@ -111,6 +149,7 @@
if (!sidebar || sidebar.querySelector('.hop-native-workflow')) return; if (!sidebar || sidebar.querySelector('.hop-native-workflow')) return;
const links = [ const links = [
workflowLink(document.querySelector('.hop-native-prompts-tab'), 'Prompts'),
workflowLink(navigationLink('/issues'), 'Tasks'), workflowLink(navigationLink('/issues'), 'Tasks'),
workflowLink(navigationLink('/pulls'), 'Proposals'), workflowLink(navigationLink('/pulls'), 'Proposals'),
workflowLink(navigationLink('/actions'), 'Evidence'), workflowLink(navigationLink('/actions'), 'Evidence'),
@@ -140,6 +179,132 @@
sidebar.prepend(list); 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() { function replaceBrandImages() {
const upstreamBrandAsset = /\/img\/(?:avatar_default\.png|logo\.png|logo\.svg)(?:\?.*)?$/; const upstreamBrandAsset = /\/img\/(?:avatar_default\.png|logo\.png|logo\.svg)(?:\?.*)?$/;
for (const image of document.querySelectorAll('img[src]')) { for (const image of document.querySelectorAll('img[src]')) {
@@ -154,7 +319,9 @@
replaceBrandImages(); replaceBrandImages();
for (const root of document.querySelectorAll(semanticRoots)) replaceExactText(root); for (const root of document.querySelectorAll(semanticRoots)) replaceExactText(root);
replaceAccessibleLabels(document); replaceAccessibleLabels(document);
addPromptsNavigation();
addWorkflowSummary(); addWorkflowSummary();
renderPromptPage();
const title = replacePhrases(document.title); const title = replacePhrases(document.title);
if (title !== document.title) document.title = title; if (title !== document.title) document.title = title;
document.documentElement.dataset.hopNative = 'true'; document.documentElement.dataset.hopNative = 'true';
+2 -1
View File
@@ -1,2 +1,3 @@
<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/hop-home.css?v=4"> <link rel="stylesheet" href="{{AssetUrlPrefix}}/css/hop-home.css?v=4">
<script src="{{AssetUrlPrefix}}/js/hop-native.js?v=8"></script> <link rel="stylesheet" href="{{AssetUrlPrefix}}/css/hop-prompts.css?v=1">
<script src="{{AssetUrlPrefix}}/js/hop-native.js?v=9"></script>
+2 -1
View File
@@ -27,6 +27,7 @@ asset or replacing the Gitea volume.
| Native route and behavior | Hop label | | Native route and behavior | Hop label |
| --- | --- | | --- | --- |
| Repository code | Files | | Repository code | Files |
| — | Prompts (Hop-native causal record) |
| Issues | Tasks | | Issues | Tasks |
| Branches | Attempts | | Branches | Attempts |
| Commits | Checkpoints | | 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: After changing the pinned Gitea version:
1. run the installer; 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; 3. confirm Attempts and Checkpoints in the Files view;
4. confirm proposal creation and acceptance labels; 4. confirm proposal creation and acceptance labels;
5. inspect browser console errors; and 5. inspect browser console errors; and
+17 -2
View File
@@ -27,5 +27,20 @@ docker compose --env-file .env up --build -d
The reverse proxy should forward the public hostname to the configured The reverse proxy should forward the public hostname to the configured
`GITEA_HTTP_BIND:GITEA_HTTP_PORT`, preserve the `Host` header, and set `GITEA_HTTP_BIND:GITEA_HTTP_PORT`, preserve the `Host` header, and set
`X-Forwarded-Proto` to `https`. Persistent repository and database data live in `X-Forwarded-Proto` to `https`. It must also proxy `/hop/` to the Hop control
the named Compose volumes. 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.
@@ -78,3 +78,33 @@ func (c *Client) Repository(ctx context.Context, owner, name string) (Repository
} }
return repo, nil 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)
}
}
@@ -42,3 +42,41 @@ func TestRepositoryRequiresToken(t *testing.T) {
t.Fatalf("error = %v, want %v", err, ErrTokenNotConfigured) 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)
}
}
@@ -21,6 +21,7 @@ const maxWebhookBody = 2 << 20
type giteaClient interface { type giteaClient interface {
Repository(context.Context, string, string) (gitea.Repository, error) Repository(context.Context, string, string) (gitea.Repository, error)
ViewerCanReadRepository(context.Context, string, string, string) (bool, error)
} }
type server struct { type server struct {
@@ -38,10 +39,90 @@ func New(data store.Store, client giteaClient, adminToken, webhookSecret string,
mux.HandleFunc("GET /readyz", s.ready) mux.HandleFunc("GET /readyz", s.ready)
mux.Handle("GET /api/v1/repositories", s.requireAdmin(http.HandlerFunc(s.listRepositories))) 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("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) mux.HandleFunc("POST /api/v1/gitea/webhooks", s.giteaWebhook)
return requestLogger(logger, mux) 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) { func (s *server) health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
} }
@@ -19,6 +19,7 @@ import (
type fakeStore struct { type fakeStore struct {
repositories []store.Repository repositories []store.Repository
prompts []store.Prompt
delivery store.WebhookDelivery delivery store.WebhookDelivery
duplicate bool duplicate bool
} }
@@ -32,19 +33,31 @@ func (f *fakeStore) UpsertRepository(_ context.Context, repository gitea.Reposit
f.repositories = append(f.repositories, saved) f.repositories = append(f.repositories, saved)
return saved, nil 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) { func (f *fakeStore) RecordWebhook(_ context.Context, delivery store.WebhookDelivery) (bool, error) {
f.delivery = delivery f.delivery = delivery
return f.duplicate, nil return f.duplicate, nil
} }
type fakeGitea struct { type fakeGitea struct {
repository gitea.Repository repository gitea.Repository
err error err error
viewerAllowed bool
} }
func (f fakeGitea) Repository(context.Context, string, string) (gitea.Repository, error) { func (f fakeGitea) Repository(context.Context, string, string) (gitea.Repository, error) {
return f.repository, f.err 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 { func testServer(data store.Store, client giteaClient) http.Handler {
return New(data, client, "admin-secret", "webhook-secret", slog.New(slog.NewTextHandler(io.Discard, nil))) 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) 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)
}
}
+9 -1
View File
@@ -9,6 +9,14 @@ import (
var encoding = base32.NewEncoding("0123456789ABCDEFGHJKMNPQRSTVWXYZ").WithPadding(base32.NoPadding) var encoding = base32.NewEncoding("0123456789ABCDEFGHJKMNPQRSTVWXYZ").WithPadding(base32.NoPadding)
func Repository() (string, error) { func Repository() (string, error) {
return newID("RP_")
}
func Prompt() (string, error) {
return newID("PM_")
}
func newID(prefix string) (string, error) {
value := make([]byte, 16) value := make([]byte, 16)
milliseconds := uint64(time.Now().UnixMilli()) milliseconds := uint64(time.Now().UnixMilli())
value[0] = byte(milliseconds >> 40) value[0] = byte(milliseconds >> 40)
@@ -20,5 +28,5 @@ func Repository() (string, error) {
if _, err := rand.Read(value[6:]); err != nil { if _, err := rand.Read(value[6:]); err != nil {
return "", err return "", err
} }
return "RP_" + encoding.EncodeToString(value), nil return prefix + encoding.EncodeToString(value), nil
} }
@@ -21,3 +21,13 @@ func TestRepository(t *testing.T) {
t.Fatal("generated duplicate IDs") 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)
}
}
@@ -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);
@@ -3,6 +3,7 @@ package store
import ( import (
"context" "context"
"embed" "embed"
"encoding/json"
"fmt" "fmt"
"sort" "sort"
"time" "time"
@@ -37,10 +38,42 @@ type WebhookDelivery struct {
Repository *gitea.Repository 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 { type Store interface {
Ping(context.Context) error Ping(context.Context) error
ListRepositories(context.Context) ([]Repository, error) ListRepositories(context.Context) ([]Repository, error)
UpsertRepository(context.Context, gitea.Repository) (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) 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) 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 { type querier interface {
QueryRow(context.Context, string, ...any) pgx.Row QueryRow(context.Context, string, ...any) pgx.Row
} }