Publish immutable portable prompt records without exposing Hop runtime files
Hop-State: A_06FN6TVSHKAXPT37Z25BN38 Hop-Proposal: R_06FN6TSP5HXV3S320BV6TN8 Hop-Task: T_06FN6RMF4VPW419BR2ZD9Q0 Hop-Attempt: AT_06FN6RMF4V18KAM05RFZ8ER
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
package hop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PortablePromptLedger is the versioned, repository-safe subset of Hop's
|
||||
// local state. It deliberately excludes SQLite, workspace paths, check output,
|
||||
// and every other machine-local runtime detail.
|
||||
type PortablePromptLedger struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
Prompts []PortablePromptRecord `json:"prompts"`
|
||||
}
|
||||
|
||||
type PortablePromptRecord struct {
|
||||
ID string `json:"id"`
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
AttemptID string `json:"attempt_id,omitempty"`
|
||||
StateID string `json:"state_id"`
|
||||
Prompt string `json:"prompt"`
|
||||
AgentName string `json:"agent_name,omitempty"`
|
||||
Status string `json:"status"`
|
||||
ResponseSummary string `json:"response_summary,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
Metadata PortablePromptMetadata `json:"metadata"`
|
||||
}
|
||||
|
||||
type PortablePromptMetadata struct {
|
||||
SourceTree string `json:"source_tree"`
|
||||
GitCommit string `json:"git_commit"`
|
||||
AttemptHead string `json:"attempt_head,omitempty"`
|
||||
AttemptHeadKind string `json:"attempt_head_kind,omitempty"`
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if destinationRoot == "" {
|
||||
destinationRoot = s.Root
|
||||
}
|
||||
graph, err := s.Store.Graph(ctx, "")
|
||||
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[attemptID] = struct{}{}
|
||||
}
|
||||
for _, row := range graph {
|
||||
state := row.State
|
||||
if state.Kind != StatePrompt || state.Prompt == "" {
|
||||
continue
|
||||
}
|
||||
if len(wantedAttempts) > 0 {
|
||||
if _, wanted := wantedAttempts[state.AttemptID]; !wanted {
|
||||
continue
|
||||
}
|
||||
}
|
||||
record := PortablePromptRecord{
|
||||
ID: state.ID,
|
||||
TaskID: state.TaskID,
|
||||
AttemptID: state.AttemptID,
|
||||
StateID: state.ID,
|
||||
Prompt: state.Prompt,
|
||||
AgentName: state.Agent,
|
||||
Status: "unknown",
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
ledger.Prompts = append(ledger.Prompts, record)
|
||||
}
|
||||
|
||||
outputDir := filepath.Join(destinationRoot, ".hop", "records", "prompts")
|
||||
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
||||
return PortablePromptLedger{}, fmt.Errorf("create prompt ledger directory: %w", err)
|
||||
}
|
||||
for _, record := range ledger.Prompts {
|
||||
outputPath := filepath.Join(outputDir, record.ID+".json")
|
||||
if _, err := os.Stat(outputPath); err == nil {
|
||||
continue // Prompt records are immutable once published.
|
||||
} else if !os.IsNotExist(err) {
|
||||
return PortablePromptLedger{}, fmt.Errorf("inspect prompt record %s: %w", record.ID, err)
|
||||
}
|
||||
contents, err := json.MarshalIndent(record, "", " ")
|
||||
if err != nil {
|
||||
return PortablePromptLedger{}, fmt.Errorf("encode prompt record %s: %w", record.ID, err)
|
||||
}
|
||||
temporary, err := os.CreateTemp(outputDir, ".prompt-*.tmp")
|
||||
if err != nil {
|
||||
return PortablePromptLedger{}, fmt.Errorf("create prompt record %s: %w", record.ID, err)
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
if _, err := temporary.Write(append(contents, '\n')); err != nil {
|
||||
_ = temporary.Close()
|
||||
_ = os.Remove(temporaryPath)
|
||||
return PortablePromptLedger{}, fmt.Errorf("write prompt record %s: %w", record.ID, err)
|
||||
}
|
||||
if err := temporary.Chmod(0o644); err != nil {
|
||||
_ = temporary.Close()
|
||||
_ = os.Remove(temporaryPath)
|
||||
return PortablePromptLedger{}, fmt.Errorf("set prompt record permissions for %s: %w", record.ID, err)
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
_ = os.Remove(temporaryPath)
|
||||
return PortablePromptLedger{}, fmt.Errorf("close prompt record %s: %w", record.ID, err)
|
||||
}
|
||||
if err := os.Rename(temporaryPath, outputPath); err != nil {
|
||||
_ = os.Remove(temporaryPath)
|
||||
return PortablePromptLedger{}, fmt.Errorf("publish prompt record %s: %w", record.ID, err)
|
||||
}
|
||||
}
|
||||
return ledger, nil
|
||||
}
|
||||
Reference in New Issue
Block a user