619 lines
17 KiB
Go
619 lines
17 KiB
Go
package fs
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"ollie/agent"
|
|
olog "ollie/log"
|
|
coresession "ollie/session"
|
|
"ollie/skills"
|
|
"ollie/toolsrv"
|
|
)
|
|
|
|
// Event ring buffer — global lifecycle events for session/agent changes.
|
|
// Consumers read from /eventwait (blocking, offset-based).
|
|
const eventRingCap = 100
|
|
|
|
type eventRing struct {
|
|
mu sync.Mutex
|
|
cond sync.Cond
|
|
buf []string // circular: buf[start%cap]..buf[(start+count-1)%cap]
|
|
cap int
|
|
start int
|
|
count int
|
|
total int64 // monotonic counter; doubles as stable offset
|
|
}
|
|
|
|
var evRing = func() eventRing {
|
|
e := eventRing{cap: eventRingCap, buf: make([]string, eventRingCap)}
|
|
e.cond.L = &e.mu
|
|
return e
|
|
}()
|
|
|
|
// PostEvent appends a structured event line and wakes all waiters.
|
|
// Event format: "<scope> <action> <path>" e.g. "S new session/foo"
|
|
func PostEvent(line string) {
|
|
evRing.mu.Lock()
|
|
idx := (evRing.start + evRing.count) % evRing.cap
|
|
evRing.buf[idx] = line
|
|
if evRing.count < evRing.cap {
|
|
evRing.count++
|
|
} else {
|
|
evRing.start = (evRing.start + 1) % evRing.cap
|
|
}
|
|
evRing.total++
|
|
evRing.mu.Unlock()
|
|
evRing.cond.Broadcast()
|
|
}
|
|
|
|
// WaitEvent blocks until events are available since the given offset (string-encoded int64).
|
|
// Returns new event lines and the updated offset for the next call.
|
|
func WaitEvent(ctx context.Context, offsetStr string) ([]byte, string, error) {
|
|
evRing.mu.Lock()
|
|
defer evRing.mu.Unlock()
|
|
|
|
var offset int64
|
|
if offsetStr != "" {
|
|
fmt.Sscanf(offsetStr, "%d", &offset)
|
|
} else {
|
|
offset = evRing.total
|
|
}
|
|
|
|
// Block until new events or context cancelled.
|
|
for evRing.total <= offset {
|
|
if ctx.Err() != nil {
|
|
return nil, "", ctx.Err()
|
|
}
|
|
evRing.cond.Wait()
|
|
}
|
|
|
|
// Calculate the index range for events after `offset`.
|
|
// offset is the total count at last read. We need events
|
|
// from position (evRing.total - evRing.count) to evRing.total.
|
|
base := evRing.total - int64(evRing.count)
|
|
if offset < base {
|
|
offset = base // consumer fell behind — give them everything we have
|
|
}
|
|
skip := int(offset - base)
|
|
var sb strings.Builder
|
|
for i := 0; i < evRing.count-skip; i++ {
|
|
idx := (evRing.start + skip + i) % evRing.cap
|
|
sb.WriteString(evRing.buf[idx])
|
|
sb.WriteByte('\n')
|
|
}
|
|
return []byte(sb.String()), fmt.Sprintf("%d", evRing.total), nil
|
|
}
|
|
|
|
var rootFileOrder = []string{"new", "idx"}
|
|
|
|
// FileMode returns the mode for a fixed session file,
|
|
// or 0 and false if the name is not a fixed file.
|
|
func FileMode(name string) (os.FileMode, bool) {
|
|
m, ok := Perms[PathSessions].Files[name]
|
|
return m, ok
|
|
}
|
|
|
|
// Config holds the dependencies for the session tree.
|
|
type Config struct {
|
|
Ctx context.Context // daemon context — sessions derive from this
|
|
AgentsDir string
|
|
SessionsDir string
|
|
|
|
Log *olog.Logger
|
|
Sink *olog.Sink
|
|
ReadFile func(string) ([]byte, error)
|
|
MkdirAll func(string, os.FileMode) error
|
|
// NewCore, if non-nil, replaces the default backend.New + agent.New
|
|
// path. It receives the session ID, agent name, and cwd, and returns a Session.
|
|
NewCore func(sessionID, agentName, cwd string) (*coresession.Session, error)
|
|
// Strict rejects inline code steps; only tool steps are allowed.
|
|
Strict bool
|
|
// Yolo skips the landrun sandbox.
|
|
Yolo bool
|
|
// NoMount disables the per-session FUSE mount (e.g. when listening on TCP).
|
|
NoMount bool
|
|
// Enable9P indicates the 9P listener is active (for operational model injection).
|
|
Enable9P bool
|
|
// InvalidateModels clears the model cache, forcing a refresh.
|
|
InvalidateModels func()
|
|
// ToolRegistry is the shared tool registry for lazy tool promotion.
|
|
ToolRegistry *toolsrv.Registry
|
|
// SkillsRegistry is the shared skills registry for skill loading.
|
|
SkillsRegistry *skills.Registry
|
|
}
|
|
|
|
// rootState is the package-level state stored in root.Data.
|
|
type rootState struct {
|
|
cfg Config
|
|
mu sync.RWMutex
|
|
sessions map[string]*Session
|
|
}
|
|
|
|
func (rs *rootState) nextUname() string {
|
|
b := make([]byte, 6)
|
|
rand.Read(b) //nolint:errcheck
|
|
return fmt.Sprintf("%06x%06x", b[:3], b[3:6])
|
|
}
|
|
|
|
// state extracts rootState from a tree's Data field.
|
|
func state(root *Tree) *rootState {
|
|
return root.Data.(*rootState)
|
|
}
|
|
|
|
// NewRoot creates the session tree with all callbacks wired up.
|
|
// The returned tree IS the session collection.
|
|
func NewRoot(cfg Config) *Tree {
|
|
if cfg.ReadFile == nil {
|
|
cfg.ReadFile = os.ReadFile
|
|
}
|
|
if cfg.MkdirAll == nil {
|
|
cfg.MkdirAll = os.MkdirAll
|
|
}
|
|
rs := &rootState{
|
|
cfg: cfg,
|
|
sessions: make(map[string]*Session),
|
|
}
|
|
restoreAllSessions(rs)
|
|
tree := NewTree(nil, 0,
|
|
WithStat(func(_ []string, name string) (os.FileInfo, error) { return rootStat(rs, name) }),
|
|
WithOpener(func(_ []string, name string) (File, error) { return rootOpen(rs, name) }),
|
|
WithLister(func(_ []string) ([]os.DirEntry, error) { return rootList(rs) }),
|
|
WithReaddir(func(_ []string, name string) ([]os.DirEntry, error) { return rootReaddir(rs, name) }),
|
|
WithDeleter(func(_ []string, name string) error { return rootDel(rs, name) }),
|
|
WithCreator(func(_ []string, name string, _ os.FileMode) error {
|
|
return fmt.Errorf("create not supported: %s", name)
|
|
}),
|
|
WithRenamer(func(_ []string, old, new string) error {
|
|
if strings.Contains(old, "/agent/") {
|
|
parts := strings.SplitN(old, "/", 3)
|
|
if len(parts) < 3 {
|
|
return fmt.Errorf("invalid path: %s", old)
|
|
}
|
|
sessName := parts[0]
|
|
rs.mu.RLock()
|
|
sess := rs.sessions[sessName]
|
|
rs.mu.RUnlock()
|
|
if sess == nil {
|
|
return fmt.Errorf("session not found: %s", sessName)
|
|
}
|
|
if sess.Core == nil {
|
|
return fmt.Errorf("no core session")
|
|
}
|
|
agentParts := strings.SplitN(parts[2], "/", 2)
|
|
aid := agentParts[0]
|
|
if ag := sess.Core.FindAgent(aid); ag != nil {
|
|
ag.SetName(new)
|
|
} else {
|
|
sess.Core.Agent().SetName(new)
|
|
}
|
|
rs.cfg.Log.Info("renamed agent in session %s -> %s", sessName, new)
|
|
PostEvent("A rename session/" + sessName + "/agent/" + aid + " session/" + sessName + "/agent/" + new)
|
|
return nil
|
|
}
|
|
return Rename(rs, old, new)
|
|
}),
|
|
)
|
|
tree.Data = rs
|
|
return tree
|
|
}
|
|
|
|
// Lookup returns the session for the given ID, or nil.
|
|
func Lookup(root *Tree, id string) *Session {
|
|
rs := state(root)
|
|
rs.mu.RLock()
|
|
defer rs.mu.RUnlock()
|
|
return rs.sessions[id]
|
|
}
|
|
|
|
// AddSession inserts a pre-built session (used by tests).
|
|
func AddSession(root *Tree, sess *Session) {
|
|
rs := state(root)
|
|
rs.mu.Lock()
|
|
rs.sessions[sess.RunnableID()] = sess
|
|
rs.mu.Unlock()
|
|
}
|
|
|
|
// LookupByUname returns the session with the given uname (principal), or nil.
|
|
func LookupByUname(root *Tree, uname string) *Session {
|
|
rs := state(root)
|
|
rs.mu.RLock()
|
|
defer rs.mu.RUnlock()
|
|
for _, sess := range rs.sessions {
|
|
if sess.uname == uname {
|
|
return sess
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// InterruptAll interrupts every active session.
|
|
func InterruptAll(root *Tree) {
|
|
rs := state(root)
|
|
rs.mu.RLock()
|
|
defer rs.mu.RUnlock()
|
|
for _, sess := range rs.sessions {
|
|
if sess.Core != nil {
|
|
sess.Core.Agent().Interrupt(agent.ErrInterrupted)
|
|
}
|
|
}
|
|
}
|
|
|
|
// OpenSessionTree returns a Tree for the given session ID.
|
|
func OpenSessionTree(root *Tree, id string) (*Tree, error) {
|
|
rs := state(root)
|
|
sess := lookupLocked(rs, id)
|
|
if sess == nil {
|
|
return nil, fmt.Errorf("session not found: %s", id)
|
|
}
|
|
return openSessionTree(rs, sess), nil
|
|
}
|
|
|
|
func lookupLocked(rs *rootState, id string) *Session {
|
|
rs.mu.RLock()
|
|
defer rs.mu.RUnlock()
|
|
return rs.sessions[id]
|
|
}
|
|
|
|
func openSessionTree(rs *rootState, sess *Session) *Tree {
|
|
return NewSessionTree(
|
|
sess,
|
|
rs.cfg.Log,
|
|
func() { Kill(rs, sess.Name()) },
|
|
func(newID string) error { return Rename(rs, sess.Name(), newID) },
|
|
nil,
|
|
rs.cfg.InvalidateModels,
|
|
rs.cfg.ToolRegistry,
|
|
)
|
|
}
|
|
|
|
func openAgentTree(rs *rootState, sess *Session) *Tree {
|
|
return NewAgentTree(
|
|
sess,
|
|
sess.Core.Agent(),
|
|
sess.AgentLog(),
|
|
rs.cfg.Log,
|
|
nil,
|
|
rs.cfg.InvalidateModels,
|
|
rs.cfg.ToolRegistry,
|
|
)
|
|
}
|
|
|
|
// --- Tree callbacks ---
|
|
|
|
func rootList(rs *rootState) ([]os.DirEntry, error) {
|
|
entries := make([]os.DirEntry, 0, len(rootFileOrder))
|
|
for _, name := range rootFileOrder {
|
|
entries = append(entries, FileEntry(name, Perms[PathSessions].Files[name]))
|
|
}
|
|
rs.mu.RLock()
|
|
for id := range rs.sessions {
|
|
entries = append(entries, DirEntry(id, Perms[PathSessionDir].DirMode))
|
|
}
|
|
rs.mu.RUnlock()
|
|
return entries, nil
|
|
}
|
|
|
|
func rootReaddir(rs *rootState, name string) ([]os.DirEntry, error) {
|
|
parts := strings.SplitN(name, "/", 4)
|
|
sessID := parts[0]
|
|
rs.mu.RLock()
|
|
sess := rs.sessions[sessID]
|
|
rs.mu.RUnlock()
|
|
if sess == nil {
|
|
return nil, fmt.Errorf("session not found: %s", sessID)
|
|
}
|
|
if len(parts) == 1 {
|
|
entries := []os.DirEntry{
|
|
FileEntry("plan", 0666),
|
|
FileEntry("env", 0444),
|
|
DirEntry("agent", 0755),
|
|
}
|
|
return entries, nil
|
|
}
|
|
if len(parts) >= 2 && parts[1] == "agent" {
|
|
if len(parts) == 2 {
|
|
entries := []os.DirEntry{FileEntry("new", 0666)}
|
|
if sess.Core != nil {
|
|
for _, ag := range sess.Core.Agents() {
|
|
dirName := ag.ID()
|
|
if n := ag.Name(); n != "" {
|
|
dirName = n
|
|
}
|
|
entries = append(entries, DirEntry(dirName, 0755))
|
|
}
|
|
}
|
|
return entries, nil
|
|
}
|
|
if len(parts) >= 3 && parts[2] == "new" {
|
|
if len(parts) == 3 {
|
|
return nil, fmt.Errorf("%s: is a directory", name)
|
|
}
|
|
return nil, fmt.Errorf("%s: not a directory", name)
|
|
}
|
|
if sess.Core == nil || sess.Core.Agent() == nil {
|
|
return nil, fmt.Errorf("no agent in session %s", sessID)
|
|
}
|
|
ag := sess.Core.FindAgent(parts[2])
|
|
if ag == nil {
|
|
return nil, fmt.Errorf("agent %s not found in session %s", parts[2], sessID)
|
|
}
|
|
if len(parts) == 3 {
|
|
al := sess.AgentLogFor(ag.ID())
|
|
if al == nil {
|
|
return nil, fmt.Errorf("agent log not found for %s", parts[2])
|
|
}
|
|
afs := NewAgentTree(sess, ag, al, rs.cfg.Log, nil, rs.cfg.InvalidateModels, rs.cfg.ToolRegistry)
|
|
entries, err := afs.List()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
entries = append(entries, DirEntry("proc", 0755))
|
|
return entries, nil
|
|
}
|
|
if len(parts) == 4 && parts[3] == "proc" {
|
|
procs := ag.ListDetached()
|
|
entries := make([]os.DirEntry, len(procs))
|
|
for i, p := range procs {
|
|
entries[i] = FileEntry(fmt.Sprintf("%d", p.PID), 0666)
|
|
}
|
|
return entries, nil
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("%s: not a directory", name)
|
|
}
|
|
|
|
func rootStat(rs *rootState, name string) (os.FileInfo, error) {
|
|
if mode, ok := Perms[PathSessions].Files[name]; ok {
|
|
return &SyntheticFileInfo{Name_: name, Mode_: mode}, nil
|
|
}
|
|
parts := strings.SplitN(name, "/", 4)
|
|
sessID := parts[0]
|
|
rs.mu.RLock()
|
|
sess, ok := rs.sessions[sessID]
|
|
rs.mu.RUnlock()
|
|
if !ok {
|
|
return nil, fmt.Errorf("%s: not found", name)
|
|
}
|
|
if len(parts) == 1 {
|
|
return &SyntheticFileInfo{Name_: sessID, Mode_: Perms[PathSessionDir].DirMode, IsDir_: true}, nil
|
|
}
|
|
if len(parts) >= 2 && parts[1] == "agent" {
|
|
if len(parts) == 2 {
|
|
return &SyntheticFileInfo{Name_: "agent", Mode_: 0755, IsDir_: true}, nil
|
|
}
|
|
if len(parts) >= 3 && parts[2] == "new" {
|
|
return &SyntheticFileInfo{Name_: "new", Mode_: Perms[PathSessionFile].Files["new"]}, nil
|
|
}
|
|
if len(parts) >= 3 {
|
|
if sess.Core == nil {
|
|
return nil, fmt.Errorf("no agent in session %s", sessID)
|
|
}
|
|
ag := sess.Core.FindAgent(parts[2])
|
|
if ag == nil {
|
|
return nil, fmt.Errorf("agent %s not found in session %s", parts[2], sessID)
|
|
}
|
|
}
|
|
if len(parts) == 3 {
|
|
return &SyntheticFileInfo{Name_: parts[2], Mode_: 0755, IsDir_: true}, nil
|
|
}
|
|
if len(parts) >= 4 && parts[3] == "proc" {
|
|
return &SyntheticFileInfo{Name_: "proc", Mode_: 0755, IsDir_: true}, nil
|
|
}
|
|
ag := sess.Core.FindAgent(parts[2])
|
|
if ag == nil {
|
|
return nil, fmt.Errorf("agent %s not found in session %s", parts[2], sessID)
|
|
}
|
|
al := sess.AgentLogFor(ag.ID())
|
|
if al == nil {
|
|
return nil, fmt.Errorf("agent log not found for %s", parts[2])
|
|
}
|
|
afs := NewAgentTree(sess, ag, al, rs.cfg.Log, nil, rs.cfg.InvalidateModels, rs.cfg.ToolRegistry)
|
|
return afs.Stat(parts[3])
|
|
}
|
|
sfs := openSessionTree(rs, sess)
|
|
return sfs.Stat(parts[1])
|
|
}
|
|
|
|
func rootOpen(rs *rootState, name string) (File, error) {
|
|
notBlocking := func(context.Context, string) ([]byte, string, error) {
|
|
return nil, "", fmt.Errorf("blocking read not supported")
|
|
}
|
|
|
|
parts := strings.SplitN(name, "/", 3)
|
|
|
|
// Proc file: {id}/proc/{pid}
|
|
if len(parts) == 3 && parts[1] == "proc" {
|
|
sessID := parts[0]
|
|
rs.mu.RLock()
|
|
sess := rs.sessions[sessID]
|
|
rs.mu.RUnlock()
|
|
if sess == nil {
|
|
return nil, fmt.Errorf("%s: not found", name)
|
|
}
|
|
pidStr := parts[2]
|
|
pid, err := strconv.Atoi(pidStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid pid: %s", pidStr)
|
|
}
|
|
return &FileConfig{
|
|
StatFn: func() (os.FileInfo, error) {
|
|
return &SyntheticFileInfo{Name_: pidStr, Mode_: 0444}, nil
|
|
},
|
|
ReadFn: func() ([]byte, error) {
|
|
if sess.Core == nil {
|
|
return nil, fmt.Errorf("no agent in session")
|
|
}
|
|
output, err := sess.Core.Agent().GetDetachedOutput(pid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return []byte(output), nil
|
|
},
|
|
WriteFn: func([]byte) error { return fmt.Errorf("read-only") },
|
|
BlockingReadFn: notBlocking,
|
|
}, nil
|
|
}
|
|
|
|
switch name {
|
|
case "new":
|
|
return &FileConfig{
|
|
StatFn: func() (os.FileInfo, error) {
|
|
return &SyntheticFileInfo{Name_: "new", Mode_: Perms[PathSessions].Files["new"]}, nil
|
|
},
|
|
ReadFn: func() ([]byte, error) {
|
|
return []byte("name=\n"), nil
|
|
},
|
|
WriteFn: func(data []byte) error {
|
|
return fmt.Errorf("use ollie-9p rdwr session/new")
|
|
},
|
|
BlockingReadFn: notBlocking,
|
|
}, nil
|
|
case "idx":
|
|
return &FileConfig{
|
|
StatFn: func() (os.FileInfo, error) {
|
|
return &SyntheticFileInfo{Name_: "idx", Mode_: Perms[PathSessions].Files["idx"]}, nil
|
|
},
|
|
ReadFn: func() ([]byte, error) { return buildIndex(rs), nil },
|
|
WriteFn: func([]byte) error { return fmt.Errorf("idx: read-only") },
|
|
BlockingReadFn: notBlocking,
|
|
}, nil
|
|
}
|
|
|
|
// Hierarchical paths: {id}/{file} or {id}/agent/{aid}/{file}
|
|
parts = strings.SplitN(name, "/", 4)
|
|
sessID := parts[0]
|
|
rs.mu.RLock()
|
|
sess := rs.sessions[sessID]
|
|
rs.mu.RUnlock()
|
|
if sess == nil {
|
|
return nil, fmt.Errorf("%s: not found", name)
|
|
}
|
|
if len(parts) == 1 {
|
|
return nil, fmt.Errorf("%s: is a directory", name)
|
|
}
|
|
|
|
if len(parts) >= 2 && parts[1] == "agent" {
|
|
if len(parts) == 2 {
|
|
return nil, fmt.Errorf("%s: is a directory", name)
|
|
}
|
|
if len(parts) >= 3 && parts[2] == "new" {
|
|
if len(parts) == 3 {
|
|
// agent/new — request-response file to create an agent
|
|
var agentResult string
|
|
return &FileConfig{
|
|
StatFn: func() (os.FileInfo, error) {
|
|
return &SyntheticFileInfo{Name_: "new", Mode_: Perms[PathSessionFile].Files["new"]}, nil
|
|
},
|
|
ReadFn: func() ([]byte, error) {
|
|
if agentResult != "" {
|
|
return []byte(agentResult + "\n"), nil
|
|
}
|
|
return []byte("name=\ncwd=\nbackend=\nmodel=\nagent=\nagent_alias=\nremote=\nsystem_prompt=\n"), nil
|
|
},
|
|
WriteFn: func(data []byte) error {
|
|
if err := CreateAgent(rs, sessID, strings.Fields(strings.TrimSpace(string(data)))); err != nil {
|
|
return err
|
|
}
|
|
agentResult = sess.Core.Agent().Name()
|
|
return nil
|
|
},
|
|
BlockingReadFn: notBlocking,
|
|
}, nil
|
|
}
|
|
return nil, fmt.Errorf("%s: not a file", name)
|
|
}
|
|
if len(parts) < 4 {
|
|
return nil, fmt.Errorf("%s: is a directory", name)
|
|
}
|
|
if sess.Core == nil {
|
|
return nil, fmt.Errorf("no agent in session %s", sessID)
|
|
}
|
|
ag := sess.Core.FindAgent(parts[2])
|
|
if ag == nil {
|
|
return nil, fmt.Errorf("agent %s not found in session %s", parts[2], sessID)
|
|
}
|
|
al := sess.AgentLogFor(ag.ID())
|
|
if al == nil {
|
|
return nil, fmt.Errorf("agent log not found for %s", parts[2])
|
|
}
|
|
afs := NewAgentTree(sess, ag, al, rs.cfg.Log, nil, rs.cfg.InvalidateModels, rs.cfg.ToolRegistry)
|
|
return afs.Open(parts[3])
|
|
}
|
|
|
|
sfs := openSessionTree(rs, sess)
|
|
return sfs.Open(parts[1])
|
|
}
|
|
|
|
func rootDel(rs *rootState, name string) error {
|
|
parts := strings.SplitN(name, "/", 3)
|
|
sessID := parts[0]
|
|
if len(parts) == 1 {
|
|
rs.mu.RLock()
|
|
_, ok := rs.sessions[sessID]
|
|
rs.mu.RUnlock()
|
|
if ok {
|
|
Kill(rs, sessID)
|
|
return nil
|
|
}
|
|
return fmt.Errorf("session not found: %s", sessID)
|
|
}
|
|
if len(parts) == 3 && parts[1] == "proc" {
|
|
rs.mu.RLock()
|
|
sess := rs.sessions[sessID]
|
|
rs.mu.RUnlock()
|
|
if sess == nil {
|
|
return fmt.Errorf("session not found: %s", sessID)
|
|
}
|
|
pid, err := strconv.Atoi(parts[2])
|
|
if err != nil {
|
|
return fmt.Errorf("invalid pid: %s", parts[2])
|
|
}
|
|
if sess.Core == nil {
|
|
return fmt.Errorf("no agent in session")
|
|
}
|
|
if !sess.Core.Agent().DismissDetached(pid) {
|
|
return fmt.Errorf("process %d not found or still running", pid)
|
|
}
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func buildIndex(rs *rootState) []byte {
|
|
var sb strings.Builder
|
|
rs.mu.RLock()
|
|
ids := make([]string, 0, len(rs.sessions))
|
|
for id := range rs.sessions {
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Strings(ids)
|
|
for _, id := range ids {
|
|
sess := rs.sessions[id]
|
|
sess.mu.RLock()
|
|
name := sess.name
|
|
if sess.Core != nil {
|
|
for _, ag := range sess.Core.Agents() {
|
|
st := ag.State()
|
|
cwd := sess.Core.CWD()
|
|
be := ag.BackendName()
|
|
model := ag.ModelName()
|
|
agName := ag.Name()
|
|
fmt.Fprintf(&sb, "%s\t%s\t%s\t%s\t%s\t%s\n", name, st, cwd, be, model, agName)
|
|
}
|
|
} else {
|
|
fmt.Fprintf(&sb, "%s\t\t\t\t\t\n", name)
|
|
}
|
|
sess.mu.RUnlock()
|
|
}
|
|
rs.mu.RUnlock()
|
|
return []byte(sb.String())
|
|
}
|