Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions backend/internal/adapters/runtime/zellij/zellij.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,19 @@ type Runtime struct {

var _ ports.Runtime = (*Runtime)(nil)

// DefaultSocketDir returns a short, stable ZELLIJ_SOCKET_DIR for AO's daemon.
// zellij's own default lives under $TMPDIR (long on macOS), which leaves almost
// none of the ~103-byte unix-socket-path budget for the session name — a long
// session id then fails with "session name must be less than 0 characters". A
// short dir restores ample budget. Empty on Windows, where zellij is not used.
// Pure: callers that run zellij should MkdirAll the result.
func DefaultSocketDir() string {
if runtime.GOOS == "windows" {
return ""
}
return "/tmp/ao-zellij-" + strconv.Itoa(os.Getuid())
}

type runner interface {
Run(ctx context.Context, env []string, name string, args ...string) ([]byte, error)
}
Expand Down Expand Up @@ -330,10 +343,18 @@ func zellijSessionName(id domain.SessionID) (string, error) {
if raw == "" {
return "", errors.New("zellij runtime: session id is required")
}
if sessionIDPattern.MatchString(raw) && len(raw) <= 48 {
return raw, nil
return SessionName(raw), nil
}

// SessionName returns the zellij session name the runtime registers for a given
// session id — applying the same sanitisation Create does. Callers that print an
// attach hint (e.g. `ao spawn`) must use this rather than the raw id, since a
// long or non-conforming id maps to a different, sanitised session name.
func SessionName(id string) string {
if sessionIDPattern.MatchString(id) && len(id) <= 48 {
return id
}
return sanitizedSessionName(raw), nil
return sanitizedSessionName(id)
}

func sanitizedSessionName(raw string) string {
Expand Down
25 changes: 25 additions & 0 deletions backend/internal/adapters/runtime/zellij/zellij_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"testing"
"time"

"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)

Expand Down Expand Up @@ -59,6 +60,30 @@ func TestZellijSessionNameSanitizesIssueRefs(t *testing.T) {
}
}

// SessionName must return the exact name Create registers a session under, so
// callers that print an attach hint (e.g. `ao spawn`) reference the real
// session. A short, conforming id passes through; a long one is sanitised to a
// different name — printing the raw id there would send users to a missing
// session.
func TestSessionNameMatchesCreateNaming(t *testing.T) {
short := "myproj-1"
if got := SessionName(short); got != short {
t.Fatalf("SessionName(%q) = %q, want it unchanged", short, got)
}

long := domain.SessionID(strings.Repeat("x", 60) + "-1")
viaCreate, err := zellijSessionName(long)
if err != nil {
t.Fatalf("zellijSessionName: %v", err)
}
if got := SessionName(string(long)); got != viaCreate {
t.Fatalf("SessionName = %q, but Create uses %q", got, viaCreate)
}
if SessionName(string(long)) == string(long) {
t.Fatal("expected a long id to be sanitised to a different name")
}
}

func TestValidateSessionAndPaneID(t *testing.T) {
for _, id := range []string{"sess-1", "S_2", "abc123"} {
if err := validateSessionID(id); err != nil {
Expand Down
97 changes: 97 additions & 0 deletions backend/internal/cli/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package cli

import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"

"github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
)

// commandTimeout bounds a mutating daemon call. Spawns do real work (git
// worktree add, zellij launch, hook install), so it is generous compared to the
// status probe timeout.
const commandTimeout = 2 * time.Minute

// apiError is the subset of the daemon's JSON error envelope the CLI surfaces.
// RequestID is surfaced so a failed command can be correlated with daemon logs.
type apiError struct {
Message string `json:"message"`
Code string `json:"code"`
RequestID string `json:"requestId"`
}

// String renders the envelope for the user: "<message> (<code>) [request <id>]",
// omitting whichever parts the daemon left empty.
func (e apiError) String() string {
msg := e.Message
if e.Code != "" {
msg = fmt.Sprintf("%s (%s)", msg, e.Code)
}
if e.RequestID != "" {
msg = fmt.Sprintf("%s [request %s]", msg, e.RequestID)
}
return msg
}

// postJSON sends body as JSON to POST /api/v1/<path> on the running daemon and
// decodes a 2xx response into out (out may be nil). A non-2xx response becomes
// an error built from the API error envelope. A missing run-file or a stale one
// (dead PID) yields a clear "not running" message rather than a
// connection-refused dump.
func (c *commandContext) postJSON(ctx context.Context, path string, body, out any) error {
cfg, err := config.Load()
if err != nil {
return err
}
info, err := runfile.Read(cfg.RunFilePath)
if err != nil {
return err
}
if info == nil {
return fmt.Errorf("AO daemon is not running — start it with `ao start`")
}
Comment thread
yyovil marked this conversation as resolved.
if !c.deps.ProcessAlive(info.PID) {
return fmt.Errorf("AO daemon is not running (stale run-file at %s) — start it with `ao start`", cfg.RunFilePath)
}

payload, err := json.Marshal(body)
if err != nil {
return err
}
url := fmt.Sprintf("http://%s:%d/api/v1/%s", config.LoopbackHost, info.Port, path)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")

// Reuse the injected client's transport (keeps it stubbable in tests) but
// give mutating calls far more headroom than the 2s status-probe timeout.
client := *c.deps.HTTPClient
client.Timeout = commandTimeout
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("call daemon: %w", err)
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var e apiError
_ = json.NewDecoder(resp.Body).Decode(&e)
if e.Message == "" {
return fmt.Errorf("daemon returned HTTP %d", resp.StatusCode)
}
return fmt.Errorf("%s", e.String())
}
if out != nil {
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("decode response: %w", err)
}
}
return nil
}
25 changes: 25 additions & 0 deletions backend/internal/cli/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package cli

import "testing"

// TestAPIErrorString covers how the CLI renders the daemon's error envelope,
// including the requestId it now surfaces for log correlation.
func TestAPIErrorString(t *testing.T) {
cases := []struct {
name string
in apiError
want string
}{
{"message only", apiError{Message: "boom"}, "boom"},
{"message and code", apiError{Message: "boom", Code: "X"}, "boom (X)"},
{"with request id", apiError{Message: "boom", Code: "X", RequestID: "req-1"}, "boom (X) [request req-1]"},
{"message and request id", apiError{Message: "boom", RequestID: "req-1"}, "boom [request req-1]"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.in.String(); got != tc.want {
t.Fatalf("String() = %q, want %q", got, tc.want)
}
})
}
}
Loading
Loading