Initial Hop alpha
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
package hop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const usageText = `Hop — prompt-native version control
|
||||
|
||||
Usage:
|
||||
hop init [path]
|
||||
hop prompt [--from STATE] [--agent NAME] "instruction"
|
||||
hop checkpoint STATE
|
||||
hop check STATE -- COMMAND [ARG...]
|
||||
hop propose [--summary TEXT] STATE
|
||||
hop accept STATE [-- COMMAND [ARG...]]
|
||||
hop status
|
||||
hop graph
|
||||
hop state STATE
|
||||
hop env STATE
|
||||
hop diff STATE
|
||||
hop history
|
||||
hop undo
|
||||
hop doctor [--repair]
|
||||
hop skill install [--path SKILLS_DIR] [--force]
|
||||
hop skill print
|
||||
hop version
|
||||
|
||||
Add --json anywhere for machine-readable output.
|
||||
`
|
||||
|
||||
const Version = "0.1.0-alpha.1"
|
||||
|
||||
func RunCLI(args []string, stdout, stderr io.Writer) int {
|
||||
jsonOutput, args := removeFlag(args, "--json")
|
||||
if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" {
|
||||
fmt.Fprint(stdout, usageText)
|
||||
return 0
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
command := args[0]
|
||||
commandArgs := args[1:]
|
||||
if command == "version" || command == "--version" {
|
||||
if jsonOutput {
|
||||
writeJSON(stdout, map[string]any{"ok": true, "version": Version})
|
||||
} else {
|
||||
fmt.Fprintf(stdout, "hop %s\n", Version)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if command == "skill" {
|
||||
return runSkillCLI(commandArgs, jsonOutput, stdout, stderr)
|
||||
}
|
||||
|
||||
if command == "init" {
|
||||
path := "."
|
||||
if len(commandArgs) > 1 {
|
||||
fmt.Fprintln(stderr, "hop init accepts at most one path")
|
||||
return 2
|
||||
}
|
||||
if len(commandArgs) == 1 {
|
||||
path = commandArgs[0]
|
||||
}
|
||||
service, state, err := InitProject(ctx, path)
|
||||
if err != nil {
|
||||
return printCLIError(err, jsonOutput, stdout, stderr)
|
||||
}
|
||||
defer service.Close()
|
||||
if jsonOutput {
|
||||
writeJSON(stdout, map[string]any{"ok": true, "root": service.Root, "state": state})
|
||||
} else {
|
||||
fmt.Fprintf(stdout, "Initialized Hop at %s\nAccepted %s · tree %s\n", service.Root, state.ID, shortHash(state.SourceTree))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
service, err := OpenProject(".")
|
||||
if err != nil {
|
||||
return printCLIError(err, jsonOutput, stdout, stderr)
|
||||
}
|
||||
defer service.Close()
|
||||
|
||||
var value any
|
||||
switch command {
|
||||
case "prompt", "start":
|
||||
fs := flag.NewFlagSet("prompt", flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
from := fs.String("from", "", "continue an existing attempt")
|
||||
agent := fs.String("agent", "", "agent or harness name")
|
||||
if err := fs.Parse(commandArgs); err != nil {
|
||||
return 2
|
||||
}
|
||||
if len(fs.Args()) != 1 || strings.TrimSpace(fs.Args()[0]) == "" {
|
||||
fmt.Fprintln(stderr, "exactly one non-empty prompt argument is required; quote prompts containing spaces")
|
||||
return 2
|
||||
}
|
||||
message := fs.Args()[0]
|
||||
result, err := service.CreatePrompt(ctx, message, *from, *agent)
|
||||
if err != nil {
|
||||
return printCLIError(err, jsonOutput, stdout, stderr)
|
||||
}
|
||||
value = result
|
||||
if !jsonOutput {
|
||||
if result.Checkpoint != nil {
|
||||
fmt.Fprintf(stdout, "Checkpointed %s before the follow-up\n", result.Checkpoint.ID)
|
||||
}
|
||||
fmt.Fprintf(stdout, "Created prompt state %s before delivery\nWorkspace: %s\n", result.Prompt.ID, result.Workspace)
|
||||
fmt.Fprintf(stdout, "Environment: HOP_ROOT=%s HOP_STATE_ID=%s HOP_TASK_ID=%s HOP_ATTEMPT_ID=%s\n", service.Root, result.Prompt.ID, result.Task.ID, result.Attempt.ID)
|
||||
}
|
||||
|
||||
case "checkpoint":
|
||||
if len(commandArgs) != 1 {
|
||||
fmt.Fprintln(stderr, "usage: hop checkpoint STATE")
|
||||
return 2
|
||||
}
|
||||
state, err := service.Checkpoint(ctx, commandArgs[0])
|
||||
if err != nil {
|
||||
return printCLIError(err, jsonOutput, stdout, stderr)
|
||||
}
|
||||
value = state
|
||||
if !jsonOutput {
|
||||
fmt.Fprintf(stdout, "Created checkpoint %s · tree %s\n", state.ID, shortHash(state.SourceTree))
|
||||
}
|
||||
|
||||
case "check":
|
||||
stateID, argv, ok := splitCommand(commandArgs)
|
||||
if !ok {
|
||||
fmt.Fprintln(stderr, "usage: hop check STATE -- COMMAND [ARG...]")
|
||||
return 2
|
||||
}
|
||||
check, err := service.RunCheck(ctx, stateID, argv)
|
||||
value = check
|
||||
if !jsonOutput {
|
||||
fmt.Fprintf(stdout, "$ %s\n%s", shellQuote(check.Command), check.Output)
|
||||
if check.Output != "" && !strings.HasSuffix(check.Output, "\n") {
|
||||
fmt.Fprintln(stdout)
|
||||
}
|
||||
fmt.Fprintf(stdout, "Check %s · exit %d · tree %s\n", check.ID, check.ExitCode, shortHash(check.TreeHash))
|
||||
}
|
||||
if err != nil {
|
||||
return printCLIError(err, jsonOutput, stdout, stderr)
|
||||
}
|
||||
|
||||
case "propose", "submit":
|
||||
fs := flag.NewFlagSet("propose", flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
summary := fs.String("summary", "", "result summary")
|
||||
if err := fs.Parse(commandArgs); err != nil {
|
||||
return 2
|
||||
}
|
||||
if len(fs.Args()) != 1 {
|
||||
fmt.Fprintln(stderr, "usage: hop propose [--summary TEXT] STATE")
|
||||
return 2
|
||||
}
|
||||
result, err := service.Propose(ctx, fs.Args()[0], *summary)
|
||||
if err != nil {
|
||||
return printCLIError(err, jsonOutput, stdout, stderr)
|
||||
}
|
||||
value = result
|
||||
if !jsonOutput {
|
||||
fmt.Fprintf(stdout, "Created proposal %s · tree %s · %d matching checks\n", result.Proposal.ID, shortHash(result.Proposal.SourceTree), len(result.Checks))
|
||||
}
|
||||
|
||||
case "accept", "land":
|
||||
stateID, argv, ok := splitOptionalCommand(commandArgs)
|
||||
if !ok {
|
||||
fmt.Fprintln(stderr, "usage: hop accept STATE [-- COMMAND [ARG...]]")
|
||||
return 2
|
||||
}
|
||||
result, err := service.Accept(ctx, stateID, argv)
|
||||
value = result
|
||||
if err != nil {
|
||||
return printCLIError(err, jsonOutput, stdout, stderr)
|
||||
}
|
||||
if !jsonOutput {
|
||||
fmt.Fprintf(stdout, "Accepted as %s · tree %s\n", result.State.ID, shortHash(result.State.SourceTree))
|
||||
if result.Check == nil {
|
||||
fmt.Fprintln(stdout, "No final-state validation command was supplied; acceptance was manual.")
|
||||
}
|
||||
for _, warning := range result.Warnings {
|
||||
fmt.Fprintf(stderr, "Warning: %s\n", warning)
|
||||
}
|
||||
}
|
||||
|
||||
case "status":
|
||||
status, err := service.Status(ctx)
|
||||
if err != nil {
|
||||
return printCLIError(err, jsonOutput, stdout, stderr)
|
||||
}
|
||||
value = status
|
||||
if !jsonOutput {
|
||||
fmt.Fprintf(stdout, "Accepted: %s · tree %s\n", status.AcceptedHead.ID, shortHash(status.AcceptedHead.SourceTree))
|
||||
if len(status.Attempts) == 0 {
|
||||
fmt.Fprintln(stdout, "No attempts yet.")
|
||||
}
|
||||
for _, attempt := range status.Attempts {
|
||||
fmt.Fprintf(stdout, "%s %-10s head=%s %s\n", attempt.ID, attempt.Status, attempt.HeadStateID, attempt.Workspace)
|
||||
}
|
||||
}
|
||||
|
||||
case "graph":
|
||||
rows, err := service.Graph(ctx)
|
||||
if err != nil {
|
||||
return printCLIError(err, jsonOutput, stdout, stderr)
|
||||
}
|
||||
value = rows
|
||||
if !jsonOutput {
|
||||
for _, row := range rows {
|
||||
parents := make([]string, 0, len(row.Parents))
|
||||
for _, parent := range row.Parents {
|
||||
parents = append(parents, parent.Role+"="+parent.StateID)
|
||||
}
|
||||
label := row.State.Summary
|
||||
if label == "" {
|
||||
label = row.State.Prompt
|
||||
}
|
||||
fmt.Fprintf(stdout, "%-10s %-28s %-50s %s\n", row.State.Kind, row.State.ID, strings.Join(parents, " "), label)
|
||||
}
|
||||
}
|
||||
|
||||
case "state", "inspect":
|
||||
if len(commandArgs) != 1 {
|
||||
fmt.Fprintln(stderr, "usage: hop state STATE")
|
||||
return 2
|
||||
}
|
||||
state, err := service.State(ctx, commandArgs[0])
|
||||
if err != nil {
|
||||
return printCLIError(err, jsonOutput, stdout, stderr)
|
||||
}
|
||||
value = state
|
||||
if !jsonOutput {
|
||||
fmt.Fprintf(stdout, "%s %s\nTree: %s\nCommit: %s\nDigest: %s\n", state.Kind, state.ID, state.SourceTree, state.GitCommit, state.Digest)
|
||||
if state.Prompt != "" {
|
||||
fmt.Fprintf(stdout, "Prompt: %s\n", state.Prompt)
|
||||
}
|
||||
if state.Summary != "" {
|
||||
fmt.Fprintf(stdout, "Summary: %s\n", state.Summary)
|
||||
}
|
||||
for _, parent := range state.Parents {
|
||||
fmt.Fprintf(stdout, "Parent: %-18s %s\n", parent.Role, parent.StateID)
|
||||
}
|
||||
}
|
||||
|
||||
case "env":
|
||||
if len(commandArgs) != 1 {
|
||||
fmt.Fprintln(stderr, "usage: hop env STATE")
|
||||
return 2
|
||||
}
|
||||
result, err := service.EnvironmentForState(ctx, commandArgs[0])
|
||||
if err != nil {
|
||||
return printCLIError(err, jsonOutput, stdout, stderr)
|
||||
}
|
||||
value = result
|
||||
if !jsonOutput {
|
||||
for _, name := range []string{"HOP_ROOT", "HOP_STATE_ID", "HOP_TASK_ID", "HOP_ATTEMPT_ID", "HOP_WORKSPACE"} {
|
||||
fmt.Fprintf(stdout, "export %s=%s\n", name, shellQuote([]string{result.Variables[name]}))
|
||||
}
|
||||
fmt.Fprintf(stdout, "cd %s\n", shellQuote([]string{result.Workspace}))
|
||||
}
|
||||
|
||||
case "diff":
|
||||
if len(commandArgs) != 1 {
|
||||
fmt.Fprintln(stderr, "usage: hop diff STATE")
|
||||
return 2
|
||||
}
|
||||
diff, err := service.Diff(ctx, commandArgs[0])
|
||||
if err != nil {
|
||||
return printCLIError(err, jsonOutput, stdout, stderr)
|
||||
}
|
||||
value = map[string]string{"state": commandArgs[0], "diff": diff}
|
||||
if !jsonOutput {
|
||||
fmt.Fprint(stdout, diff)
|
||||
}
|
||||
|
||||
case "history":
|
||||
states, err := service.History(ctx)
|
||||
if err != nil {
|
||||
return printCLIError(err, jsonOutput, stdout, stderr)
|
||||
}
|
||||
value = states
|
||||
if !jsonOutput {
|
||||
for _, state := range states {
|
||||
fmt.Fprintf(stdout, "%s %s %s\n", state.ID, shortHash(state.SourceTree), state.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
case "undo":
|
||||
if len(commandArgs) != 0 {
|
||||
fmt.Fprintln(stderr, "usage: hop undo")
|
||||
return 2
|
||||
}
|
||||
state, err := service.Undo(ctx)
|
||||
var committed *CommittedStateError
|
||||
if err != nil && !errors.As(err, &committed) {
|
||||
return printCLIError(err, jsonOutput, stdout, stderr)
|
||||
}
|
||||
if committed != nil {
|
||||
state = committed.State
|
||||
value = map[string]any{"state": state, "warnings": []string{committed.Error()}}
|
||||
} else {
|
||||
value = state
|
||||
}
|
||||
if !jsonOutput {
|
||||
fmt.Fprintf(stdout, "Created forward undo state %s · tree %s\n", state.ID, shortHash(state.SourceTree))
|
||||
if committed != nil {
|
||||
fmt.Fprintf(stderr, "Warning: %s\n", committed.Error())
|
||||
}
|
||||
}
|
||||
|
||||
case "doctor":
|
||||
fs := flag.NewFlagSet("doctor", flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
repair := fs.Bool("repair", false, "repair the internal accepted ref")
|
||||
if err := fs.Parse(commandArgs); err != nil || len(fs.Args()) != 0 {
|
||||
return 2
|
||||
}
|
||||
report, err := service.Doctor(ctx, *repair)
|
||||
if err != nil {
|
||||
return printCLIError(err, jsonOutput, stdout, stderr)
|
||||
}
|
||||
value = report
|
||||
if !jsonOutput {
|
||||
if report.OK {
|
||||
fmt.Fprintf(stdout, "Hop project is healthy · %s\n", report.AcceptedState)
|
||||
} else {
|
||||
for _, problem := range report.Problems {
|
||||
fmt.Fprintf(stdout, "Problem: %s\n", problem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
fmt.Fprintf(stderr, "unknown command %q\n\n%s", command, usageText)
|
||||
return 2
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
writeJSON(stdout, map[string]any{"ok": true, "data": value})
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runSkillCLI(args []string, jsonOutput bool, stdout, stderr io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(stderr, "usage: hop skill install [--path SKILLS_DIR] [--force] | hop skill print")
|
||||
return 2
|
||||
}
|
||||
switch args[0] {
|
||||
case "install":
|
||||
fs := flag.NewFlagSet("skill install", flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
path := fs.String("path", "", "parent skills directory")
|
||||
force := fs.Bool("force", false, "update an existing Hop skill")
|
||||
if err := fs.Parse(args[1:]); err != nil || len(fs.Args()) != 0 {
|
||||
return 2
|
||||
}
|
||||
result, err := InstallSkill(*path, *force)
|
||||
if err != nil {
|
||||
return printCLIError(err, jsonOutput, stdout, stderr)
|
||||
}
|
||||
if jsonOutput {
|
||||
writeJSON(stdout, map[string]any{"ok": true, "data": result})
|
||||
} else {
|
||||
fmt.Fprintf(stdout, "Installed Hop skill at %s\n", result.Path)
|
||||
}
|
||||
return 0
|
||||
case "print":
|
||||
if len(args) != 1 {
|
||||
fmt.Fprintln(stderr, "usage: hop skill print")
|
||||
return 2
|
||||
}
|
||||
contents, err := EmbeddedSkillText()
|
||||
if err != nil {
|
||||
return printCLIError(err, jsonOutput, stdout, stderr)
|
||||
}
|
||||
if jsonOutput {
|
||||
writeJSON(stdout, map[string]any{"ok": true, "data": map[string]string{"skill": contents}})
|
||||
} else {
|
||||
fmt.Fprint(stdout, contents)
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintf(stderr, "unknown skill command %q\n", args[0])
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
func printCLIError(err error, jsonOutput bool, stdout, stderr io.Writer) int {
|
||||
code := 1
|
||||
var conflict *ConflictError
|
||||
var stale *StaleHeadError
|
||||
var failed *CheckFailedError
|
||||
switch {
|
||||
case errors.As(err, &conflict):
|
||||
code = 20
|
||||
case errors.As(err, &stale):
|
||||
code = 21
|
||||
case errors.As(err, &failed):
|
||||
code = 22
|
||||
}
|
||||
if jsonOutput {
|
||||
writeJSON(stdout, map[string]any{"ok": false, "error": err.Error(), "exit_code": code})
|
||||
} else {
|
||||
fmt.Fprintf(stderr, "hop: %v\n", err)
|
||||
if conflict != nil && len(conflict.Paths) > 0 {
|
||||
fmt.Fprintln(stderr, "Overlapping paths:")
|
||||
for _, path := range conflict.Paths {
|
||||
fmt.Fprintf(stderr, " %s\n", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
func removeFlag(args []string, wanted string) (bool, []string) {
|
||||
found := false
|
||||
filtered := make([]string, 0, len(args))
|
||||
for i, arg := range args {
|
||||
if arg == "--" {
|
||||
filtered = append(filtered, args[i:]...)
|
||||
break
|
||||
}
|
||||
if arg == wanted {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, arg)
|
||||
}
|
||||
return found, filtered
|
||||
}
|
||||
|
||||
func splitCommand(args []string) (string, []string, bool) {
|
||||
if len(args) < 3 || args[1] != "--" {
|
||||
return "", nil, false
|
||||
}
|
||||
return args[0], args[2:], len(args[2:]) > 0
|
||||
}
|
||||
|
||||
func splitOptionalCommand(args []string) (string, []string, bool) {
|
||||
if len(args) == 1 {
|
||||
return args[0], nil, true
|
||||
}
|
||||
if len(args) >= 3 && args[1] == "--" {
|
||||
return args[0], args[2:], true
|
||||
}
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
func writeJSON(w io.Writer, value any) {
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetIndent("", " ")
|
||||
_ = encoder.Encode(value)
|
||||
}
|
||||
|
||||
func shortHash(hash string) string {
|
||||
if len(hash) <= 12 {
|
||||
return hash
|
||||
}
|
||||
return hash[:12]
|
||||
}
|
||||
+1498
File diff suppressed because it is too large
Load Diff
+1033
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
package hop
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var idEncoding = base32.NewEncoding("0123456789ABCDEFGHJKMNPQRSTVWXYZ").WithPadding(base32.NoPadding)
|
||||
|
||||
func newID(prefix string) string {
|
||||
var entropy [8]byte
|
||||
if _, err := rand.Read(entropy[:]); err != nil {
|
||||
panic(fmt.Sprintf("generate id: %v", err))
|
||||
}
|
||||
stamp := uint64(time.Now().UTC().UnixMilli())
|
||||
var raw [14]byte
|
||||
raw[0] = byte(stamp >> 40)
|
||||
raw[1] = byte(stamp >> 32)
|
||||
raw[2] = byte(stamp >> 24)
|
||||
raw[3] = byte(stamp >> 16)
|
||||
raw[4] = byte(stamp >> 8)
|
||||
raw[5] = byte(stamp)
|
||||
copy(raw[6:], entropy[:])
|
||||
return strings.ToUpper(prefix) + "_" + idEncoding.EncodeToString(raw[:])
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package hop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type StateKind string
|
||||
|
||||
const (
|
||||
StatePrompt StateKind = "prompt"
|
||||
StateCheckpoint StateKind = "checkpoint"
|
||||
StateProposal StateKind = "proposal"
|
||||
StateAccepted StateKind = "accepted"
|
||||
StateFailed StateKind = "failed"
|
||||
StateCancelled StateKind = "cancelled"
|
||||
)
|
||||
|
||||
type Parent struct {
|
||||
StateID string `json:"state_id"`
|
||||
Role string `json:"role"`
|
||||
Order int `json:"order"`
|
||||
}
|
||||
|
||||
type State struct {
|
||||
ID string `json:"id"`
|
||||
Kind StateKind `json:"kind"`
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
AttemptID string `json:"attempt_id,omitempty"`
|
||||
CanonicalAnchorID string `json:"canonical_anchor_id,omitempty"`
|
||||
SourceTree string `json:"source_tree"`
|
||||
GitCommit string `json:"git_commit"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Agent string `json:"agent,omitempty"`
|
||||
Digest string `json:"digest"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Parents []Parent `json:"parents,omitempty"`
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Attempt struct {
|
||||
ID string `json:"id"`
|
||||
TaskID string `json:"task_id"`
|
||||
Agent string `json:"agent,omitempty"`
|
||||
Workspace string `json:"workspace"`
|
||||
BaseStateID string `json:"base_state_id"`
|
||||
HeadStateID string `json:"head_state_id"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Check struct {
|
||||
ID string `json:"id"`
|
||||
AttemptID string `json:"attempt_id"`
|
||||
StateID string `json:"state_id,omitempty"`
|
||||
TreeHash string `json:"tree_hash"`
|
||||
Command []string `json:"command"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Output string `json:"output"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type GraphRow struct {
|
||||
State State `json:"state"`
|
||||
Parents []Parent `json:"parents"`
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
Root string `json:"root"`
|
||||
AcceptedHead State `json:"accepted_head"`
|
||||
Attempts []Attempt `json:"attempts"`
|
||||
}
|
||||
|
||||
type AcceptResult struct {
|
||||
State State `json:"state"`
|
||||
ProposalPaths []string `json:"proposal_paths"`
|
||||
CurrentPaths []string `json:"current_paths"`
|
||||
Check *Check `json:"check,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type PromptResult struct {
|
||||
Prompt State `json:"prompt"`
|
||||
Checkpoint *State `json:"checkpoint,omitempty"`
|
||||
Task Task `json:"task"`
|
||||
Attempt Attempt `json:"attempt"`
|
||||
Workspace string `json:"workspace"`
|
||||
Deliver []string `json:"deliver"`
|
||||
}
|
||||
|
||||
type EnvironmentResult struct {
|
||||
State State `json:"state"`
|
||||
Attempt Attempt `json:"attempt"`
|
||||
Workspace string `json:"workspace"`
|
||||
Variables map[string]string `json:"variables"`
|
||||
}
|
||||
|
||||
type ProposalResult struct {
|
||||
Proposal State `json:"proposal"`
|
||||
Checks []Check `json:"checks"`
|
||||
}
|
||||
|
||||
type DoctorReport struct {
|
||||
OK bool `json:"ok"`
|
||||
AcceptedState string `json:"accepted_state"`
|
||||
AcceptedCommit string `json:"accepted_commit"`
|
||||
RefCommit string `json:"ref_commit,omitempty"`
|
||||
Repaired bool `json:"repaired"`
|
||||
Problems []string `json:"problems,omitempty"`
|
||||
}
|
||||
|
||||
type StaleHeadError struct {
|
||||
Expected string
|
||||
Actual string
|
||||
}
|
||||
|
||||
func (e *StaleHeadError) Error() string {
|
||||
return "accepted head changed while the operation was running"
|
||||
}
|
||||
|
||||
type CheckFailedError struct {
|
||||
Check Check
|
||||
}
|
||||
|
||||
func (e *CheckFailedError) Error() string {
|
||||
return "validation command failed"
|
||||
}
|
||||
|
||||
// CommittedStateError means the authoritative SQLite transition succeeded but
|
||||
// a derived Git ref needs repair. Callers must not retry the state transition.
|
||||
type CommittedStateError struct {
|
||||
State State
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *CommittedStateError) Error() string {
|
||||
return fmt.Sprintf("state %s is committed, but derived Git refs need repair: %v", e.State.ID, e.Err)
|
||||
}
|
||||
|
||||
func (e *CommittedStateError) Unwrap() error { return e.Err }
|
||||
|
||||
type ConflictError struct {
|
||||
Paths []string
|
||||
}
|
||||
|
||||
func (e *ConflictError) Error() string {
|
||||
return "proposal overlaps changes accepted since its canonical anchor"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,578 @@
|
||||
package hop
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInitPreservesGitBranchIndexAndWorkingTree(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
runGitTest(t, root, "init", "--quiet")
|
||||
writeTestFile(t, filepath.Join(root, "tracked.txt"), "committed\n")
|
||||
runGitTest(t, root, "add", "tracked.txt")
|
||||
runGitTest(t, root, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--quiet", "-m", "initial")
|
||||
|
||||
writeTestFile(t, filepath.Join(root, "tracked.txt"), "staged\n")
|
||||
runGitTest(t, root, "add", "tracked.txt")
|
||||
writeTestFile(t, filepath.Join(root, "tracked.txt"), "working\n")
|
||||
writeTestFile(t, filepath.Join(root, "untracked.txt"), "untracked\n")
|
||||
|
||||
beforeHead := runGitTest(t, root, "rev-parse", "HEAD")
|
||||
beforeBranch := runGitTest(t, root, "symbolic-ref", "--short", "HEAD")
|
||||
beforeIndex := runGitTest(t, root, "diff", "--cached", "--binary")
|
||||
beforeStatus := runGitTest(t, root, "status", "--porcelain=v1")
|
||||
|
||||
service, initial, err := InitProject(ctx, root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := runGitTest(t, root, "rev-parse", "HEAD"); got != beforeHead {
|
||||
t.Fatalf("HEAD moved from %s to %s", beforeHead, got)
|
||||
}
|
||||
if got := runGitTest(t, root, "symbolic-ref", "--short", "HEAD"); got != beforeBranch {
|
||||
t.Fatalf("branch changed from %s to %s", beforeBranch, got)
|
||||
}
|
||||
if got := runGitTest(t, root, "diff", "--cached", "--binary"); got != beforeIndex {
|
||||
t.Fatal("Hop init changed the user's index")
|
||||
}
|
||||
if got := runGitTest(t, root, "status", "--porcelain=v1"); got != beforeStatus {
|
||||
t.Fatalf("working status changed:\nwant %q\n got %q", beforeStatus, got)
|
||||
}
|
||||
assertTreeFiles(t, service, initial.GitCommit, map[string]string{
|
||||
"tracked.txt": "working\n",
|
||||
"untracked.txt": "untracked\n",
|
||||
})
|
||||
if err := service.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
service, again, err := InitProject(ctx, root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = service.Close() })
|
||||
if again.ID != initial.ID {
|
||||
t.Fatalf("idempotent init created %s, want existing %s", again.ID, initial.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRefusesAUserTrackedHopDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
runGitTest(t, root, "init", "--quiet")
|
||||
writeTestFile(t, filepath.Join(root, ".hop", "user-owned.txt"), "do not overwrite\n")
|
||||
runGitTest(t, root, "add", "-f", ".hop/user-owned.txt")
|
||||
_, _, err := InitProject(context.Background(), root)
|
||||
if err == nil || !strings.Contains(err.Error(), ".hop is already tracked") {
|
||||
t.Fatalf("InitProject error = %v, want tracked .hop refusal", err)
|
||||
}
|
||||
contents, readErr := os.ReadFile(filepath.Join(root, ".hop", "user-owned.txt"))
|
||||
if readErr != nil || string(contents) != "do not overwrite\n" {
|
||||
t.Fatalf("tracked .hop content changed: %q, %v", string(contents), readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIJSONWorkflow(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "base.txt"), "base\n")
|
||||
previousDirectory, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(previousDirectory) })
|
||||
|
||||
runCLIJSONTest(t, []string{"init", "--json"})
|
||||
started := runCLIJSONTest(t, []string{"start", "--agent", "fake", "--json", "Add CLI file"})
|
||||
data := objectField(t, started, "data")
|
||||
prompt := objectField(t, data, "prompt")
|
||||
promptID := stringField(t, prompt, "id")
|
||||
workspace := stringField(t, data, "workspace")
|
||||
if promptID == "" || workspace == "" {
|
||||
t.Fatal("start JSON omitted prompt ID or workspace")
|
||||
}
|
||||
writeTestFile(t, filepath.Join(workspace, "cli.txt"), "cli\n")
|
||||
|
||||
proposed := runCLIJSONTest(t, []string{"propose", "--summary", "CLI change", "--json", promptID})
|
||||
proposal := objectField(t, objectField(t, proposed, "data"), "proposal")
|
||||
proposalID := stringField(t, proposal, "id")
|
||||
accepted := runCLIJSONTest(t, []string{"land", proposalID, "--json", "--", "sh", "-c", "test -f cli.txt"})
|
||||
acceptedState := objectField(t, objectField(t, accepted, "data"), "state")
|
||||
if kind := stringField(t, acceptedState, "kind"); kind != string(StateAccepted) {
|
||||
t.Fatalf("landed kind = %q, want %q", kind, StateAccepted)
|
||||
}
|
||||
status := runCLIJSONTest(t, []string{"status", "--json"})
|
||||
statusData := objectField(t, status, "data")
|
||||
head := objectField(t, statusData, "accepted_head")
|
||||
if stringField(t, head, "id") != stringField(t, acceptedState, "id") {
|
||||
t.Fatal("status accepted head does not match landed state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorRejectsCommitTreeAndDigestMismatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, initial := newTestProject(t, map[string]string{"base.txt": "base\n"})
|
||||
emptyTree, err := service.Repo.EmptyTree(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.Store.db.ExecContext(ctx,
|
||||
`UPDATE states SET source_tree = ?, digest = 'tampered' WHERE id = ?`, emptyTree, initial.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report, err := service.Doctor(ctx, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatal("doctor approved a state whose commit tree and digest were tampered")
|
||||
}
|
||||
joined := strings.Join(report.Problems, "\n")
|
||||
if !strings.Contains(joined, "records tree") || !strings.Contains(joined, "digest mismatch") {
|
||||
t.Fatalf("doctor problems did not explain both mismatches: %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenProjectInsideFinalValidationDoesNotReacquireAcceptanceLock(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
|
||||
release, err := acquireProjectLock(ctx, service.Root, "accept")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer release()
|
||||
previous, hadPrevious := os.LookupEnv("HOP_ACCEPTANCE_LOCK_HELD")
|
||||
if err := os.Setenv("HOP_ACCEPTANCE_LOCK_HELD", "1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if hadPrevious {
|
||||
_ = os.Setenv("HOP_ACCEPTANCE_LOCK_HELD", previous)
|
||||
} else {
|
||||
_ = os.Unsetenv("HOP_ACCEPTANCE_LOCK_HELD")
|
||||
}
|
||||
})
|
||||
opened := make(chan error, 1)
|
||||
go func() {
|
||||
nested, openErr := OpenProject(service.Root)
|
||||
if openErr == nil {
|
||||
openErr = nested.Close()
|
||||
}
|
||||
opened <- openErr
|
||||
}()
|
||||
select {
|
||||
case openErr := <-opened:
|
||||
if openErr != nil {
|
||||
t.Fatal(openErr)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("OpenProject deadlocked by reacquiring the acceptance lock")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptFollowupAndProposalAreImmutable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, initial := newTestProject(t, map[string]string{"app.txt": "initial\n"})
|
||||
|
||||
exactPrompt := " Change the app\nwithout normalizing this text. "
|
||||
started, err := service.CreatePrompt(ctx, exactPrompt, "", "test-agent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if started.Prompt.SourceTree != initial.SourceTree {
|
||||
t.Fatalf("prompt tree = %s, want accepted tree %s", started.Prompt.SourceTree, initial.SourceTree)
|
||||
}
|
||||
if started.Prompt.Prompt != exactPrompt {
|
||||
t.Fatalf("stored prompt = %q, want exact %q", started.Prompt.Prompt, exactPrompt)
|
||||
}
|
||||
writeTestFile(t, filepath.Join(started.Workspace, "app.txt"), "first change\n")
|
||||
|
||||
followup, err := service.CreatePrompt(ctx, "Use the other approach", started.Prompt.ID, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if followup.Checkpoint == nil {
|
||||
t.Fatal("follow-up did not create a checkpoint")
|
||||
}
|
||||
if followup.Prompt.SourceTree != followup.Checkpoint.SourceTree {
|
||||
t.Fatal("follow-up prompt did not preserve the checkpoint tree")
|
||||
}
|
||||
if followup.Prompt.SourceTree == initial.SourceTree {
|
||||
t.Fatal("checkpoint failed to capture workspace edits")
|
||||
}
|
||||
parent, err := service.Store.ParentByRole(ctx, followup.Prompt.ID, "run_parent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parent.StateID != followup.Checkpoint.ID {
|
||||
t.Fatalf("follow-up parent = %s, want checkpoint %s", parent.StateID, followup.Checkpoint.ID)
|
||||
}
|
||||
|
||||
writeTestFile(t, filepath.Join(started.Workspace, "app.txt"), "proposed\n")
|
||||
proposed, err := service.Propose(ctx, followup.Prompt.ID, "Changed the app")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
frozenTree := proposed.Proposal.SourceTree
|
||||
writeTestFile(t, filepath.Join(started.Workspace, "app.txt"), "changed after proposal\n")
|
||||
stored, err := service.State(ctx, proposed.Proposal.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.SourceTree != frozenTree {
|
||||
t.Fatal("proposal tree changed after later workspace edits")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRunsAgainstTheRecordedCheckpointTree(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, _ := newTestProject(t, map[string]string{"app.txt": "initial\n"})
|
||||
started, err := service.CreatePrompt(ctx, "Check exact state", "", "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeTestFile(t, filepath.Join(started.Workspace, "app.txt"), "checkpointed\n")
|
||||
signal := filepath.Join(t.TempDir(), "check-started")
|
||||
type outcome struct {
|
||||
check Check
|
||||
err error
|
||||
}
|
||||
result := make(chan outcome, 1)
|
||||
go func() {
|
||||
check, checkErr := service.RunCheck(ctx, started.Prompt.ID, []string{
|
||||
"sh", "-c", `touch "$1"; sleep 0.2; cat app.txt`, "hop-check", signal,
|
||||
})
|
||||
result <- outcome{check: check, err: checkErr}
|
||||
}()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if _, err := os.Stat(signal); err == nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("validation command did not start")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
writeTestFile(t, filepath.Join(started.Workspace, "app.txt"), "raced edit\n")
|
||||
finished := <-result
|
||||
if finished.err != nil {
|
||||
t.Fatal(finished.err)
|
||||
}
|
||||
if finished.check.Output != "checkpointed\n" {
|
||||
t.Fatalf("check observed %q, want checkpointed tree", finished.check.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisjointProposalsLandAndUndoMovesForward(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, initial := newTestProject(t, map[string]string{"base.txt": "base\n"})
|
||||
|
||||
first, err := service.CreatePrompt(ctx, "Add one", "", "agent-one")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := service.CreatePrompt(ctx, "Add two", "", "agent-two")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeTestFile(t, filepath.Join(first.Workspace, "one.txt"), "one\n")
|
||||
writeTestFile(t, filepath.Join(second.Workspace, "two.txt"), "two\n")
|
||||
|
||||
proposalOne, err := service.Propose(ctx, first.Prompt.ID, "Add one")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proposalTwo, err := service.Propose(ctx, second.Prompt.ID, "Add two")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
acceptedOne, err := service.Accept(ctx, proposalOne.Proposal.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
acceptedTwo, err := service.Accept(ctx, proposalTwo.Proposal.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acceptedTwo.State.SourceTree == acceptedOne.State.SourceTree {
|
||||
t.Fatal("second disjoint acceptance did not change the accepted tree")
|
||||
}
|
||||
assertTreeFiles(t, service, acceptedTwo.State.GitCommit, map[string]string{
|
||||
"base.txt": "base\n",
|
||||
"one.txt": "one\n",
|
||||
"two.txt": "two\n",
|
||||
})
|
||||
|
||||
undo, err := service.Undo(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if undo.ID == acceptedOne.State.ID || undo.ID == acceptedTwo.State.ID {
|
||||
t.Fatal("undo rewrote an old state instead of creating a new one")
|
||||
}
|
||||
if undo.SourceTree != acceptedOne.State.SourceTree {
|
||||
t.Fatalf("undo tree = %s, want previous accepted tree %s", undo.SourceTree, acceptedOne.State.SourceTree)
|
||||
}
|
||||
if undo.SourceTree == initial.SourceTree {
|
||||
t.Fatal("undo erased more than the latest accepted transition")
|
||||
}
|
||||
assertTreeFiles(t, service, undo.GitCommit, map[string]string{
|
||||
"base.txt": "base\n",
|
||||
"one.txt": "one\n",
|
||||
})
|
||||
assertTreeMissing(t, service, undo.GitCommit, "two.txt")
|
||||
report, err := service.Doctor(ctx, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("doctor reported problems: %#v", report.Problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentDisjointAcceptancesSerialize(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
|
||||
|
||||
first, err := service.CreatePrompt(ctx, "Add alpha", "", "alpha")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := service.CreatePrompt(ctx, "Add beta", "", "beta")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeTestFile(t, filepath.Join(first.Workspace, "alpha.txt"), "alpha\n")
|
||||
writeTestFile(t, filepath.Join(second.Workspace, "beta.txt"), "beta\n")
|
||||
alpha, err := service.Propose(ctx, first.Prompt.ID, "Alpha")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
beta, err := service.Propose(ctx, second.Prompt.ID, "Beta")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
proposalIDs := []string{alpha.Proposal.ID, beta.Proposal.ID}
|
||||
errs := make(chan error, len(proposalIDs))
|
||||
var wg sync.WaitGroup
|
||||
for _, id := range proposalIDs {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, acceptErr := service.Accept(ctx, id, nil)
|
||||
errs <- acceptErr
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for acceptErr := range errs {
|
||||
if acceptErr != nil {
|
||||
t.Fatalf("concurrent acceptance: %v", acceptErr)
|
||||
}
|
||||
}
|
||||
head, err := service.Store.AcceptedHead(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertTreeFiles(t, service, head.GitCommit, map[string]string{
|
||||
"base.txt": "base\n",
|
||||
"alpha.txt": "alpha\n",
|
||||
"beta.txt": "beta\n",
|
||||
})
|
||||
history, err := service.History(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(history) != 3 {
|
||||
t.Fatalf("canonical history has %d states, want initial plus two acceptances", len(history))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlappingProposalAndFailedFinalCheckDoNotMoveHead(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, _ := newTestProject(t, map[string]string{"shared.txt": "base\n"})
|
||||
|
||||
first, err := service.CreatePrompt(ctx, "First edit", "", "one")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := service.CreatePrompt(ctx, "Second edit", "", "two")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeTestFile(t, filepath.Join(first.Workspace, "shared.txt"), "first\n")
|
||||
writeTestFile(t, filepath.Join(second.Workspace, "shared.txt"), "second\n")
|
||||
firstProposal, err := service.Propose(ctx, first.Prompt.ID, "First")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondProposal, err := service.Propose(ctx, second.Prompt.ID, "Second")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firstAccepted, err := service.Accept(ctx, firstProposal.Proposal.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = service.Accept(ctx, secondProposal.Proposal.ID, nil)
|
||||
var conflict *ConflictError
|
||||
if !errors.As(err, &conflict) {
|
||||
t.Fatalf("second acceptance error = %v, want ConflictError", err)
|
||||
}
|
||||
if len(conflict.Paths) != 1 || conflict.Paths[0] != "shared.txt" {
|
||||
t.Fatalf("conflict paths = %#v", conflict.Paths)
|
||||
}
|
||||
head, err := service.Store.AcceptedHead(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if head.ID != firstAccepted.State.ID {
|
||||
t.Fatalf("blocked proposal moved accepted head to %s", head.ID)
|
||||
}
|
||||
|
||||
third, err := service.CreatePrompt(ctx, "Add safe file", "", "three")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeTestFile(t, filepath.Join(third.Workspace, "safe.txt"), "safe\n")
|
||||
thirdProposal, err := service.Propose(ctx, third.Prompt.ID, "Safe")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = service.Accept(ctx, thirdProposal.Proposal.ID, []string{"sh", "-c", "exit 9"})
|
||||
var checkFailed *CheckFailedError
|
||||
if !errors.As(err, &checkFailed) {
|
||||
t.Fatalf("failed validation error = %v, want CheckFailedError", err)
|
||||
}
|
||||
if checkFailed.Check.StateID == "" {
|
||||
t.Fatal("failed final-tree validation was not attached to a durable state")
|
||||
}
|
||||
failedState, err := service.Store.GetState(ctx, checkFailed.Check.StateID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failedState.Kind != StateFailed || failedState.SourceTree != checkFailed.Check.TreeHash {
|
||||
t.Fatalf("failed state = %#v, check tree = %s", failedState, checkFailed.Check.TreeHash)
|
||||
}
|
||||
head, err = service.Store.AcceptedHead(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if head.ID != firstAccepted.State.ID {
|
||||
t.Fatal("failed final-tree validation moved accepted head")
|
||||
}
|
||||
}
|
||||
|
||||
func newTestProject(t *testing.T, files map[string]string) (*Service, State) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for path, contents := range files {
|
||||
writeTestFile(t, filepath.Join(root, path), contents)
|
||||
}
|
||||
service, initial, err := InitProject(context.Background(), root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = service.Close() })
|
||||
return service, initial
|
||||
}
|
||||
|
||||
func writeTestFile(t *testing.T, path, contents string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertTreeFiles(t *testing.T, service *Service, commit string, expected map[string]string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "materialized")
|
||||
if _, err := service.Repo.AddDetachedWorktree(context.Background(), path, commit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = service.Repo.RemoveWorktree(context.Background(), path, true) })
|
||||
for name, want := range expected {
|
||||
contents, err := os.ReadFile(filepath.Join(path, name))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
if string(contents) != want {
|
||||
t.Fatalf("%s = %q, want %q", name, string(contents), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertTreeMissing(t *testing.T, service *Service, commit string, name string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "materialized")
|
||||
if _, err := service.Repo.AddDetachedWorktree(context.Background(), path, commit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = service.Repo.RemoveWorktree(context.Background(), path, true) })
|
||||
if _, err := os.Stat(filepath.Join(path, name)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("expected %s to be absent, stat error = %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func runGitTest(t *testing.T, root string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = root
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, stderr.String())
|
||||
}
|
||||
return strings.TrimSpace(stdout.String())
|
||||
}
|
||||
|
||||
func runCLIJSONTest(t *testing.T, args []string) map[string]any {
|
||||
t.Helper()
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := RunCLI(args, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("hop %s exited %d\nstdout: %s\nstderr: %s", strings.Join(args, " "), code, stdout.String(), stderr.String())
|
||||
}
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
|
||||
t.Fatalf("decode JSON from hop %s: %v\n%s", strings.Join(args, " "), err, stdout.String())
|
||||
}
|
||||
if ok, _ := result["ok"].(bool); !ok {
|
||||
t.Fatalf("hop %s returned non-ok JSON: %#v", strings.Join(args, " "), result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func objectField(t *testing.T, object map[string]any, key string) map[string]any {
|
||||
t.Helper()
|
||||
value, ok := object[key].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("field %q is not an object: %#v", key, object[key])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func stringField(t *testing.T, object map[string]any, key string) string {
|
||||
t.Helper()
|
||||
value, ok := object[key].(string)
|
||||
if !ok {
|
||||
t.Fatalf("field %q is not a string: %#v", key, object[key])
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package hop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
hopskill "github.com/hop-vcs/hop/skills/hop"
|
||||
)
|
||||
|
||||
type SkillInstallResult struct {
|
||||
Path string `json:"path"`
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
|
||||
func DefaultSkillBase() (string, error) {
|
||||
home := os.Getenv("CODEX_HOME")
|
||||
if home == "" {
|
||||
userHome, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("find home directory: %w", err)
|
||||
}
|
||||
home = filepath.Join(userHome, ".codex")
|
||||
}
|
||||
return filepath.Join(home, "skills"), nil
|
||||
}
|
||||
|
||||
func EmbeddedSkillText() (string, error) {
|
||||
contents, err := hopskill.Files.ReadFile("SKILL.md")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read embedded Hop skill: %w", err)
|
||||
}
|
||||
return string(contents), nil
|
||||
}
|
||||
|
||||
// InstallSkill writes the embedded skill below a skills directory. Existing
|
||||
// bundles require force; known files are overwritten without deleting unknown
|
||||
// user files from the destination.
|
||||
func InstallSkill(base string, force bool) (SkillInstallResult, error) {
|
||||
if strings.TrimSpace(base) == "" {
|
||||
var err error
|
||||
base, err = DefaultSkillBase()
|
||||
if err != nil {
|
||||
return SkillInstallResult{}, err
|
||||
}
|
||||
}
|
||||
absBase, err := filepath.Abs(base)
|
||||
if err != nil {
|
||||
return SkillInstallResult{}, fmt.Errorf("resolve skills directory: %w", err)
|
||||
}
|
||||
target := filepath.Join(absBase, "hop")
|
||||
if info, err := os.Lstat(target); err == nil {
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return SkillInstallResult{}, fmt.Errorf("refusing to install through symlink %s", target)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return SkillInstallResult{}, fmt.Errorf("skill target exists and is not a directory: %s", target)
|
||||
}
|
||||
if !force {
|
||||
return SkillInstallResult{}, fmt.Errorf("Hop skill already exists at %s; pass --force to update it", target)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return SkillInstallResult{}, fmt.Errorf("inspect skill target: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
return SkillInstallResult{}, fmt.Errorf("create skill target: %w", err)
|
||||
}
|
||||
|
||||
result := SkillInstallResult{Path: target}
|
||||
err = fs.WalkDir(hopskill.Files, ".", func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if path == "." {
|
||||
return nil
|
||||
}
|
||||
destination := filepath.Join(target, filepath.FromSlash(path))
|
||||
if entry.IsDir() {
|
||||
return os.MkdirAll(destination, 0o755)
|
||||
}
|
||||
if info, statErr := os.Lstat(destination); statErr == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("refusing to overwrite symlink %s", destination)
|
||||
} else if statErr != nil && !os.IsNotExist(statErr) {
|
||||
return statErr
|
||||
}
|
||||
contents, readErr := hopskill.Files.ReadFile(path)
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
if writeErr := os.WriteFile(destination, contents, 0o644); writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
result.Files = append(result.Files, path)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return SkillInstallResult{}, fmt.Errorf("install Hop skill: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package hop
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallSkillBundle(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
result, err := InstallSkill(base, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Path != filepath.Join(base, "hop") {
|
||||
t.Fatalf("skill path = %s", result.Path)
|
||||
}
|
||||
wantFiles := []string{"SKILL.md", "agents/openai.yaml", "references/protocol.md"}
|
||||
for _, relative := range wantFiles {
|
||||
contents, err := os.ReadFile(filepath.Join(result.Path, relative))
|
||||
if err != nil {
|
||||
t.Fatalf("read installed %s: %v", relative, err)
|
||||
}
|
||||
if len(contents) == 0 {
|
||||
t.Fatalf("installed %s is empty", relative)
|
||||
}
|
||||
}
|
||||
if _, err := InstallSkill(base, false); err == nil || !strings.Contains(err.Error(), "already exists") {
|
||||
t.Fatalf("second install error = %v, want existing-skill error", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(result.Path, "SKILL.md"), []byte("tampered"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := InstallSkill(base, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contents, err := os.ReadFile(filepath.Join(result.Path, "SKILL.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(contents) == "tampered" || !strings.Contains(string(contents), "name: hop") {
|
||||
t.Fatal("forced skill update did not restore the embedded bundle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillCLIWorksOutsideHopProject(t *testing.T) {
|
||||
var stdout, stderr strings.Builder
|
||||
base := t.TempDir()
|
||||
code := RunCLI([]string{"skill", "install", "--path", base, "--json"}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("skill install exited %d: %s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), filepath.Join(base, "hop")) {
|
||||
t.Fatalf("skill install JSON omitted target: %s", stdout.String())
|
||||
}
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
code = RunCLI([]string{"skill", "print"}, &stdout, &stderr)
|
||||
if code != 0 || !strings.Contains(stdout.String(), "Require a durable Hop prompt state") {
|
||||
t.Fatalf("skill print exited %d\nstdout: %s\nstderr: %s", code, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package hop
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxRecordedOutput = 128 * 1024
|
||||
|
||||
func FindHopRoot(start string) (string, error) {
|
||||
if configured := os.Getenv("HOP_ROOT"); configured != "" {
|
||||
return requireHopRoot(configured)
|
||||
}
|
||||
|
||||
abs, err := filepath.Abs(start)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
info, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
abs = filepath.Dir(abs)
|
||||
}
|
||||
|
||||
for dir := abs; ; dir = filepath.Dir(dir) {
|
||||
if _, err := os.Stat(filepath.Join(dir, ".hop", "hop.db")); err == nil {
|
||||
return dir, nil
|
||||
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("not inside a Hop project; run 'hop init' first")
|
||||
}
|
||||
|
||||
func requireHopRoot(root string) (string, error) {
|
||||
abs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(abs, ".hop", "hop.db")); err != nil {
|
||||
return "", fmt.Errorf("HOP_ROOT does not contain .hop/hop.db: %s", abs)
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
func digestState(state State, parents []Parent) (string, error) {
|
||||
copyState := state
|
||||
copyState.Digest = ""
|
||||
copyState.Parents = append([]Parent(nil), parents...)
|
||||
sort.Slice(copyState.Parents, func(i, j int) bool {
|
||||
if copyState.Parents[i].Order != copyState.Parents[j].Order {
|
||||
return copyState.Parents[i].Order < copyState.Parents[j].Order
|
||||
}
|
||||
if copyState.Parents[i].Role != copyState.Parents[j].Role {
|
||||
return copyState.Parents[i].Role < copyState.Parents[j].Role
|
||||
}
|
||||
return copyState.Parents[i].StateID < copyState.Parents[j].StateID
|
||||
})
|
||||
payload, err := json.Marshal(copyState)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(payload)
|
||||
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
type commandResult struct {
|
||||
ExitCode int
|
||||
Output string
|
||||
}
|
||||
|
||||
func runWorkspaceCommand(ctx context.Context, workspace string, env []string, argv []string) (commandResult, error) {
|
||||
if len(argv) == 0 {
|
||||
return commandResult{}, fmt.Errorf("no command provided")
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||
cmd.Dir = workspace
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
var output bytes.Buffer
|
||||
cmd.Stdout = &output
|
||||
cmd.Stderr = &output
|
||||
err := cmd.Run()
|
||||
result := commandResult{Output: truncateRecordedOutput(output.String())}
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
result.ExitCode = exitErr.ExitCode()
|
||||
return result, nil
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func truncateRecordedOutput(output string) string {
|
||||
if len(output) <= maxRecordedOutput {
|
||||
return output
|
||||
}
|
||||
const marker = "\n… output truncated by Hop …\n"
|
||||
keep := maxRecordedOutput - len(marker)
|
||||
return output[:keep/2] + marker + output[len(output)-(keep-keep/2):]
|
||||
}
|
||||
|
||||
func acquireProjectLock(ctx context.Context, root, name string) (func(), error) {
|
||||
path := filepath.Join(root, ".hop", name+".lock")
|
||||
for {
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err == nil {
|
||||
_, _ = file.WriteString(fmt.Sprintf("pid=%d\n", os.Getpid()))
|
||||
_ = file.Close()
|
||||
stopHeartbeat := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
now := time.Now()
|
||||
_ = os.Chtimes(path, now, now)
|
||||
case <-stopHeartbeat:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
close(stopHeartbeat)
|
||||
_ = os.Remove(path)
|
||||
})
|
||||
}, nil
|
||||
}
|
||||
if !errors.Is(err, fs.ErrExist) {
|
||||
return nil, err
|
||||
}
|
||||
if info, statErr := os.Stat(path); statErr == nil && time.Since(info.ModTime()) > 5*time.Minute {
|
||||
_ = os.Remove(path)
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("wait for Hop %s lock: %w", name, ctx.Err())
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shellQuote(argv []string) string {
|
||||
quoted := make([]string, len(argv))
|
||||
for i, arg := range argv {
|
||||
if arg != "" && !strings.ContainsAny(arg, " \t\n\"'\\$`;&|<>()[]{}*?!") {
|
||||
quoted[i] = arg
|
||||
continue
|
||||
}
|
||||
quoted[i] = "'" + strings.ReplaceAll(arg, "'", "'\"'\"'") + "'"
|
||||
}
|
||||
return strings.Join(quoted, " ")
|
||||
}
|
||||
Reference in New Issue
Block a user