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:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user