Publish only user prompts with their own proposal outcomes
Hop-State: A_06FN72NMB1DRV5SV2T6YWPR Hop-Proposal: R_06FN72K60164AV77NA2QP5G Hop-Task: T_06FN6ZVNS25H7V4BRG743PG Hop-Attempt: AT_06FN6ZVNS0W32KK9YQCRG88
This commit is contained in:
+95
-24
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -39,10 +40,26 @@ type PortablePromptMetadata struct {
|
||||
AttemptHeadKind string `json:"attempt_head_kind,omitempty"`
|
||||
}
|
||||
|
||||
type promptExportOptions struct {
|
||||
AttemptIDs []string
|
||||
PublishableOnly bool
|
||||
Status string
|
||||
ResponseSummary string
|
||||
Overwrite bool
|
||||
}
|
||||
|
||||
type suppressedPromptManifest struct {
|
||||
PromptIDs []string `json:"prompt_ids"`
|
||||
}
|
||||
|
||||
// ExportPromptLedger writes immutable prompt files beneath
|
||||
// .hop/records/prompts. Passing attemptIDs restricts the export to those
|
||||
// attempts, which keeps concurrent proposals from writing the same files.
|
||||
func (s *Service) ExportPromptLedger(ctx context.Context, destinationRoot string, attemptIDs ...string) (PortablePromptLedger, error) {
|
||||
return s.exportPromptLedger(ctx, destinationRoot, promptExportOptions{AttemptIDs: attemptIDs, PublishableOnly: true})
|
||||
}
|
||||
|
||||
func (s *Service) exportPromptLedger(ctx context.Context, destinationRoot string, options promptExportOptions) (PortablePromptLedger, error) {
|
||||
if destinationRoot == "" {
|
||||
destinationRoot = s.Root
|
||||
}
|
||||
@@ -50,25 +67,59 @@ func (s *Service) ExportPromptLedger(ctx context.Context, destinationRoot string
|
||||
if err != nil {
|
||||
return PortablePromptLedger{}, err
|
||||
}
|
||||
suppressed, err := loadSuppressedPromptIDs(destinationRoot)
|
||||
if err != nil {
|
||||
return PortablePromptLedger{}, err
|
||||
}
|
||||
ledger := PortablePromptLedger{
|
||||
SchemaVersion: 1,
|
||||
GeneratedAt: time.Now().UTC(),
|
||||
Prompts: make([]PortablePromptRecord, 0),
|
||||
}
|
||||
wantedAttempts := make(map[string]struct{}, len(attemptIDs))
|
||||
for _, attemptID := range attemptIDs {
|
||||
wantedAttempts := make(map[string]struct{}, len(options.AttemptIDs))
|
||||
for _, attemptID := range options.AttemptIDs {
|
||||
wantedAttempts[attemptID] = struct{}{}
|
||||
}
|
||||
lastPromptByAttempt := make(map[string]string)
|
||||
for _, row := range graph {
|
||||
if row.State.Kind == StatePrompt && row.State.Prompt != "" && row.State.AttemptID != "" {
|
||||
lastPromptByAttempt[row.State.AttemptID] = row.State.ID
|
||||
}
|
||||
}
|
||||
for _, row := range graph {
|
||||
state := row.State
|
||||
if state.Kind != StatePrompt || state.Prompt == "" {
|
||||
continue
|
||||
}
|
||||
if _, hidden := suppressed[state.ID]; hidden {
|
||||
continue
|
||||
}
|
||||
if _, reconciliation := decodeReconciliationConflicts(state.Summary); reconciliation || strings.HasPrefix(state.Prompt, "Resolve proposal ") {
|
||||
continue
|
||||
}
|
||||
if len(wantedAttempts) > 0 {
|
||||
if _, wanted := wantedAttempts[state.AttemptID]; !wanted {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if state.AttemptID == "" {
|
||||
continue
|
||||
}
|
||||
attempt, err := s.Store.GetAttempt(ctx, state.AttemptID)
|
||||
if err != nil {
|
||||
return PortablePromptLedger{}, fmt.Errorf("read attempt for prompt %s: %w", state.ID, err)
|
||||
}
|
||||
var head State
|
||||
if attempt.HeadStateID != "" {
|
||||
head, err = s.Store.GetState(ctx, attempt.HeadStateID)
|
||||
if err != nil {
|
||||
return PortablePromptLedger{}, fmt.Errorf("read attempt head for prompt %s: %w", state.ID, err)
|
||||
}
|
||||
}
|
||||
if options.PublishableOnly && head.Kind != StateProposal && head.Kind != StateAccepted {
|
||||
continue
|
||||
}
|
||||
|
||||
record := PortablePromptRecord{
|
||||
ID: state.ID,
|
||||
TaskID: state.TaskID,
|
||||
@@ -76,33 +127,28 @@ func (s *Service) ExportPromptLedger(ctx context.Context, destinationRoot string
|
||||
StateID: state.ID,
|
||||
Prompt: state.Prompt,
|
||||
AgentName: state.Agent,
|
||||
Status: "unknown",
|
||||
Status: attempt.Status,
|
||||
CreatedAt: state.CreatedAt,
|
||||
Metadata: PortablePromptMetadata{
|
||||
SourceTree: state.SourceTree,
|
||||
GitCommit: state.GitCommit,
|
||||
},
|
||||
}
|
||||
if state.AttemptID != "" {
|
||||
attempt, err := s.Store.GetAttempt(ctx, state.AttemptID)
|
||||
if err != nil {
|
||||
return PortablePromptLedger{}, fmt.Errorf("read attempt for prompt %s: %w", state.ID, err)
|
||||
}
|
||||
record.Status = attempt.Status
|
||||
record.Metadata.AttemptHead = attempt.HeadStateID
|
||||
if attempt.HeadStateID != "" {
|
||||
head, err := s.Store.GetState(ctx, attempt.HeadStateID)
|
||||
if err != nil {
|
||||
return PortablePromptLedger{}, fmt.Errorf("read attempt head for prompt %s: %w", state.ID, err)
|
||||
}
|
||||
record.Metadata.AttemptHeadKind = string(head.Kind)
|
||||
record.ResponseSummary = head.Summary
|
||||
if head.Kind == StateAccepted || head.Kind == StateFailed || head.Kind == StateCancelled {
|
||||
completedAt := head.CreatedAt
|
||||
record.CompletedAt = &completedAt
|
||||
}
|
||||
record.Metadata.AttemptHead = attempt.HeadStateID
|
||||
record.Metadata.AttemptHeadKind = string(head.Kind)
|
||||
if lastPromptByAttempt[state.AttemptID] == state.ID {
|
||||
record.ResponseSummary = head.Summary
|
||||
if options.ResponseSummary != "" {
|
||||
record.ResponseSummary = options.ResponseSummary
|
||||
}
|
||||
}
|
||||
if options.Status != "" {
|
||||
record.Status = options.Status
|
||||
}
|
||||
if head.Kind == StateAccepted || head.Kind == StateFailed || head.Kind == StateCancelled {
|
||||
completedAt := head.CreatedAt
|
||||
record.CompletedAt = &completedAt
|
||||
}
|
||||
ledger.Prompts = append(ledger.Prompts, record)
|
||||
}
|
||||
|
||||
@@ -112,10 +158,12 @@ func (s *Service) ExportPromptLedger(ctx context.Context, destinationRoot string
|
||||
}
|
||||
for _, record := range ledger.Prompts {
|
||||
outputPath := filepath.Join(outputDir, record.ID+".json")
|
||||
if _, err := os.Stat(outputPath); err == nil {
|
||||
_, statErr := os.Stat(outputPath)
|
||||
if statErr == nil && !options.Overwrite {
|
||||
continue // Prompt records are immutable once published.
|
||||
} else if !os.IsNotExist(err) {
|
||||
return PortablePromptLedger{}, fmt.Errorf("inspect prompt record %s: %w", record.ID, err)
|
||||
}
|
||||
if statErr != nil && !os.IsNotExist(statErr) {
|
||||
return PortablePromptLedger{}, fmt.Errorf("inspect prompt record %s: %w", record.ID, statErr)
|
||||
}
|
||||
contents, err := json.MarshalIndent(record, "", " ")
|
||||
if err != nil {
|
||||
@@ -147,3 +195,26 @@ func (s *Service) ExportPromptLedger(ctx context.Context, destinationRoot string
|
||||
}
|
||||
return ledger, nil
|
||||
}
|
||||
|
||||
func loadSuppressedPromptIDs(destinationRoot string) (map[string]struct{}, error) {
|
||||
path := filepath.Join(destinationRoot, ".hop", "records", "suppressed.json")
|
||||
contents, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return map[string]struct{}{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read suppressed prompt manifest: %w", err)
|
||||
}
|
||||
var manifest suppressedPromptManifest
|
||||
if err := json.Unmarshal(contents, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("decode suppressed prompt manifest: %w", err)
|
||||
}
|
||||
result := make(map[string]struct{}, len(manifest.PromptIDs))
|
||||
for _, id := range manifest.PromptIDs {
|
||||
if !strings.HasPrefix(id, "P_") {
|
||||
return nil, fmt.Errorf("invalid suppressed prompt ID %q", id)
|
||||
}
|
||||
result[id] = struct{}{}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
+15
-22
@@ -482,19 +482,6 @@ func (s *Service) checkpointAttempt(ctx context.Context, attempt Attempt) (State
|
||||
}
|
||||
|
||||
func (s *Service) RunCheck(ctx context.Context, stateID string, argv []string) (Check, error) {
|
||||
state, err := s.Store.GetState(ctx, stateID)
|
||||
if err != nil {
|
||||
return Check{}, err
|
||||
}
|
||||
if state.AttemptID != "" {
|
||||
attempt, err := s.Store.GetAttempt(ctx, state.AttemptID)
|
||||
if err != nil {
|
||||
return Check{}, err
|
||||
}
|
||||
if _, err := s.ExportPromptLedger(ctx, attempt.Workspace, attempt.ID); err != nil {
|
||||
return Check{}, err
|
||||
}
|
||||
}
|
||||
checkpoint, err := s.Checkpoint(ctx, stateID)
|
||||
if err != nil {
|
||||
return Check{}, err
|
||||
@@ -564,6 +551,10 @@ func (s *Service) Propose(ctx context.Context, stateID, summary string) (Proposa
|
||||
if err != nil {
|
||||
return ProposalResult{}, err
|
||||
}
|
||||
if strings.TrimSpace(summary) == "" {
|
||||
summary = task.Title
|
||||
}
|
||||
summary, _ = RedactPromptSecrets(summary)
|
||||
reconciliationPrompt, reconciliation, err := s.Store.ReconciliationPromptForAttempt(ctx, attempt.ID)
|
||||
if err != nil {
|
||||
return ProposalResult{}, err
|
||||
@@ -580,15 +571,12 @@ func (s *Service) Propose(ctx context.Context, stateID, summary string) (Proposa
|
||||
if err != nil {
|
||||
return ProposalResult{}, err
|
||||
}
|
||||
if _, err := s.ExportPromptLedger(ctx, attempt.Workspace, attempt.ID); err != nil {
|
||||
return ProposalResult{}, err
|
||||
}
|
||||
commit, tree, err := workspaceRepo.Snapshot(ctx, "hop: proposal\n")
|
||||
_, validationTree, err := workspaceRepo.Snapshot(ctx, "hop: proposal validation tree\n")
|
||||
if err != nil {
|
||||
return ProposalResult{}, err
|
||||
}
|
||||
if reconciliation {
|
||||
unresolved, err := unresolvedReconciliationMarkers(ctx, workspaceRepo, tree, reconciliationConflicts)
|
||||
unresolved, err := unresolvedReconciliationMarkers(ctx, workspaceRepo, validationTree, reconciliationConflicts)
|
||||
if err != nil {
|
||||
return ProposalResult{}, err
|
||||
}
|
||||
@@ -596,7 +584,7 @@ func (s *Service) Propose(ctx context.Context, stateID, summary string) (Proposa
|
||||
return ProposalResult{}, fmt.Errorf("reconciliation still contains merge markers in: %s", strings.Join(unresolved, ", "))
|
||||
}
|
||||
}
|
||||
checks, err := s.Store.ListChecks(ctx, attempt.ID, tree)
|
||||
checks, err := s.Store.ListChecks(ctx, attempt.ID, validationTree)
|
||||
if err != nil {
|
||||
return ProposalResult{}, err
|
||||
}
|
||||
@@ -612,10 +600,15 @@ func (s *Service) Propose(ctx context.Context, stateID, summary string) (Proposa
|
||||
return ProposalResult{}, fmt.Errorf("reconciliation must pass hop check on the resolved tree before proposing")
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(summary) == "" {
|
||||
summary = task.Title
|
||||
if _, err := s.exportPromptLedger(ctx, attempt.Workspace, promptExportOptions{
|
||||
AttemptIDs: []string{attempt.ID}, Status: "proposed", ResponseSummary: summary,
|
||||
}); err != nil {
|
||||
return ProposalResult{}, err
|
||||
}
|
||||
commit, tree, err := workspaceRepo.Snapshot(ctx, "hop: proposal\n")
|
||||
if err != nil {
|
||||
return ProposalResult{}, err
|
||||
}
|
||||
summary, _ = RedactPromptSecrets(summary)
|
||||
canonicalAnchorID := attempt.BaseStateID
|
||||
if reconciliation && reconciliationPrompt.CanonicalAnchorID != "" {
|
||||
canonicalAnchorID = reconciliationPrompt.CanonicalAnchorID
|
||||
|
||||
@@ -259,11 +259,50 @@ func TestProposeExportsPortablePromptLedger(t *testing.T) {
|
||||
if err := json.Unmarshal(contents, &record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.ID != result.Prompt.ID || record.Prompt != "Publish a portable prompt ledger" {
|
||||
if record.ID != result.Prompt.ID || record.Prompt != "Publish a portable prompt ledger" || record.Status != "proposed" || record.ResponseSummary != "Publish the ledger" {
|
||||
t.Fatalf("unexpected portable prompt record: %#v", record)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportOmitsActiveInternalAttempt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
|
||||
if _, err := service.CreatePrompt(ctx, "Read-only internal audit", "", "codex"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ledger, err := service.ExportPromptLedger(ctx, service.Root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ledger.Prompts) != 0 {
|
||||
t.Fatalf("exported active internal prompts: %#v", ledger.Prompts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposeRespectsSuppressedPromptManifest(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
|
||||
result, err := service.CreatePrompt(ctx, "Internal controller instruction", "", "codex")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := fmt.Sprintf("{\"prompt_ids\":[%q]}\n", result.Prompt.ID)
|
||||
writeTestFile(t, filepath.Join(result.Workspace, ".hop", "records", "suppressed.json"), manifest)
|
||||
writeTestFile(t, filepath.Join(result.Workspace, "feature.txt"), "done\n")
|
||||
proposal, err := service.Propose(ctx, result.Prompt.ID, "Apply the user-facing change")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
materialized := filepath.Join(t.TempDir(), "proposal")
|
||||
if _, err := service.Repo.AddDetachedWorktree(ctx, materialized, proposal.Proposal.GitCommit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = service.Repo.RemoveWorktree(ctx, materialized, true) })
|
||||
if _, err := os.Stat(filepath.Join(materialized, ".hop", "records", "prompts", result.Prompt.ID+".json")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("suppressed prompt was published: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenProjectWaitsForInitializationLock(t *testing.T) {
|
||||
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
|
||||
root := service.Root
|
||||
@@ -345,7 +384,7 @@ func TestCLIJSONWorkflow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIExportWritesPortablePromptRecords(t *testing.T) {
|
||||
func TestCLIExportOmitsActivePromptRecords(t *testing.T) {
|
||||
t.Setenv("HOP_ROOT", "")
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "base.txt"), "base\n")
|
||||
@@ -362,7 +401,7 @@ func TestCLIExportWritesPortablePromptRecords(t *testing.T) {
|
||||
runCLIJSONTest(t, []string{"begin", "--agent", "codex", "--json", "Publish prompt records"})
|
||||
runCLIJSONTest(t, []string{"export", "--output", ".", "--json"})
|
||||
entries, err := filepath.Glob(filepath.Join(root, ".hop", "records", "prompts", "P_*.json"))
|
||||
if err != nil || len(entries) != 1 {
|
||||
if err != nil || len(entries) != 0 {
|
||||
t.Fatalf("portable prompt records = %v, err=%v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user