Skip to content

Commit 5a70a5d

Browse files
committed
feat: extract herm features, move convodag to eyrie, add provider fallback
- Remove convodag/ package (moved to eyrie/storage.DAG) - Import eyrie/storage directly in cmd/chat.go and engine/session.go - engine/subagent_budget.go: mode budgets (explore/general), turn limits, tool allowlists - engine/subagent_synthesis.go: graceful synthesis when budget exceeded - engine/project_snapshot.go: TTL-cached project context (10s, explore-mode strip) - container/lifecycle.go: Status, ExecWithStdin, Rebuild, EnsureRunning - tool/descriptions.go + descriptions/*.md: embedded tool descriptions from markdown - plugin/skill_loader.go: load skills from project .hawk/skills/ directory - metrics/tool_aggregates.go: per-tool call count, bytes, duration, errors - hawkerr/bridge_error_test.go + sessioncapture/bridge_test.go: coverage for untested packages - cmd/options.go: provider fallback when configured provider key missing - config/settings.go: GROK_API_KEY alternate lookup - plugin/manager_test.go: fix flaky timeouts (5s -> 15s) - Remove dead cmd/fuzzy.go stub
1 parent d6b358b commit 5a70a5d

28 files changed

Lines changed: 1652 additions & 857 deletions

cmd/chat.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import (
2222
"github.com/charmbracelet/lipgloss"
2323

2424
hawkconfig "github.com/GrayCodeAI/hawk/config"
25-
"github.com/GrayCodeAI/hawk/convodag"
25+
"github.com/GrayCodeAI/eyrie/storage"
2626
"github.com/GrayCodeAI/hawk/engine"
2727
"github.com/GrayCodeAI/hawk/logger"
2828
"github.com/GrayCodeAI/hawk/memory"
@@ -215,7 +215,7 @@ func newChatModel(ref *progRef, systemPrompt string, settings hawkconfig.Setting
215215
// Initialize conversation DAG for branching support
216216
if home, err := os.UserHomeDir(); err == nil {
217217
dagPath := filepath.Join(home, ".hawk", "sessions", "convo.db")
218-
if dag, err := convodag.New(dagPath, sid); err == nil {
218+
if dag, err := storage.NewDAG(dagPath, sid); err == nil {
219219
sess.ConvoDAG = dag
220220
}
221221
}

container/lifecycle.go

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// Package container provides Docker container lifecycle management for hawk's
2+
// sandboxed execution environments. It wraps the Docker CLI to start, stop,
3+
// inspect, and rebuild containers.
4+
package container
5+
6+
import (
7+
"bytes"
8+
"context"
9+
"fmt"
10+
"os/exec"
11+
"strings"
12+
"time"
13+
)
14+
15+
// Error codes returned by ContainerError.
16+
const (
17+
CodeNotFound = "not_found"
18+
CodeNotRunning = "not_running"
19+
CodeExecFailed = "exec_failed"
20+
CodeTimeout = "timeout"
21+
)
22+
23+
// ContainerError is a typed error with a machine-readable Code field.
24+
type ContainerError struct {
25+
Code string
26+
Message string
27+
}
28+
29+
func (e *ContainerError) Error() string {
30+
return fmt.Sprintf("%s: %s", e.Code, e.Message)
31+
}
32+
33+
// State constants returned by Status.
34+
const (
35+
StateRunning = "running"
36+
StateStopped = "exited"
37+
StateNotFound = "not_found"
38+
)
39+
40+
// dockerCmd is a function variable for exec.CommandContext, replaceable in tests.
41+
var dockerCmd = exec.CommandContext
42+
43+
// lookPath is a function variable for exec.LookPath, replaceable in tests.
44+
var lookPath = exec.LookPath
45+
46+
// Status queries Docker for the current state of a container.
47+
// Returns StateRunning, StateStopped, or StateNotFound.
48+
func Status(ctx context.Context, containerID string) (string, error) {
49+
if containerID == "" {
50+
return StateNotFound, nil
51+
}
52+
53+
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
54+
defer cancel()
55+
56+
cmd := dockerCmd(ctx, "docker", "inspect", "--format", "{{.State.Status}}", containerID)
57+
var stdout, stderr bytes.Buffer
58+
cmd.Stdout = &stdout
59+
cmd.Stderr = &stderr
60+
61+
if err := cmd.Run(); err != nil {
62+
// If inspect fails, the container doesn't exist.
63+
errMsg := strings.TrimSpace(stderr.String())
64+
if strings.Contains(errMsg, "No such object") || strings.Contains(errMsg, "not found") {
65+
return StateNotFound, nil
66+
}
67+
return StateNotFound, &ContainerError{
68+
Code: CodeNotFound,
69+
Message: fmt.Sprintf("docker inspect: %s", errMsg),
70+
}
71+
}
72+
73+
state := strings.TrimSpace(stdout.String())
74+
switch state {
75+
case "running":
76+
return StateRunning, nil
77+
case "exited", "dead", "created":
78+
return StateStopped, nil
79+
default:
80+
return state, nil
81+
}
82+
}
83+
84+
// ExecResult holds the output of a command executed in a container.
85+
type ExecResult struct {
86+
Stdout string
87+
Stderr string
88+
ExitCode int
89+
}
90+
91+
// ExecWithStdin runs a command inside a container with stdin piped from the
92+
// provided byte slice. Arguments are passed directly to docker exec without
93+
// shell wrapping, making it safe for structured input (e.g. JSON).
94+
func ExecWithStdin(ctx context.Context, containerID string, cmd []string, stdin []byte) (*ExecResult, error) {
95+
if containerID == "" {
96+
return nil, &ContainerError{Code: CodeNotFound, Message: "no container ID"}
97+
}
98+
99+
args := append([]string{"exec", "-i", containerID}, cmd...)
100+
c := dockerCmd(ctx, "docker", args...)
101+
c.Stdin = bytes.NewReader(stdin)
102+
103+
var stdout, stderr bytes.Buffer
104+
c.Stdout = &stdout
105+
c.Stderr = &stderr
106+
107+
err := c.Run()
108+
if ctx.Err() == context.DeadlineExceeded {
109+
return nil, &ContainerError{Code: CodeTimeout, Message: "command timed out"}
110+
}
111+
112+
exitCode := 0
113+
if err != nil {
114+
if exitErr, ok := err.(*exec.ExitError); ok {
115+
exitCode = exitErr.ExitCode()
116+
} else {
117+
return nil, &ContainerError{
118+
Code: CodeExecFailed,
119+
Message: fmt.Sprintf("docker exec: %v", err),
120+
}
121+
}
122+
}
123+
124+
return &ExecResult{
125+
Stdout: stdout.String(),
126+
Stderr: stderr.String(),
127+
ExitCode: exitCode,
128+
}, nil
129+
}
130+
131+
// Rebuild stops and removes an existing container, then creates a new one
132+
// from the specified image. The new container is started with sleep infinity
133+
// and the given working directory mounted.
134+
func Rebuild(ctx context.Context, containerID, newImage, workDir string) (string, error) {
135+
// Stop and remove old container if it exists.
136+
if containerID != "" {
137+
rmCtx, rmCancel := context.WithTimeout(ctx, 10*time.Second)
138+
defer rmCancel()
139+
rm := dockerCmd(rmCtx, "docker", "rm", "-f", containerID)
140+
_ = rm.Run()
141+
}
142+
143+
// Create new container.
144+
createCtx, createCancel := context.WithTimeout(ctx, 120*time.Second)
145+
defer createCancel()
146+
147+
args := []string{
148+
"run", "-d",
149+
"-v", workDir + ":" + workDir,
150+
"-w", workDir,
151+
newImage,
152+
"sleep", "infinity",
153+
}
154+
c := dockerCmd(createCtx, "docker", args...)
155+
var stdout, stderr bytes.Buffer
156+
c.Stdout = &stdout
157+
c.Stderr = &stderr
158+
159+
if err := c.Run(); err != nil {
160+
return "", &ContainerError{
161+
Code: CodeExecFailed,
162+
Message: fmt.Sprintf("docker run: %s", strings.TrimSpace(stderr.String())),
163+
}
164+
}
165+
166+
return strings.TrimSpace(stdout.String()), nil
167+
}
168+
169+
// EnsureRunning guarantees that the specified container is in a running state.
170+
// If the container is stopped, it starts it. If it does not exist, it creates
171+
// a new one from the given image with the working directory mounted.
172+
// Returns the container ID (which may differ from input if recreated).
173+
func EnsureRunning(ctx context.Context, containerID, image, workDir string) (string, error) {
174+
state, err := Status(ctx, containerID)
175+
if err != nil {
176+
// If we can't determine state, try to create fresh.
177+
return Rebuild(ctx, "", image, workDir)
178+
}
179+
180+
switch state {
181+
case StateRunning:
182+
return containerID, nil
183+
184+
case StateStopped:
185+
// Try to start the existing container.
186+
startCtx, startCancel := context.WithTimeout(ctx, 30*time.Second)
187+
defer startCancel()
188+
start := dockerCmd(startCtx, "docker", "start", containerID)
189+
var stderr bytes.Buffer
190+
start.Stderr = &stderr
191+
if err := start.Run(); err != nil {
192+
// Start failed — rebuild from scratch.
193+
return Rebuild(ctx, containerID, image, workDir)
194+
}
195+
return containerID, nil
196+
197+
case StateNotFound:
198+
return Rebuild(ctx, "", image, workDir)
199+
200+
default:
201+
// Unknown state — rebuild.
202+
return Rebuild(ctx, containerID, image, workDir)
203+
}
204+
}

0 commit comments

Comments
 (0)