From 65da71bb807782b89cc84ef9e495fbe10ad9e1fc Mon Sep 17 00:00:00 2001 From: cyph3rasi Date: Sun, 12 Jul 2026 02:36:13 -0700 Subject: [PATCH] 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 --- .../control-plane/internal/httpapi/server.go | 33 +++++++++----- .../internal/httpapi/server_test.go | 43 +++++++++++++++++-- .../store/migrations/004_prompt_identity.sql | 3 ++ .../control-plane/internal/store/store.go | 14 ++++-- 4 files changed, 76 insertions(+), 17 deletions(-) create mode 100644 services/control-plane/internal/store/migrations/004_prompt_identity.sql diff --git a/services/control-plane/internal/httpapi/server.go b/services/control-plane/internal/httpapi/server.go index d6ea212..3f02487 100644 --- a/services/control-plane/internal/httpapi/server.go +++ b/services/control-plane/internal/httpapi/server.go @@ -91,6 +91,7 @@ func (s *server) createPrompt(w http.ResponseWriter, r *http.Request) { } var input struct { UserLogin string `json:"user_login"` + GiteaUserID int64 `json:"gitea_user_id"` TaskID string `json:"task_id"` AttemptID string `json:"attempt_id"` StateID string `json:"state_id"` @@ -100,6 +101,7 @@ func (s *server) createPrompt(w http.ResponseWriter, r *http.Request) { Status string `json:"status"` ResponseSummary string `json:"response_summary"` Metadata json.RawMessage `json:"metadata"` + CreatedAt *time.Time `json:"created_at"` CompletedAt *time.Time `json:"completed_at"` } if err := decodeJSON(w, r, &input); err != nil { @@ -107,8 +109,13 @@ func (s *server) createPrompt(w http.ResponseWriter, r *http.Request) { return } input.UserLogin = strings.TrimSpace(input.UserLogin) - if !validSlug(input.UserLogin) { - writeError(w, http.StatusBadRequest, "user_login is required") + if input.GiteaUserID < 0 || (input.GiteaUserID == 0 && !validSlug(input.UserLogin)) || (input.GiteaUserID > 0 && input.UserLogin != "") { + 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 } input.Prompt = strings.TrimSpace(input.Prompt) @@ -129,21 +136,25 @@ func (s *server) createPrompt(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "metadata must be valid JSON and at most 16 KiB") return } - promptOwner, err := s.gitea.User(r.Context(), input.UserLogin) - if err != nil { - if errors.Is(err, gitea.ErrTokenNotConfigured) { - writeError(w, http.StatusServiceUnavailable, err.Error()) + promptOwnerID := input.GiteaUserID + if promptOwnerID == 0 { + promptOwner, err := s.gitea.User(r.Context(), input.UserLogin) + if err != nil { + if errors.Is(err, gitea.ErrTokenNotConfigured) { + writeError(w, http.StatusServiceUnavailable, err.Error()) + return + } + s.logger.Error("resolve prompt owner", "user_login", input.UserLogin, "error", err) + writeError(w, http.StatusBadGateway, "could not resolve prompt owner") return } - s.logger.Error("resolve prompt owner", "user_login", input.UserLogin, "error", err) - writeError(w, http.StatusBadGateway, "could not resolve prompt owner") - return + promptOwnerID = promptOwner.ID } saved, err := s.store.CreatePrompt(r.Context(), store.CreatePromptInput{ 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, - ResponseSummary: input.ResponseSummary, Metadata: input.Metadata, CompletedAt: input.CompletedAt, + ResponseSummary: input.ResponseSummary, Metadata: input.Metadata, CreatedAt: input.CreatedAt, CompletedAt: input.CompletedAt, }) if err != nil { s.logger.Error("create prompt", "owner", owner, "name", name, "error", err) diff --git a/services/control-plane/internal/httpapi/server_test.go b/services/control-plane/internal/httpapi/server_test.go index 50a39f5..d4ea157 100644 --- a/services/control-plane/internal/httpapi/server_test.go +++ b/services/control-plane/internal/httpapi/server_test.go @@ -169,7 +169,7 @@ func TestListPromptsRejectsUnauthenticatedViewer(t *testing.T) { func TestCreatePrompt(t *testing.T) { data := &fakeStore{} 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") response := httptest.NewRecorder() 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{}) - 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") response := httptest.NewRecorder() handler.ServeHTTP(response, req) diff --git a/services/control-plane/internal/store/migrations/004_prompt_identity.sql b/services/control-plane/internal/store/migrations/004_prompt_identity.sql new file mode 100644 index 0000000..df23750 --- /dev/null +++ b/services/control-plane/internal/store/migrations/004_prompt_identity.sql @@ -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 <> ''; diff --git a/services/control-plane/internal/store/store.go b/services/control-plane/internal/store/store.go index 066e27b..b16b5b1 100644 --- a/services/control-plane/internal/store/store.go +++ b/services/control-plane/internal/store/store.go @@ -67,6 +67,7 @@ type CreatePromptInput struct { Status string ResponseSummary string Metadata json.RawMessage + CreatedAt *time.Time CompletedAt *time.Time } @@ -199,11 +200,18 @@ func (p *Postgres) CreatePrompt(ctx context.Context, input CreatePromptInput) (P } var prompt Prompt 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) - SELECT $1, r.id, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 + 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, COALESCE($14, now()), $15 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`, - 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) if err != nil { return Prompt{}, err