hierarchical SessionStore: /s/ is now a regular route entry
SessionStore accepts paths like id/file, id/t/tool, new, idx. All /s/ dispatch logic removed from server handlers. Remaining /s/ refs are metadata only (makeStat, fileOwnerGroup). - SessionStore.Stat/Open/Delete/Rename/Create handle hierarchical paths - SessionStore.ListDir for subdirectory listing - Tool idx filtering moved into SessionStore.openEntry - SessionStore.MkdirAll delegates to ToolStore - Removed: sessionFileStore, sessionAllowTools, filterToolIndex, OpenFiltered, storeReadCtx, storeBlockingRead from server.go - routeStore passes full suffix (not pathBase) to stores
This commit is contained in:
parent
8f0dfdef37
commit
8c2f4a8754
352
server.go
352
server.go
|
|
@ -174,6 +174,11 @@ func (s *ToolStore) ListDir(rel string) ([]os.DirEntry, error) {
|
|||
return os.ReadDir(filepath.Join(s.dir, rel))
|
||||
}
|
||||
|
||||
// MkdirAll creates a directory relative to the tools root.
|
||||
func (s *ToolStore) MkdirAll(rel string) error {
|
||||
return os.MkdirAll(filepath.Join(s.dir, rel), 0755)
|
||||
}
|
||||
|
||||
func (s *ToolStore) Stat(name string) (os.FileInfo, error) {
|
||||
if name == "idx" {
|
||||
return &syntheticFileInfo{Name_: "idx", Mode_: 0444}, nil
|
||||
|
|
@ -201,25 +206,6 @@ func (s *ToolStore) Open(name string) (StoreEntry, error) {
|
|||
return s.FlatDirStore.Open(name)
|
||||
}
|
||||
|
||||
// OpenFiltered opens an entry, applying allowTools filtering to idx if non-nil.
|
||||
func (s *ToolStore) OpenFiltered(name string, allowed map[string]bool) (StoreEntry, error) {
|
||||
if name == "idx" && allowed != nil {
|
||||
return &store.EntryConfig{
|
||||
StatFn: func() (os.FileInfo, error) { return &syntheticFileInfo{Name_: "idx", Mode_: 0444}, nil },
|
||||
ReadFn: func() ([]byte, error) {
|
||||
data, err := s.index()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return filterToolIndex(data, allowed), nil
|
||||
},
|
||||
WriteFn: func([]byte) error { return fmt.Errorf("idx: read-only") },
|
||||
BlockingReadFn: func(context.Context, string) ([]byte, string, error) { return nil, "", fmt.Errorf("blocking read not supported") },
|
||||
}, nil
|
||||
}
|
||||
return s.Open(name)
|
||||
}
|
||||
|
||||
func (s *ToolStore) index() ([]byte, error) {
|
||||
entries, err := s.FlatDirStore.List()
|
||||
if err != nil {
|
||||
|
|
@ -350,6 +336,7 @@ func New(sink *olog.Sink, opts ...ServerOption) *Server {
|
|||
Sink: s.sink,
|
||||
Strict: s.strict,
|
||||
Yolo: s.yolo,
|
||||
ToolStore: s.toolStore,
|
||||
SaveTranscript: func(data []byte) error {
|
||||
name := time.Now().Format("20060102T150405") + "-chat.md"
|
||||
return storeWrite(s.transcriptStore, name, data)
|
||||
|
|
@ -389,6 +376,7 @@ func (s *Server) storeRoutes() []storeRoute {
|
|||
{"/a/", func() Store { return s.agentStore }},
|
||||
{"/p/", func() Store { return s.promptStore }},
|
||||
{"/m/", func() Store { return s.memStore }},
|
||||
{"/s/", func() Store { return s.sessionStore }},
|
||||
{"/sk/", func() Store { return s.skillStore }},
|
||||
{"/u/", func() Store { return s.utilStore }},
|
||||
{"/x/", func() Store { return s.pluginStore }},
|
||||
|
|
@ -401,7 +389,7 @@ func (s *Server) storeRoutes() []storeRoute {
|
|||
func (s *Server) routeStore(path string) (Store, string) {
|
||||
for _, r := range s.storeRoutes() {
|
||||
if strings.HasPrefix(path, r.prefix) {
|
||||
return r.store(), pathBase(path)
|
||||
return r.store(), strings.TrimPrefix(path, r.prefix)
|
||||
}
|
||||
}
|
||||
// Root-level files (e.g. /backends, /help).
|
||||
|
|
@ -411,41 +399,11 @@ func (s *Server) routeStore(path string) (Store, string) {
|
|||
return s.rootStore, name
|
||||
}
|
||||
}
|
||||
// Session store files (/s/sh, /s/bfg, etc.)
|
||||
if isSessionStoreFile(path) {
|
||||
return s.sessionStore, pathBase(path)
|
||||
}
|
||||
// Session paths: /s/{id}/{file} or /s/{id}/t/{tool}
|
||||
if strings.HasPrefix(path, "/s/") {
|
||||
parts := strings.SplitN(strings.TrimPrefix(path, "/s/"), "/", 3)
|
||||
if len(parts) >= 2 {
|
||||
sessID := parts[0]
|
||||
if parts[1] == "t" && len(parts) == 3 {
|
||||
// Tool file: /s/{id}/t/{rel}
|
||||
return s.toolStore, parts[2]
|
||||
}
|
||||
if parts[1] != "t" {
|
||||
// Session file: /s/{id}/{file}
|
||||
if sfs, ok := s.sessionFileStore(sessID); ok {
|
||||
return sfs, parts[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// openEntry opens a StoreEntry for the given path, applying any path-specific
|
||||
// logic (e.g. tool idx filtering by session allowTools).
|
||||
// openEntry opens a StoreEntry for the given path.
|
||||
func (s *Server) openEntry(path string) (StoreEntry, error) {
|
||||
// Tool idx with per-session filtering.
|
||||
if strings.HasPrefix(path, "/s/") {
|
||||
parts := strings.SplitN(strings.TrimPrefix(path, "/s/"), "/", 3)
|
||||
if len(parts) == 3 && parts[1] == "t" {
|
||||
allowed := s.sessionAllowTools(parts[0])
|
||||
return s.toolStore.OpenFiltered(parts[2], allowed)
|
||||
}
|
||||
}
|
||||
st, name := s.routeStore(path)
|
||||
if st == nil {
|
||||
return nil, fmt.Errorf("%s: not found", path)
|
||||
|
|
@ -580,43 +538,7 @@ func defaultTmpDir() string {
|
|||
return paths.DataDir() + "/tmp"
|
||||
}
|
||||
|
||||
// sessionFileStore returns a Store for the given session ID.
|
||||
func (s *Server) sessionFileStore(sessID string) (store.RunnableStore, bool) {
|
||||
st, err := s.sessionStore.OpenStore(sessID)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return st, true
|
||||
}
|
||||
|
||||
// sessionAllowTools returns the allowTools set for a session, or nil if unrestricted.
|
||||
func (s *Server) sessionAllowTools(sessID string) map[string]bool {
|
||||
sess := s.sessionStore.Session(sessID)
|
||||
if sess == nil || len(sess.AllowTools) == 0 {
|
||||
return nil
|
||||
}
|
||||
m := make(map[string]bool, len(sess.AllowTools))
|
||||
for _, t := range sess.AllowTools {
|
||||
m[t] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// filterToolIndex filters a tool index (## name\n...\n\n sections) to only include allowed tools.
|
||||
func filterToolIndex(data []byte, allowed map[string]bool) []byte {
|
||||
var out []byte
|
||||
for _, section := range strings.Split(string(data), "## ") {
|
||||
if section == "" {
|
||||
continue
|
||||
}
|
||||
name, _, _ := strings.Cut(section, "\n")
|
||||
if allowed[name] {
|
||||
out = append(out, "## "...)
|
||||
out = append(out, section...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// storeRead opens an entry in a store and reads it.
|
||||
func storeRead(s store.Store, name string) ([]byte, error) {
|
||||
|
|
@ -785,43 +707,13 @@ func (s *Server) pathType(path string) string {
|
|||
return "dir"
|
||||
}
|
||||
if st, name := s.routeStore(path); st != nil {
|
||||
if _, err := st.Stat(name); err == nil {
|
||||
return "file"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Session store.
|
||||
trimmed := strings.TrimPrefix(path, "/")
|
||||
parts := strings.SplitN(trimmed, "/", 3)
|
||||
switch {
|
||||
case len(parts) == 1 && parts[0] == "s":
|
||||
return "dir"
|
||||
case len(parts) == 2 && parts[0] == "s":
|
||||
if info, err := s.sessionStore.Stat(parts[1]); err == nil {
|
||||
if info, err := st.Stat(name); err == nil {
|
||||
if info.IsDir() {
|
||||
return "dir"
|
||||
}
|
||||
return "file"
|
||||
}
|
||||
case len(parts) == 3 && parts[0] == "s":
|
||||
if parts[2] == "t" {
|
||||
if s.sessionStore.Session(parts[1]) != nil {
|
||||
return "dir"
|
||||
}
|
||||
} else if strings.HasPrefix(parts[2], "t/") {
|
||||
rel := strings.TrimPrefix(parts[2], "t/")
|
||||
if info, err := s.toolStore.Stat(rel); err == nil {
|
||||
if info.IsDir() {
|
||||
return "dir"
|
||||
}
|
||||
return "file"
|
||||
}
|
||||
} else if sfs, ok := s.sessionFileStore(parts[1]); ok {
|
||||
if _, err := sfs.Stat(parts[2]); err == nil {
|
||||
return "file"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
@ -958,51 +850,46 @@ func (s *Server) create(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
|||
if !ok {
|
||||
return errFcall(fc, "bad fid")
|
||||
}
|
||||
if fc.Perm&plan9.DMDIR != 0 {
|
||||
// mkdir under session tool directory: /s/{sid}/t or /s/{sid}/t/...
|
||||
if strings.HasPrefix(f.path, "/s/") {
|
||||
parts := strings.SplitN(strings.TrimPrefix(f.path, "/s/"), "/", 3)
|
||||
if (len(parts) == 2 && parts[1] == "t") || (len(parts) == 3 && parts[1] == "t") {
|
||||
var rel string
|
||||
if len(parts) == 2 {
|
||||
rel = fc.Name
|
||||
} else {
|
||||
rel = parts[2] + "/" + fc.Name
|
||||
}
|
||||
dir := filepath.Join(s.toolStore.dir, rel)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return errFcall(fc, err.Error())
|
||||
}
|
||||
newPath := pathJoin(f.path, fc.Name)
|
||||
qid := plan9.Qid{Type: QTDir, Path: qidPath(newPath)}
|
||||
f.path = newPath
|
||||
f.qid = qid
|
||||
f.mode = fc.Mode
|
||||
return &plan9.Fcall{Type: plan9.Rcreate, Tag: fc.Tag, Qid: qid}
|
||||
}
|
||||
}
|
||||
return errFcall(fc, "mkdir not supported")
|
||||
}
|
||||
|
||||
newPath := pathJoin(f.path, fc.Name)
|
||||
|
||||
if fc.Perm&plan9.DMDIR != 0 {
|
||||
// mkdir: delegate to store if it supports MkdirAll.
|
||||
type dirCreator interface {
|
||||
MkdirAll(string) error
|
||||
}
|
||||
st, name := s.routeStore(newPath)
|
||||
if st == nil {
|
||||
return errFcall(fc, "mkdir not supported")
|
||||
}
|
||||
if dc, ok := st.(dirCreator); ok {
|
||||
if err := dc.MkdirAll(name); err != nil {
|
||||
return errFcall(fc, err.Error())
|
||||
}
|
||||
} else {
|
||||
return errFcall(fc, "mkdir not supported")
|
||||
}
|
||||
qid := plan9.Qid{Type: QTDir, Path: qidPath(newPath)}
|
||||
f.path = newPath
|
||||
f.qid = qid
|
||||
f.mode = fc.Mode
|
||||
return &plan9.Fcall{Type: plan9.Rcreate, Tag: fc.Tag, Qid: qid}
|
||||
}
|
||||
|
||||
s.log.Debug("Tcreate parent=%q name=%q", f.path, fc.Name)
|
||||
|
||||
// Generic store-backed directories.
|
||||
// File create: route to the parent directory's store.
|
||||
if st := s.routeDir(f.path); st != nil {
|
||||
if err := st.Create(fc.Name); err != nil {
|
||||
return errFcall(fc, err.Error())
|
||||
}
|
||||
} else if strings.HasPrefix(f.path, "/s/") {
|
||||
// Tool creates under session: /s/{sid}/t or /s/{sid}/t/...
|
||||
parts := strings.SplitN(strings.TrimPrefix(f.path, "/s/"), "/", 3)
|
||||
if (len(parts) == 2 && parts[1] == "t") || (len(parts) == 3 && parts[1] == "t") {
|
||||
var rel string
|
||||
if len(parts) == 2 {
|
||||
rel = fc.Name
|
||||
} else {
|
||||
rel = parts[2] + "/" + fc.Name
|
||||
}
|
||||
if err := s.toolStore.Create(rel); err != nil {
|
||||
} else if st, parentName := s.routeStore(f.path); st != nil {
|
||||
// Parent is a subdirectory within a store; create relative to it.
|
||||
type subCreator interface {
|
||||
Create(string) error
|
||||
}
|
||||
if sc, ok := st.(subCreator); ok {
|
||||
if err := sc.Create(parentName + "/" + fc.Name); err != nil {
|
||||
return errFcall(fc, err.Error())
|
||||
}
|
||||
}
|
||||
|
|
@ -1168,47 +1055,25 @@ func (s *Server) wstat(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
|||
return &plan9.Fcall{Type: plan9.Rwstat, Tag: fc.Tag}
|
||||
}
|
||||
|
||||
// Generic store-backed files.
|
||||
if st, _ := s.routeStore(f.path); st != nil {
|
||||
if err := st.Rename(oldName, newDir.Name); err != nil {
|
||||
return errFcall(fc, err.Error())
|
||||
}
|
||||
// Update fid path: find the prefix and replace the base.
|
||||
for _, r := range s.storeRoutes() {
|
||||
if strings.HasPrefix(f.path, r.prefix) {
|
||||
cs.mu.Lock()
|
||||
f.path = r.prefix + newDir.Name
|
||||
f.qid.Path = qidPath(f.path)
|
||||
cs.mu.Unlock()
|
||||
break
|
||||
}
|
||||
}
|
||||
st, relPath := s.routeStore(f.path)
|
||||
if st == nil {
|
||||
return &plan9.Fcall{Type: plan9.Rwstat, Tag: fc.Tag}
|
||||
}
|
||||
|
||||
// Session paths.
|
||||
if strings.HasPrefix(f.path, "/s/") {
|
||||
parts := strings.SplitN(strings.TrimPrefix(f.path, "/s/"), "/", 3)
|
||||
if len(parts) == 3 && parts[1] == "t" {
|
||||
// Tool rename under session: /s/{sid}/t/{rel}
|
||||
oldRel := parts[2]
|
||||
parentRel := oldRel[:len(oldRel)-len(oldName)]
|
||||
newRel := parentRel + newDir.Name
|
||||
if err := s.toolStore.Rename(oldRel, newRel); err != nil {
|
||||
return errFcall(fc, err.Error())
|
||||
}
|
||||
if err := st.Rename(relPath, newDir.Name); err != nil {
|
||||
return errFcall(fc, err.Error())
|
||||
}
|
||||
// Update fid path.
|
||||
for _, r := range s.storeRoutes() {
|
||||
if strings.HasPrefix(f.path, r.prefix) {
|
||||
// Replace the last path component with the new name.
|
||||
parent := f.path[:len(f.path)-len(oldName)]
|
||||
cs.mu.Lock()
|
||||
f.path = "/s/" + parts[0] + "/t/" + newRel
|
||||
f.path = parent + newDir.Name
|
||||
f.qid.Path = qidPath(f.path)
|
||||
cs.mu.Unlock()
|
||||
} else if len(parts) == 1 && f.path != "/s/new" {
|
||||
// Session directory rename: /s/{old} -> /s/{new}
|
||||
if err := s.sessionStore.Rename(parts[0], newDir.Name); err != nil {
|
||||
return errFcall(fc, err.Error())
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return &plan9.Fcall{Type: plan9.Rwstat, Tag: fc.Tag}
|
||||
}
|
||||
|
||||
|
|
@ -1263,35 +1128,11 @@ func (s *Server) remove(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
|||
path := f.path
|
||||
s.log.Debug("Tremove path=%q", path)
|
||||
|
||||
// Generic store-backed files.
|
||||
if st, name := s.routeStore(path); st != nil {
|
||||
if err := st.Delete(name); err != nil {
|
||||
return errFcall(fc, err.Error())
|
||||
}
|
||||
return &plan9.Fcall{Type: plan9.Rremove, Tag: fc.Tag}
|
||||
}
|
||||
|
||||
var err error
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/s/") && path != "/s/new":
|
||||
parts := strings.SplitN(strings.TrimPrefix(path, "/s/"), "/", 3)
|
||||
if len(parts) >= 2 && (parts[1] == "t" || strings.HasPrefix(parts[1], "t/")) {
|
||||
if len(parts) == 2 && parts[1] == "t" {
|
||||
err = nil // can't remove the t/ dir itself
|
||||
} else if len(parts) == 3 && parts[1] == "t" {
|
||||
err = s.toolStore.Delete(parts[2])
|
||||
} else {
|
||||
err = nil
|
||||
}
|
||||
} else if len(parts) >= 2 {
|
||||
err = nil // synthetic file; let rm -r continue
|
||||
} else {
|
||||
err = s.sessionStore.Delete(parts[0])
|
||||
}
|
||||
default:
|
||||
st, name := s.routeStore(path)
|
||||
if st == nil {
|
||||
return errFcall(fc, "remove not supported")
|
||||
}
|
||||
if err != nil {
|
||||
if err := st.Delete(name); err != nil {
|
||||
return errFcall(fc, err.Error())
|
||||
}
|
||||
return &plan9.Fcall{Type: plan9.Rremove, Tag: fc.Tag}
|
||||
|
|
@ -1323,15 +1164,6 @@ func (s *Server) InterruptAll() {
|
|||
func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
|
||||
var dirs []plan9.Dir
|
||||
|
||||
makeDir := func(name, fpath string, isDir bool, mode plan9.Perm) plan9.Dir {
|
||||
q := plan9.Qid{Path: qidPath(fpath)}
|
||||
if isDir {
|
||||
q.Type = QTDir
|
||||
}
|
||||
uid, gid := s.fileOwnerGroup(fpath)
|
||||
return plan9.Dir{Qid: q, Mode: mode, Name: name, Uid: uid, Gid: gid, Muid: uid}
|
||||
}
|
||||
|
||||
if path == "/" {
|
||||
rootEntries := []string{"a", "backends", "help", "m", "p", "s", "sk", "tmp", "u", "x", "tr"}
|
||||
for _, name := range rootEntries {
|
||||
|
|
@ -1340,6 +1172,7 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
|
|||
dirs = append(dirs, st)
|
||||
}
|
||||
} else if st := s.routeDir(path); st != nil {
|
||||
// Top-level store directory (e.g. /a, /s, /m).
|
||||
entries, _ := st.List()
|
||||
for _, e := range entries {
|
||||
fpath := path + "/" + e.Name()
|
||||
|
|
@ -1350,53 +1183,22 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
|
|||
}
|
||||
dirs = append(dirs, d)
|
||||
}
|
||||
} else if path == "/s" {
|
||||
entries, _ := s.sessionStore.List()
|
||||
for _, e := range entries {
|
||||
info, _ := e.Info()
|
||||
perm := plan9.Perm(info.Mode() & 0777)
|
||||
if e.IsDir() {
|
||||
perm = plan9.DMDIR | perm
|
||||
}
|
||||
dirs = append(dirs, makeDir(e.Name(), "/s/"+e.Name(), e.IsDir(), perm))
|
||||
} else if st, name := s.routeStore(path); st != nil {
|
||||
// Subdirectory within a store (e.g. /s/mysession, /s/mysession/t).
|
||||
type dirLister interface {
|
||||
ListDir(string) ([]os.DirEntry, error)
|
||||
}
|
||||
} else if strings.HasPrefix(path, "/s/") {
|
||||
parts := strings.SplitN(strings.TrimPrefix(path, "/s/"), "/", 3)
|
||||
if len(parts) >= 2 && parts[1] == "t" {
|
||||
if len(parts) == 2 {
|
||||
// /s/{sid}/t
|
||||
entries, _ := s.toolStore.List()
|
||||
allowed := s.sessionAllowTools(parts[0])
|
||||
for _, e := range entries {
|
||||
if e.Name() == "idx" {
|
||||
dirs = append(dirs, makeDir(e.Name(), path+"/"+e.Name(), false, 0444))
|
||||
continue
|
||||
}
|
||||
if allowed != nil && !allowed[e.Name()] {
|
||||
continue
|
||||
}
|
||||
mode := plan9.Perm(0777)
|
||||
dirs = append(dirs, makeDir(e.Name(), path+"/"+e.Name(), e.IsDir(), mode|plan9.Perm(boolToDir(e.IsDir()))))
|
||||
}
|
||||
} else {
|
||||
// /s/{sid}/t/{subdir...}
|
||||
rel := parts[2]
|
||||
entries, _ := s.toolStore.ListDir(rel)
|
||||
for _, e := range entries {
|
||||
dirs = append(dirs, makeDir(e.Name(), path+"/"+e.Name(), e.IsDir(), 0777|plan9.Perm(boolToDir(e.IsDir()))))
|
||||
if dl, ok := st.(dirLister); ok {
|
||||
entries, _ := dl.ListDir(name)
|
||||
for _, e := range entries {
|
||||
fpath := path + "/" + e.Name()
|
||||
d := s.makeStat(fpath)
|
||||
if info, err := e.Info(); err == nil {
|
||||
d.Atime = uint32(info.ModTime().Unix())
|
||||
d.Mtime = uint32(info.ModTime().Unix())
|
||||
}
|
||||
dirs = append(dirs, d)
|
||||
}
|
||||
} else if len(parts) == 1 {
|
||||
// /s/{sid} — session files + t/
|
||||
sessID := parts[0]
|
||||
if sfs, ok := s.sessionFileStore(sessID); ok {
|
||||
entries, _ := sfs.List()
|
||||
for _, e := range entries {
|
||||
info, _ := e.Info()
|
||||
dirs = append(dirs, makeDir(e.Name(), path+"/"+e.Name(), false, plan9.Perm(info.Mode())))
|
||||
}
|
||||
}
|
||||
dirs = append(dirs, makeDir("t", path+"/t", true, plan9.DMDIR|0500))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1607,13 +1409,9 @@ func (s *Server) makeStat(path string) plan9.Dir {
|
|||
dir.Mtime = uint32(info.ModTime().Unix())
|
||||
}
|
||||
case strings.HasPrefix(path, "/s/"):
|
||||
parts := strings.SplitN(strings.TrimPrefix(path, "/s/"), "/", 2)
|
||||
if len(parts) == 2 {
|
||||
if sfs, ok := s.sessionFileStore(parts[0]); ok {
|
||||
if info, err := sfs.Stat(parts[1]); err == nil {
|
||||
dir.Length = uint64(info.Size())
|
||||
}
|
||||
}
|
||||
name := strings.TrimPrefix(path, "/s/")
|
||||
if info, err := s.sessionStore.Stat(name); err == nil {
|
||||
dir.Length = uint64(info.Size())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
256
store/session.go
256
store/session.go
|
|
@ -111,6 +111,8 @@ type SessionStoreConfig struct {
|
|||
// for protocol-level fixups (e.g. fid path rewriting).
|
||||
OnRename func(oldID, newID string)
|
||||
SaveTranscript func([]byte) error
|
||||
// ToolStore provides access to the shared tool directory.
|
||||
ToolStore Store
|
||||
// NewCore, if non-nil, replaces the default backend.New + agent.NewAgentCore
|
||||
// path. It receives the session ID, agent name, and cwd, and returns a Core.
|
||||
NewCore func(sessionID, agentName, cwd string) (agent.Core, error)
|
||||
|
|
@ -151,7 +153,7 @@ func NewSessionStore(cfg SessionStoreConfig) *SessionStore {
|
|||
ListFn: ss.list,
|
||||
OpenFn: ss.openEntry,
|
||||
DeleteFn: ss.del,
|
||||
CreateFn: func(string) error { return fmt.Errorf("create not supported for sessions") },
|
||||
CreateFn: ss.create,
|
||||
RenameFn: ss.renameSession,
|
||||
}
|
||||
return ss
|
||||
|
|
@ -177,23 +179,85 @@ func (s *SessionStore) list() ([]os.DirEntry, error) {
|
|||
return entries, nil
|
||||
}
|
||||
|
||||
// ListDir lists entries in a subdirectory (e.g. "{id}", "{id}/t", "{id}/t/{subdir}").
|
||||
func (s *SessionStore) ListDir(name string) ([]os.DirEntry, error) {
|
||||
parts := strings.SplitN(name, "/", 3)
|
||||
sessID := parts[0]
|
||||
sess := s.Session(sessID)
|
||||
if sess == nil {
|
||||
return nil, fmt.Errorf("session not found: %s", sessID)
|
||||
}
|
||||
// {id} — list session files + t/
|
||||
if len(parts) == 1 {
|
||||
sfs, err := s.openStore(sess)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := sfs.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries = append(entries, DirEntry("t", 0500))
|
||||
return entries, nil
|
||||
}
|
||||
// {id}/t — list tools (filtered by allowTools)
|
||||
if parts[1] == "t" && s.cfg.ToolStore != nil {
|
||||
if len(parts) == 2 {
|
||||
return s.cfg.ToolStore.List()
|
||||
}
|
||||
// {id}/t/{subdir} — delegate to ToolStore.ListDir if available
|
||||
type listDirer interface {
|
||||
ListDir(string) ([]os.DirEntry, error)
|
||||
}
|
||||
if ld, ok := s.cfg.ToolStore.(listDirer); ok {
|
||||
return ld.ListDir(parts[2])
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("%s: not a directory", name)
|
||||
}
|
||||
|
||||
func (s *SessionStore) stat(name string) (os.FileInfo, error) {
|
||||
// Top-level fixed files (new, idx, sh, etc.)
|
||||
if mode, ok := sessionStoreFiles[name]; ok {
|
||||
return &SyntheticFileInfo{Name_: name, Mode_: mode}, nil
|
||||
}
|
||||
parts := strings.SplitN(name, "/", 3)
|
||||
sessID := parts[0]
|
||||
s.mu.RLock()
|
||||
_, ok := s.sessions[name]
|
||||
sess, ok := s.sessions[sessID]
|
||||
s.mu.RUnlock()
|
||||
if ok {
|
||||
return &SyntheticFileInfo{Name_: name, Mode_: 0555, IsDir_: true}, nil
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: not found", name)
|
||||
}
|
||||
return nil, fmt.Errorf("%s: not found", name)
|
||||
// Session directory: {id}
|
||||
if len(parts) == 1 {
|
||||
return &SyntheticFileInfo{Name_: sessID, Mode_: 0555, IsDir_: true}, nil
|
||||
}
|
||||
// Tools directory: {id}/t
|
||||
if parts[1] == "t" {
|
||||
if len(parts) == 2 {
|
||||
return &SyntheticFileInfo{Name_: "t", Mode_: 0500, IsDir_: true}, nil
|
||||
}
|
||||
// Tool file: {id}/t/{rel}
|
||||
if s.cfg.ToolStore != nil {
|
||||
return s.cfg.ToolStore.Stat(parts[2])
|
||||
}
|
||||
return nil, fmt.Errorf("%s: not found", name)
|
||||
}
|
||||
// Session file: {id}/{file}
|
||||
sfs, err := s.openStore(sess)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sfs.Stat(parts[1])
|
||||
}
|
||||
|
||||
func (s *SessionStore) openEntry(name string) (StoreEntry, error) {
|
||||
notBlocking := func(context.Context, string) ([]byte, string, error) {
|
||||
return nil, "", fmt.Errorf("blocking read not supported")
|
||||
}
|
||||
|
||||
// Top-level fixed files.
|
||||
switch name {
|
||||
case "new":
|
||||
return &EntryConfig{
|
||||
|
|
@ -213,30 +277,136 @@ func (s *SessionStore) openEntry(name string) (StoreEntry, error) {
|
|||
WriteFn: func([]byte) error { return fmt.Errorf("idx: read-only") },
|
||||
BlockingReadFn: notBlocking,
|
||||
}, nil
|
||||
default:
|
||||
if _, ok := sessionStoreFiles[name]; ok {
|
||||
return &EntryConfig{
|
||||
StatFn: func() (os.FileInfo, error) { return &SyntheticFileInfo{Name_: name, Mode_: sessionStoreFiles[name]}, nil },
|
||||
ReadFn: func() ([]byte, error) {
|
||||
return s.cfg.ReadFile(paths.CfgDir() + "/scripts/s/" + name)
|
||||
},
|
||||
WriteFn: func([]byte) error { return fmt.Errorf("%s: not writable", name) },
|
||||
BlockingReadFn: notBlocking,
|
||||
}, nil
|
||||
}
|
||||
if _, ok := sessionStoreFiles[name]; ok {
|
||||
return &EntryConfig{
|
||||
StatFn: func() (os.FileInfo, error) { return &SyntheticFileInfo{Name_: name, Mode_: sessionStoreFiles[name]}, nil },
|
||||
ReadFn: func() ([]byte, error) {
|
||||
return s.cfg.ReadFile(paths.CfgDir() + "/scripts/s/" + name)
|
||||
},
|
||||
WriteFn: func([]byte) error { return fmt.Errorf("%s: not writable", name) },
|
||||
BlockingReadFn: notBlocking,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Hierarchical paths: {id}/{file} or {id}/t/{tool}
|
||||
parts := strings.SplitN(name, "/", 3)
|
||||
sessID := parts[0]
|
||||
sess := s.Session(sessID)
|
||||
if sess == nil {
|
||||
return nil, fmt.Errorf("%s: not found", name)
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
return nil, fmt.Errorf("%s: is a directory", name)
|
||||
}
|
||||
// Tool file: {id}/t/{rel}
|
||||
if parts[1] == "t" {
|
||||
if len(parts) < 3 {
|
||||
return nil, fmt.Errorf("%s: is a directory", name)
|
||||
}
|
||||
rel := parts[2]
|
||||
if s.cfg.ToolStore == nil {
|
||||
return nil, fmt.Errorf("%s: no tool store", name)
|
||||
}
|
||||
// Apply per-session allowTools filtering on idx.
|
||||
if rel == "idx" && len(sess.AllowTools) > 0 {
|
||||
allowed := make(map[string]bool, len(sess.AllowTools))
|
||||
for _, t := range sess.AllowTools {
|
||||
allowed[t] = true
|
||||
}
|
||||
return s.openFilteredToolIdx(allowed)
|
||||
}
|
||||
return s.cfg.ToolStore.Open(rel)
|
||||
}
|
||||
// Session file: {id}/{file}
|
||||
sfs, err := s.openStore(sess)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sfs.Open(parts[1])
|
||||
}
|
||||
|
||||
|
||||
func (s *SessionStore) openFilteredToolIdx(allowed map[string]bool) (StoreEntry, error) {
|
||||
base, err := s.cfg.ToolStore.Open("idx")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &EntryConfig{
|
||||
StatFn: base.Stat,
|
||||
ReadFn: func() ([]byte, error) {
|
||||
data, err := base.Read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return filterToolIdx(data, allowed), nil
|
||||
},
|
||||
WriteFn: base.Write,
|
||||
BlockingReadFn: base.BlockingRead,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func filterToolIdx(data []byte, allowed map[string]bool) []byte {
|
||||
var out []byte
|
||||
for _, section := range strings.Split(string(data), "## ") {
|
||||
if section == "" {
|
||||
continue
|
||||
}
|
||||
name, _, _ := strings.Cut(section, "\n")
|
||||
if allowed[name] {
|
||||
out = append(out, "## "...)
|
||||
out = append(out, section...)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("%s: not found", name)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *SessionStore) create(name string) error {
|
||||
// Tool create: {id}/t/{rel}
|
||||
parts := strings.SplitN(name, "/", 3)
|
||||
if len(parts) == 3 && parts[1] == "t" && s.cfg.ToolStore != nil {
|
||||
return s.cfg.ToolStore.Create(parts[2])
|
||||
}
|
||||
return fmt.Errorf("create not supported: %s", name)
|
||||
}
|
||||
|
||||
// MkdirAll creates a directory within the session's tool store.
|
||||
func (s *SessionStore) MkdirAll(name string) error {
|
||||
parts := strings.SplitN(name, "/", 3)
|
||||
if len(parts) == 3 && parts[1] == "t" {
|
||||
type mkdirAller interface {
|
||||
MkdirAll(string) error
|
||||
}
|
||||
if md, ok := s.cfg.ToolStore.(mkdirAller); ok {
|
||||
return md.MkdirAll(parts[2])
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("mkdir not supported: %s", name)
|
||||
}
|
||||
|
||||
func (s *SessionStore) del(name string) error {
|
||||
s.mu.RLock()
|
||||
_, ok := s.sessions[name]
|
||||
s.mu.RUnlock()
|
||||
if ok {
|
||||
s.KillSession(name)
|
||||
return nil
|
||||
parts := strings.SplitN(name, "/", 3)
|
||||
sessID := parts[0]
|
||||
// Delete session directory itself.
|
||||
if len(parts) == 1 {
|
||||
s.mu.RLock()
|
||||
_, ok := s.sessions[sessID]
|
||||
s.mu.RUnlock()
|
||||
if ok {
|
||||
s.KillSession(sessID)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("session not found: %s", sessID)
|
||||
}
|
||||
return fmt.Errorf("session not found: %s", name)
|
||||
// Delete tool file: {id}/t/{rel}
|
||||
if parts[1] == "t" && len(parts) == 3 {
|
||||
if s.cfg.ToolStore != nil {
|
||||
return s.cfg.ToolStore.Delete(parts[2])
|
||||
}
|
||||
return fmt.Errorf("no tool store")
|
||||
}
|
||||
// Session files are synthetic; allow rm -r to continue.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Session returns the session for the given ID, or nil.
|
||||
|
|
@ -261,17 +431,22 @@ func (s *SessionStore) SessionByUname(uname string) *Session {
|
|||
// OpenStore returns a RunnableStore for the given session ID.
|
||||
func (s *SessionStore) OpenStore(id string) (RunnableStore, error) {
|
||||
if sess := s.Session(id); sess != nil {
|
||||
return NewSessionFileStore(
|
||||
sess,
|
||||
s.cfg.Log,
|
||||
func() { s.KillSession(id) },
|
||||
func(newID string) error { return s.Rename(id, newID) },
|
||||
s.cfg.SaveTranscript,
|
||||
), nil
|
||||
return s.openStore(sess)
|
||||
}
|
||||
return nil, fmt.Errorf("session not found: %s", id)
|
||||
}
|
||||
|
||||
func (s *SessionStore) openStore(sess *Session) (RunnableStore, error) {
|
||||
id := sess.RunnableID()
|
||||
return NewSessionFileStore(
|
||||
sess,
|
||||
s.cfg.Log,
|
||||
func() { s.KillSession(id) },
|
||||
func(newID string) error { return s.Rename(id, newID) },
|
||||
s.cfg.SaveTranscript,
|
||||
), nil
|
||||
}
|
||||
|
||||
// InterruptAll interrupts every active session.
|
||||
func (s *SessionStore) InterruptAll() {
|
||||
s.mu.RLock()
|
||||
|
|
@ -463,7 +638,26 @@ func (s *SessionStore) createSession(args []string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *SessionStore) renameSession(oldID, newID string) error {
|
||||
func (s *SessionStore) renameSession(old, new string) error {
|
||||
// Tool rename: {id}/t/{oldRel} -> newName
|
||||
if strings.Contains(old, "/t/") {
|
||||
parts := strings.SplitN(old, "/", 3)
|
||||
if len(parts) == 3 && parts[1] == "t" && s.cfg.ToolStore != nil {
|
||||
oldRel := parts[2]
|
||||
// new is just the new base name; reconstruct full rel path.
|
||||
oldBase := oldRel
|
||||
if i := strings.LastIndex(oldRel, "/"); i >= 0 {
|
||||
oldBase = oldRel[i+1:]
|
||||
}
|
||||
parentRel := oldRel[:len(oldRel)-len(oldBase)]
|
||||
newRel := parentRel + new
|
||||
return s.cfg.ToolStore.Rename(oldRel, newRel)
|
||||
}
|
||||
return fmt.Errorf("rename not supported: %s", old)
|
||||
}
|
||||
// Session rename: {oldID} -> {newID}
|
||||
oldID := old
|
||||
newID := new
|
||||
s.mu.Lock()
|
||||
|
||||
sess, ok := s.sessions[oldID]
|
||||
|
|
|
|||
Reference in New Issue