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
}