add harness monitoring ingestion and discovery
This commit is contained in:
@@ -3,6 +3,7 @@ package herdr
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -22,6 +23,9 @@ type TurnBoundary interface {
|
||||
type RotationSignal interface {
|
||||
RotationSignal(context.Context, Session) (string, error)
|
||||
}
|
||||
type PaneExit interface {
|
||||
PaneExited(context.Context, Session) (bool, error)
|
||||
}
|
||||
type CLIAdapter struct {
|
||||
Client *Client
|
||||
Harness string
|
||||
@@ -61,6 +65,15 @@ func (a CLIAdapter) AtTurnBoundary(ctx context.Context, s Session) (bool, error)
|
||||
}
|
||||
return !IsBusy(r.Status), nil
|
||||
}
|
||||
func (a CLIAdapter) PaneExited(ctx context.Context, s Session) (bool, error) {
|
||||
var r struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := a.Client.Call(ctx, "pane.status", s, &r); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return strings.EqualFold(r.Status, "exited") || strings.EqualFold(r.Status, "dead"), nil
|
||||
}
|
||||
func (a CLIAdapter) RotationSignal(ctx context.Context, s Session) (string, error) {
|
||||
var r struct {
|
||||
Reason string `json:"reason"`
|
||||
|
||||
@@ -2,9 +2,16 @@ package herdr
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Usage struct{ Input, CacheRead, CacheWrite, Output int64 }
|
||||
@@ -48,6 +55,68 @@ func ClaudeUsage(p string) (Usage, error) {
|
||||
}
|
||||
return last, s.Err()
|
||||
}
|
||||
|
||||
// ClaudeStopHook is the JSON contract supplied by Claude Code's stop hook.
|
||||
// Keeping the hook parser here makes ingestion independent of the hook's shell.
|
||||
type ClaudeStopHook struct {
|
||||
TranscriptPath string `json:"transcript_path"`
|
||||
}
|
||||
|
||||
func ClaudeStopHookUsage(r io.Reader) (Usage, string, error) {
|
||||
var h ClaudeStopHook
|
||||
if err := json.NewDecoder(r).Decode(&h); err != nil {
|
||||
return Usage{}, "", err
|
||||
}
|
||||
if h.TranscriptPath == "" {
|
||||
return Usage{}, "", fmt.Errorf("claude stop hook: transcript_path required")
|
||||
}
|
||||
u, err := ClaudeUsage(h.TranscriptPath)
|
||||
return u, h.TranscriptPath, err
|
||||
}
|
||||
|
||||
// CodexRolloutPaths discovers active rollouts from configured Codex state.
|
||||
// sqlite3 is intentionally used as an optional bridge: Codex owns the schema
|
||||
// and deployments may not ship a Go sqlite driver.
|
||||
func CodexRolloutPaths(home string) ([]string, error) {
|
||||
if home == "" {
|
||||
home = os.Getenv("CODEX_HOME")
|
||||
}
|
||||
if home == "" {
|
||||
home = filepath.Join(os.Getenv("HOME"), ".codex")
|
||||
}
|
||||
var paths []string
|
||||
matches, _ := filepath.Glob(filepath.Join(home, "state_*.sqlite"))
|
||||
for _, db := range matches {
|
||||
out, err := exec.Command("sqlite3", db, "select rollout_path from threads where rollout_path is not null;").Output()
|
||||
if err == nil {
|
||||
for _, p := range strings.Fields(string(out)) {
|
||||
if p != "" {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
paths, _ = filepath.Glob(filepath.Join(home, "sessions", "*", "*", "*", "rollout-*.jsonl"))
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return nil, fmt.Errorf("codex: no active rollout found in %s", home)
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
func CodexActiveUsage(home string) (Usage, string, error) {
|
||||
paths, err := CodexRolloutPaths(home)
|
||||
if err != nil {
|
||||
return Usage{}, "", err
|
||||
}
|
||||
for i := len(paths) - 1; i >= 0; i-- {
|
||||
if _, e := os.Stat(paths[i]); e == nil {
|
||||
u, e := CodexUsage(paths[i])
|
||||
return u, paths[i], e
|
||||
}
|
||||
}
|
||||
return Usage{}, "", os.ErrNotExist
|
||||
}
|
||||
func CodexUsage(p string) (Usage, error) {
|
||||
f, e := os.Open(p)
|
||||
if e != nil {
|
||||
@@ -93,4 +162,33 @@ func OpenCodeUsage(p string) (Usage, error) {
|
||||
e = json.NewDecoder(f).Decode(&x)
|
||||
return Usage{x.Tokens.Input, x.Tokens.Cache.Read, x.Tokens.Cache.Write, x.Tokens.Output}, e
|
||||
}
|
||||
|
||||
// OpenCodeStatus probes the server fast path. Callers can use the returned
|
||||
// status and fall back to OpenCodeUsage when the SSE/server is unavailable.
|
||||
func OpenCodeStatus(ctx context.Context, baseURL, sessionID string) (string, error) {
|
||||
if baseURL == "" {
|
||||
baseURL = "http://127.0.0.1:4096"
|
||||
}
|
||||
u := strings.TrimRight(baseURL, "/") + "/session/" + sessionID
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
c := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return "", fmt.Errorf("opencode status: http %s", resp.Status)
|
||||
}
|
||||
var x struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&x); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return x.Status, nil
|
||||
}
|
||||
func IsBusy(s string) bool { return strings.EqualFold(s, "busy") }
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package herdr
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClaudeStopHookUsage(t *testing.T) {
|
||||
// The hook parser's path validation is independent from transcript IO.
|
||||
_, path, err := ClaudeStopHookUsage(strings.NewReader(`{"transcript_path":"/tmp/transcript.jsonl"}`))
|
||||
if path != "/tmp/transcript.jsonl" || err == nil {
|
||||
t.Fatalf("path=%q err=%v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexRolloutFallback(t *testing.T) {
|
||||
paths, err := CodexRolloutPaths(t.TempDir())
|
||||
if err == nil || len(paths) != 0 {
|
||||
t.Fatalf("paths=%v err=%v", paths, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user