Restore private prompt history with idempotent user-scoped imports
Hop-State: A_06FNBBSMHQ9HVEQCC70EPA8 Hop-Proposal: R_06FNBBRHSSJTCFN79G3KWJ0 Hop-Task: T_06FNB93F0NJANQV08BA6VAG Hop-Attempt: AT_06FNB93F0PSZWAGR81JCWBG
This commit is contained in:
@@ -91,6 +91,7 @@ func (s *server) createPrompt(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
var input struct {
|
var input struct {
|
||||||
UserLogin string `json:"user_login"`
|
UserLogin string `json:"user_login"`
|
||||||
|
GiteaUserID int64 `json:"gitea_user_id"`
|
||||||
TaskID string `json:"task_id"`
|
TaskID string `json:"task_id"`
|
||||||
AttemptID string `json:"attempt_id"`
|
AttemptID string `json:"attempt_id"`
|
||||||
StateID string `json:"state_id"`
|
StateID string `json:"state_id"`
|
||||||
@@ -100,6 +101,7 @@ func (s *server) createPrompt(w http.ResponseWriter, r *http.Request) {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
ResponseSummary string `json:"response_summary"`
|
ResponseSummary string `json:"response_summary"`
|
||||||
Metadata json.RawMessage `json:"metadata"`
|
Metadata json.RawMessage `json:"metadata"`
|
||||||
|
CreatedAt *time.Time `json:"created_at"`
|
||||||
CompletedAt *time.Time `json:"completed_at"`
|
CompletedAt *time.Time `json:"completed_at"`
|
||||||
}
|
}
|
||||||
if err := decodeJSON(w, r, &input); err != nil {
|
if err := decodeJSON(w, r, &input); err != nil {
|
||||||
@@ -107,8 +109,13 @@ func (s *server) createPrompt(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
input.UserLogin = strings.TrimSpace(input.UserLogin)
|
input.UserLogin = strings.TrimSpace(input.UserLogin)
|
||||||
if !validSlug(input.UserLogin) {
|
if input.GiteaUserID < 0 || (input.GiteaUserID == 0 && !validSlug(input.UserLogin)) || (input.GiteaUserID > 0 && input.UserLogin != "") {
|
||||||
writeError(w, http.StatusBadRequest, "user_login is required")
|
writeError(w, http.StatusBadRequest, "exactly one of gitea_user_id or user_login is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
input.StateID = strings.TrimSpace(input.StateID)
|
||||||
|
if input.StateID == "" || len(input.StateID) > 128 {
|
||||||
|
writeError(w, http.StatusBadRequest, "state_id is required and must be at most 128 characters")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
input.Prompt = strings.TrimSpace(input.Prompt)
|
input.Prompt = strings.TrimSpace(input.Prompt)
|
||||||
@@ -129,6 +136,8 @@ func (s *server) createPrompt(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "metadata must be valid JSON and at most 16 KiB")
|
writeError(w, http.StatusBadRequest, "metadata must be valid JSON and at most 16 KiB")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
promptOwnerID := input.GiteaUserID
|
||||||
|
if promptOwnerID == 0 {
|
||||||
promptOwner, err := s.gitea.User(r.Context(), input.UserLogin)
|
promptOwner, err := s.gitea.User(r.Context(), input.UserLogin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gitea.ErrTokenNotConfigured) {
|
if errors.Is(err, gitea.ErrTokenNotConfigured) {
|
||||||
@@ -139,11 +148,13 @@ func (s *server) createPrompt(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadGateway, "could not resolve prompt owner")
|
writeError(w, http.StatusBadGateway, "could not resolve prompt owner")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
promptOwnerID = promptOwner.ID
|
||||||
|
}
|
||||||
saved, err := s.store.CreatePrompt(r.Context(), store.CreatePromptInput{
|
saved, err := s.store.CreatePrompt(r.Context(), store.CreatePromptInput{
|
||||||
Owner: owner, Repository: name, TaskID: input.TaskID, AttemptID: input.AttemptID, StateID: input.StateID,
|
Owner: owner, Repository: name, TaskID: input.TaskID, AttemptID: input.AttemptID, StateID: input.StateID,
|
||||||
GiteaUserID: promptOwner.ID,
|
GiteaUserID: promptOwnerID,
|
||||||
Prompt: input.Prompt, AgentName: input.AgentName, AgentModel: input.AgentModel, Status: input.Status,
|
Prompt: input.Prompt, AgentName: input.AgentName, AgentModel: input.AgentModel, Status: input.Status,
|
||||||
ResponseSummary: input.ResponseSummary, Metadata: input.Metadata, CompletedAt: input.CompletedAt,
|
ResponseSummary: input.ResponseSummary, Metadata: input.Metadata, CreatedAt: input.CreatedAt, CompletedAt: input.CompletedAt,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("create prompt", "owner", owner, "name", name, "error", err)
|
s.logger.Error("create prompt", "owner", owner, "name", name, "error", err)
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ func TestListPromptsRejectsUnauthenticatedViewer(t *testing.T) {
|
|||||||
func TestCreatePrompt(t *testing.T) {
|
func TestCreatePrompt(t *testing.T) {
|
||||||
data := &fakeStore{}
|
data := &fakeStore{}
|
||||||
handler := testServer(data, fakeGitea{user: gitea.User{ID: 42, Login: "alice"}})
|
handler := testServer(data, fakeGitea{user: gitea.User{ID: 42, Login: "alice"}})
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/repositories/hop/demo/prompts", bytes.NewBufferString(`{"user_login":"alice","prompt":"Ship the prompt view","agent_name":"codex","status":"completed","metadata":{"duration_ms":1200}}`))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/repositories/hop/demo/prompts", bytes.NewBufferString(`{"user_login":"alice","state_id":"P_TEST","prompt":"Ship the prompt view","agent_name":"codex","status":"completed","metadata":{"duration_ms":1200}}`))
|
||||||
req.Header.Set("Authorization", "Bearer admin-secret")
|
req.Header.Set("Authorization", "Bearer admin-secret")
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(response, req)
|
handler.ServeHTTP(response, req)
|
||||||
@@ -184,9 +184,46 @@ func TestCreatePrompt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreatePromptRequiresOwner(t *testing.T) {
|
func TestCreatePromptWithImmutableGiteaUserID(t *testing.T) {
|
||||||
|
data := &fakeStore{}
|
||||||
|
handler := testServer(data, fakeGitea{err: errors.New("user lookup must not run")})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/repositories/hop/demo/prompts", bytes.NewBufferString(`{"gitea_user_id":42,"state_id":"P_TEST","prompt":"Restore my prompt history","status":"completed","created_at":"2026-07-11T19:26:01Z"}`))
|
||||||
|
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].GiteaUserID != 42 {
|
||||||
|
t.Fatalf("prompt not attributed to immutable user ID: %#v", data.prompts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePromptRejectsAmbiguousOwner(t *testing.T) {
|
||||||
handler := testServer(&fakeStore{}, fakeGitea{})
|
handler := testServer(&fakeStore{}, fakeGitea{})
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/repositories/hop/demo/prompts", bytes.NewBufferString(`{"prompt":"private work","status":"completed"}`))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/repositories/hop/demo/prompts", bytes.NewBufferString(`{"gitea_user_id":42,"user_login":"alice","prompt":"private work","status":"completed"}`))
|
||||||
|
req.Header.Set("Authorization", "Bearer admin-secret")
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, req)
|
||||||
|
if response.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusBadRequest, response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePromptRequiresOwner(t *testing.T) {
|
||||||
|
handler := testServer(&fakeStore{}, fakeGitea{})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/repositories/hop/demo/prompts", bytes.NewBufferString(`{"state_id":"P_TEST","prompt":"private work","status":"completed"}`))
|
||||||
|
req.Header.Set("Authorization", "Bearer admin-secret")
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, req)
|
||||||
|
if response.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusBadRequest, response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePromptRequiresStateID(t *testing.T) {
|
||||||
|
handler := testServer(&fakeStore{}, fakeGitea{})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/repositories/hop/demo/prompts", bytes.NewBufferString(`{"gitea_user_id":42,"prompt":"private work","status":"completed"}`))
|
||||||
req.Header.Set("Authorization", "Bearer admin-secret")
|
req.Header.Set("Authorization", "Bearer admin-secret")
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(response, req)
|
handler.ServeHTTP(response, req)
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
CREATE UNIQUE INDEX prompts_repository_user_state_idx
|
||||||
|
ON prompts (repository_id, gitea_user_id, state_id)
|
||||||
|
WHERE gitea_user_id IS NOT NULL AND state_id <> '';
|
||||||
@@ -67,6 +67,7 @@ type CreatePromptInput struct {
|
|||||||
Status string
|
Status string
|
||||||
ResponseSummary string
|
ResponseSummary string
|
||||||
Metadata json.RawMessage
|
Metadata json.RawMessage
|
||||||
|
CreatedAt *time.Time
|
||||||
CompletedAt *time.Time
|
CompletedAt *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,11 +200,18 @@ func (p *Postgres) CreatePrompt(ctx context.Context, input CreatePromptInput) (P
|
|||||||
}
|
}
|
||||||
var prompt Prompt
|
var prompt Prompt
|
||||||
err = p.pool.QueryRow(ctx, `
|
err = p.pool.QueryRow(ctx, `
|
||||||
INSERT INTO prompts (id, repository_id, gitea_user_id, task_id, attempt_id, state_id, prompt, agent_name, agent_model, status, response_summary, metadata, completed_at)
|
INSERT INTO prompts (id, repository_id, gitea_user_id, task_id, attempt_id, state_id, prompt, agent_name, agent_model, status, response_summary, metadata, created_at, completed_at)
|
||||||
SELECT $1, r.id, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
|
SELECT $1, r.id, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, COALESCE($14, now()), $15
|
||||||
FROM repositories r WHERE r.owner=$2 AND r.name=$3
|
FROM repositories r WHERE r.owner=$2 AND r.name=$3
|
||||||
|
ON CONFLICT (repository_id, gitea_user_id, state_id)
|
||||||
|
WHERE gitea_user_id IS NOT NULL AND state_id <> ''
|
||||||
|
DO UPDATE SET
|
||||||
|
task_id=EXCLUDED.task_id, attempt_id=EXCLUDED.attempt_id,
|
||||||
|
prompt=EXCLUDED.prompt, agent_name=EXCLUDED.agent_name, agent_model=EXCLUDED.agent_model,
|
||||||
|
status=EXCLUDED.status, response_summary=EXCLUDED.response_summary, metadata=EXCLUDED.metadata,
|
||||||
|
created_at=EXCLUDED.created_at, completed_at=EXCLUDED.completed_at
|
||||||
RETURNING id, gitea_user_id, task_id, attempt_id, state_id, prompt, agent_name, agent_model, status, response_summary, metadata, created_at, completed_at`,
|
RETURNING id, gitea_user_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.GiteaUserID, input.TaskID, input.AttemptID, input.StateID, input.Prompt, input.AgentName, input.AgentModel, input.Status, input.ResponseSummary, metadata, input.CompletedAt,
|
id, input.Owner, input.Repository, input.GiteaUserID, input.TaskID, input.AttemptID, input.StateID, input.Prompt, input.AgentName, input.AgentModel, input.Status, input.ResponseSummary, metadata, input.CreatedAt, input.CompletedAt,
|
||||||
).Scan(&prompt.ID, &prompt.GiteaUserID, &prompt.TaskID, &prompt.AttemptID, &prompt.StateID, &prompt.Prompt, &prompt.AgentName, &prompt.AgentModel, &prompt.Status, &prompt.ResponseSummary, &prompt.Metadata, &prompt.CreatedAt, &prompt.CompletedAt)
|
).Scan(&prompt.ID, &prompt.GiteaUserID, &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 {
|
if err != nil {
|
||||||
return Prompt{}, err
|
return Prompt{}, err
|
||||||
|
|||||||
Reference in New Issue
Block a user