diff --git a/AGENTS.md b/AGENTS.md index dd1a756..b38afbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,11 +58,18 @@ just clean # remove build artifacts Requires [just](https://github.com/casey/just): `cargo install just` ## Testing ```bash -# All Go tests: -go test ./... +# All Go tests (excludes cmd/ollie-remote which needs just to build): +go test $(go list ./... | grep -v cmd/ollie-remote) # KDE has integration test scripts: cd kde && ./test-e2e.sh +# ollie-remote requires the just build pipeline to resolve embedded deps: +just ollie-remote +just test +# or individually: +just test-remote ``` + +**Note on `cmd/ollie-remote`**: This package uses `//go:embed sandbox/default.yaml` and `//go:embed all:tools`. The embedded files are not in the source tree — `just ollie-remote` copies them in before building then cleans up. Running `go test ./...` will fail on this package. Use `just test-remote` or `go test $(go list ./... | grep -v cmd/ollie-remote)` instead. ## Language & Conventions - **Go** (root module): Go 1.25+, standard library preferred, minimal dependencies. diff --git a/cmd/olliesrv/server.go b/cmd/olliesrv/server.go index dc0d486..1a5e028 100644 --- a/cmd/olliesrv/server.go +++ b/cmd/olliesrv/server.go @@ -15,13 +15,13 @@ import ( "sync" "time" + "ollie/agent" "ollie/backend" - "ollie/toolsrv" "ollie/elevate" + "ollie/fs/session" olog "ollie/log" "ollie/paths" - "ollie/fs/session" - "ollie/agent" + "ollie/toolsrv" "9fans.net/go/plan9" ) @@ -573,6 +573,10 @@ func (s *Server) pathType(path string) string { if len(parts) == 2 { return "dir" } + // No agent in empty session. + if sess.Core == nil { + return "" + } // Validate agent Name agentName := sess.Core.Agent().Name() if agentName == "" || parts[2] != agentName { @@ -600,6 +604,9 @@ func (s *Server) pathType(path string) string { } // /session/{id}/proc/{pid} — verify PID exists in detached list if len(parts) == 3 && parts[1] == "proc" { + if sess.Core == nil { + return "" + } for _, p := range sess.Core.Agent().ListDetached() { if fmt.Sprintf("%d", p.PID) == parts[2] { return "file" @@ -837,19 +844,19 @@ func (s *Server) read(cs *connState, fc *plan9.Fcall, ctx context.Context) *plan return s.readDNS(fc, &f.generateResult) } -// /route: return per-fid result from previous write (offset 0-based) - if path == "/route" { - s.log.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count) - return s.readDNS(fc, &f.routeResult) - } - - // /session/new: return per-fid result from previous write - if path == "/session/new" { - s.log.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count) - return s.readDNS(fc, &f.sessionResult) - } - + // /route: return per-fid result from previous write (offset 0-based) + if path == "/route" { s.log.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count) + return s.readDNS(fc, &f.routeResult) + } + + // /session/new: return per-fid result from previous write + if path == "/session/new" { + s.log.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count) + return s.readDNS(fc, &f.sessionResult) + } + + s.log.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count) entry, err := s.openEntry(path) if err != nil { return errFcall(fc, err.Error()) @@ -1017,22 +1024,22 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall { if err != nil { return errFcall(fc, err.Error()) } -f.routeResult = []byte(fmt.Sprintf("backend=%s model=%s", result.Backend, result.Model)) - return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(fc.Data))} - } + f.routeResult = []byte(fmt.Sprintf("backend=%s model=%s", result.Backend, result.Model)) + return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(fc.Data))} + } - // /session/new: synchronous create-session (per-fid state, like /net/dns) - if f.path == "/session/new" { - cs.mu.Unlock() - name, err := fs.CreateFromRoot(s.sessionTree, strings.Fields(strings.TrimSpace(string(fc.Data)))) - if err != nil { - return errFcall(fc, err.Error()) - } - f.sessionResult = []byte(name) - return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(fc.Data))} + // /session/new: synchronous create-session (per-fid state, like /net/dns) + if f.path == "/session/new" { + cs.mu.Unlock() + name, err := fs.CreateFromRoot(s.sessionTree, strings.Fields(strings.TrimSpace(string(fc.Data)))) + if err != nil { + return errFcall(fc, err.Error()) } + f.sessionResult = []byte(name) + return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(fc.Data))} + } - // Accumulate; the 9P client may split large writes across multiple Twrite messages. + // Accumulate; the 9P client may split large writes across multiple Twrite messages. end := int(fc.Offset) + len(fc.Data) if end > len(f.writeBuf) { grown := make([]byte, end) diff --git a/fs/session/create.go b/fs/session/create.go index 3b86973..7a3c9c2 100644 --- a/fs/session/create.go +++ b/fs/session/create.go @@ -14,8 +14,9 @@ import ( "ollie/toolsrv" ) -// Create creates a new session from key=value args. Returns the session name. -// The session is NOT registered yet — CreateAgent must be called to finalize it. +// Create creates a new empty session from key=value args and registers it. +// Returns the session name. The session has no agent (Core is nil) until +// CreateAgent is called. func Create(rs *rootState, args []string) (string, error) { name := "" for _, arg := range args { @@ -49,7 +50,16 @@ func Create(rs *rootState, args []string) (string, error) { return "", fmt.Errorf("session already exists: %s", name) } - rs.cfg.Log.Info("reserved session name %s (id=%s)", name, sessID) + sessionCtx, sessionCancel := context.WithCancel(rs.cfg.Ctx) + sess := NewSession(sessID, nil, sessionCtx, sessionCancel) + sess.SetName(name) + + rs.mu.Lock() + rs.sessions[name] = sess + rs.mu.Unlock() + + rs.cfg.Log.Info("created empty session %s (id=%s)", name, sessID) + broadcastEvents() return name, nil } diff --git a/fs/session/files.go b/fs/session/files.go index 7054472..ff36697 100644 --- a/fs/session/files.go +++ b/fs/session/files.go @@ -172,7 +172,9 @@ func (h *sessionHelper) handleCtl(input string) error { } } case "save": - h.sess.Core.SaveSession("") // triggers session persistence + if h.sess.Core != nil { + h.sess.Core.SaveSession("") // triggers session persistence + } case "invalidate": if h.invalidateModels != nil { h.invalidateModels() diff --git a/fs/session/persist.go b/fs/session/persist.go index f6c1d4d..8586e47 100644 --- a/fs/session/persist.go +++ b/fs/session/persist.go @@ -24,7 +24,9 @@ func Kill(rs *rootState, id string) { rs.mu.Unlock() if sess != nil { sess.Cancel() - sess.Core.Close() + if sess.Core != nil { + sess.Core.Close() + } if sess.proc != nil { sess.proc.Close() } @@ -52,7 +54,7 @@ func Rename(rs *rootState, old, new string) error { rs.mu.Unlock() return fmt.Errorf("session already exists: %s", new) } - if sess.Core.Agent().IsRunning() { + if sess.Core != nil && sess.Core.Agent().IsRunning() { rs.mu.Unlock() return fmt.Errorf("cannot rename while agent is running") } @@ -91,7 +93,9 @@ func Shutdown(root *Tree) { rs.mu.Unlock() if sess != nil { sess.Cancel() - sess.Core.Close() + if sess.Core != nil { + sess.Core.Close() + } rs.cfg.Log.Info("shutdown session %s", id) } } @@ -103,7 +107,7 @@ func waitIdle(rs *rootState, poll, timeout time.Duration) { allIdle := true rs.mu.RLock() for _, sess := range rs.sessions { - if sess.Core.Agent().State() != "idle" { + if sess.Core != nil && sess.Core.Agent().State() != "idle" { allIdle = false break } @@ -130,6 +134,9 @@ func persistSession(rs *rootState, id string) { if !ok { return } + if sess.Core == nil { + return + } dir := activeSessionsDir(rs) os.MkdirAll(dir, 0700) path := filepath.Join(dir, sess.id+".json") diff --git a/fs/session/root.go b/fs/session/root.go index 27c794a..e52d390 100644 --- a/fs/session/root.go +++ b/fs/session/root.go @@ -194,7 +194,9 @@ func InterruptAll(root *Tree) { rs.mu.RLock() defer rs.mu.RUnlock() for _, sess := range rs.sessions { - sess.Core.Agent().Interrupt(agent.ErrInterrupted) + if sess.Core != nil { + sess.Core.Agent().Interrupt(agent.ErrInterrupted) + } } } @@ -389,6 +391,9 @@ func rootOpen(rs *rootState, name string) (File, 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 @@ -531,6 +536,9 @@ func rootDel(rs *rootState, name string) error { 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) } @@ -550,12 +558,15 @@ func buildIndex(rs *rootState) []byte { for _, id := range ids { sess := rs.sessions[id] sess.mu.RLock() - st := sess.Core.Agent().State() - cwd := sess.Core.CWD() - be := sess.Core.Agent().BackendName() - model := sess.Core.Agent().ModelName() - ag := sess.Core.Agent().Name() name := sess.name + var st, cwd, be, model, ag string + if sess.Core != nil { + st = sess.Core.Agent().State() + cwd = sess.Core.CWD() + be = sess.Core.Agent().BackendName() + model = sess.Core.Agent().ModelName() + ag = sess.Core.Agent().Name() + } sess.mu.RUnlock() fmt.Fprintf(&sb, "%s\t%s\t%s\t%s\t%s\t%s\n", name, st, cwd, be, model, ag) }