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
- 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.
+3
View File
@@ -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
@@ -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;
}
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';
+2 -1
View File
@@ -1,2 +1,3 @@
<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 |
| --- | --- |
| 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
+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
`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.
@@ -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)
}
}
@@ -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)
}
}
@@ -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"})
}
@@ -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)
}
}
+9 -1
View File
@@ -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
}
@@ -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)
}
}
@@ -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 (
"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
}