Update virtfs architecture documentation

This commit is contained in:
Ollie Agent 2026-08-16 19:36:18 +02:00
parent b1ef9dc68c
commit 0bab759f56
2 changed files with 157 additions and 179 deletions

157
doc/architecture-virtfs.md Normal file
View File

@ -0,0 +1,157 @@
# virtfs Architecture
`virtfs` is the embedded Go library that defines Ollie’s synthetic filesystem. It separates namespace declaration from protocol transport: `cmd/olliesrv/internal/fs/spec.go` declares the Ollie namespace, `virtfs.BuildTree` materializes it as an in-memory tree, and the 9P server exposes that tree to clients.
The library is installed as part of the normal Go build. It is not a separately installed runtime service or configuration directory. The repository package at `virtfs/` is compiled into `olliesrv` by the `ninep` and `core` build targets in `justfile`.
## Design
Declarations are built with constructors and options. Handlers are plain closures that capture their target state; there is no handler-context object or later wiring pass. Dynamic directories use `Each`, whose callback returns fully bound child declarations.
The resulting `*virtfs.Tree` supports the filesystem operations needed by the 9P adapter and can also be used directly in tests.
## Core Type
```go
type FsNodeDecl struct {
Name string
Aliases []string // alternate lookup names
UID string // "" = inherit from parent
GID string // "" = inherit from parent
Mode os.FileMode // 0 = inherit parent default
Desc string // help description
Stat func() os.FileInfo
Children []FsNodeDecl
Bindings func() ([]FsNodeDecl, error)
Read func() ([]byte, error)
Write func([]byte) error
BlockOnce func(context.Context, string) ([]byte, string, error)
Stream func(context.Context, string) ([]byte, string, error)
Rdwr func(context.Context, []byte) ([]byte, error)
Remove func() error
Rename func(string) error
}
```
A declaration is a directory when it has `Children` or `Bindings`; otherwise it is a file. `BlockOnce`, `Stream`, and `Rdwr` are mutually exclusive. Handlers capture their target state in closures.
### `DirNode(name, children_or_options...)`
Declares a static directory. Arguments may be child declarations, slices of declarations, or `NodeOption` functions:
```go
virtfs.DirNode("session",
virtfs.FileNode("new", 0666, virtfs.Rdwr(requestSessionNew)),
virtfs.FileNode("idx", 0444, virtfs.Read(readSessionIdx)),
virtfs.GID("agent"),
)
```
### `Each(name, bindingsFn, options...)`
Declares a dynamic directory. The callback returns the currently available child declarations. Each returned child is already bound to its target through closures:
```go
virtfs.Each("{sname}", listSessions)
```
### `FileNode(name, mode, options...)`
Declares a file node. Options provide handlers, metadata, ownership, aliases, and help text:
```go
virtfs.FileNode("chat", 0444,
virtfs.Doc("Agent chat log"),
virtfs.Stream(streamAgentChat, agentSignal),
)
```
## Node Options
| Option | Signature | Purpose |
|---|---|---|
| `Read(fn)` | `func() ([]byte, error)` | Non-blocking read |
| `Write(fn)` | `func([]byte) error` | Write handler |
| `Stream(fn, signal)` | read callback plus signal channel | Blocking streaming read |
| `StreamRaw(fn)` | `func(context.Context, string) ...` | Custom streaming read |
| `BlockOnce(fn, signal)` | read callback plus signal channel | Return one changed value per open |
| `BlockOnceRaw(fn)` | `func(context.Context, string) ...` | Custom blocking read |
| `Rdwr(fn)` | `func(context.Context, []byte) ([]byte, error)` | Atomic write-then-read |
| `RemoveNode(fn)` | `func() error` | Remove handler |
| `RenameNode(fn)` | `func(string) error` | Rename handler |
| `StatOverride(fn)` | `func() os.FileInfo` | Custom stat |
| `UID(uid)` / `GID(gid)` | `string` | Ownership |
| `Alias(names...)` | `...string` | Alternate lookup names |
| `Doc(desc)` | `string` | Help description |
## Dynamic Declarations
The Ollie namespace uses `Each` for runtime collections such as sessions and agents. The callback builds declarations for the current objects and captures each object in its handlers. It does not receive a context or return a generic template that is wired later.
```go
virtfs.Each("{aname}", func() ([]virtfs.FsNodeDecl, error) {
var out []virtfs.FsNodeDecl
for _, a := range agents {
agent := a
out = append(out, virtfs.DirNode(agent.Name(),
virtfs.Alias(agent.ID()),
virtfs.FileNode("id", 0444, virtfs.Read(func() ([]byte, error) {
return []byte(agent.ID() + "\n"), nil
})),
))
}
return out, nil
})
```
## Building and Installation
`cmd/olliesrv/internal/fs.NewRoot` initializes session state, calls `buildTreeSpec`, and passes the resulting declaration to `virtfs.BuildTree`. The returned tree is mounted into the 9P server. `virtfs` is therefore a linked package, not a separately installed daemon.
The repository build uses:
```sh
just build # includes core and the 9P server
just install # installs the resulting runtime and data files
```
The install phase copies runtime data under `~/.config/ollie` and `~/.local/share/ollie`; it does not copy the `virtfs` package or install a separate virtfs directory.
## Tree API
```go
tree := virtfs.BuildTree(spec)
tree.List()
tree.Stat(path)
tree.Open(path)
tree.Create(path)
tree.Delete(path)
tree.Rename(oldPath, newPath)
tree.Readdir(path)
tree.MkdirAll(path, mode)
```
`virtfs.GenerateHelp(spec)` renders the `Doc` annotations used by the server’s help file.
## Rules
1. Directories use `Children` or `Bindings`; file handlers belong on leaf declarations.
2. `BlockOnce`, `Stream`, and `Rdwr` are mutually exclusive.
3. A template-style name such as `{sname}` must have a `Bindings` callback.
4. Ownership inherits through empty `UID` and `GID` values.
5. A zero mode inherits from the parent. Root directories default to `0755`; root files default to `0444`.
## Aliases
A node may declare alternate names with `Alias(...)`. During path walks, if no child matches `Name`, the tree checks `Aliases`. This lets clients address a node by immutable ID while listings use a mutable display name.
```go
virtfs.DirNode(session.Name(),
virtfs.Alias(session.ID()),
// ...
)
```
Aliases are invisible in directory listings. Session and agent declarations use IDs as stable aliases where needed. Clients that maintain long-lived paths should use the ID form.

View File

@ -1,179 +0,0 @@
# EDSL for 9P Filesystem Specification
The entire 9P namespace is declared as a single tree using an embedded
domain-specific language (EDSL) in Go. The declaration lives in
`fs/spec.go` and is the single source of truth for every file
and directory in the system.
## Core Type
```go
type FsNodeDecl struct {
Name string
Aliases []string // alternate walk names (invisible in listings)
UID string // "" = inherit from parent
GID string // "" = inherit from parent
Mode os.FileMode // 0 = inherit parent default
Stat func() os.FileInfo // metadata override
Children []FsNodeDecl // static children (implies directory)
List func(HandlerCtx) ([]FsNodeDecl, error) // dynamic children
Read func(HandlerCtx) ([]byte, error)
Write func(HandlerCtx, []byte) error
BlockOnce func(HandlerCtx, context.Context, string) ([]byte, string, error)
Stream func(HandlerCtx, context.Context, string) ([]byte, string, error)
Rdwr func(HandlerCtx, []byte) ([]byte, error)
Create func(HandlerCtx, string) error
Remove func(HandlerCtx) error
}
```
Nodes are either **directories** (have `Children` or `List`) or **leaf files**
(have read/write/blocking handlers). At most one blocking variant
(`BlockOnce`, `Stream`, `Rdwr`) may be set per node.
## Constructors
### `Dir(name, children_or_options...)`
Declares a static directory. Accepts a mix of child `FsNodeDecl` values
and `NodeOption` functions:
```go
Dir("session",
Leaf("new", 0666, Rdwr(requestSessionNew)),
Leaf("idx", 0444, Read(readSessionIdx)),
GID("agent"),
)
```
### `TemplateDir(name, listFn)`
Declares a dynamic directory whose children are resolved at runtime by
`listFn`. The list function receives a `HandlerCtx` and returns fully
built `[]FsNodeDecl`:
```go
TemplateDir("{id}", listSessions)
```
### `Leaf(name, mode, options...)`
Declares a file node. Options specify handlers and ownership:
```go
Leaf("env", 0444, Read(readSessionEnv))
Leaf("ctl", 0666, Read(nopRead), Write(writeSessionCtl))
Leaf("chat", 0444, Read(readAgentChat), Stream(blockAgentChat))
```
## Node Options
| Option | Signature | Sets |
|---|---|---|
| `Read(fn)` | `func(HandlerCtx) ([]byte, error)` | `d.Read` |
| `Write(fn)` | `func(HandlerCtx, []byte) error` | `d.Write` |
| `Stream(fn)` | `func(HandlerCtx, ctx, base) (data, base, err)` | `d.Stream` |
| `BlockOnce(fn)` | `func(HandlerCtx, ctx, base) (data, base, err)` | `d.BlockOnce` |
| `Rdwr(fn)` | `func(HandlerCtx, []byte) ([]byte, error)` | `d.Rdwr` |
| `CreateFile(fn)` | `func(HandlerCtx, string) error` | `d.Create` |
| `Remove(fn)` | `func(HandlerCtx) error` | `d.Remove` |
| `Stat(fn)` | `func() os.FileInfo` | `d.Stat` |
| `UID(s)` | string | `d.UID` |
| `GID(s)` | string | `d.GID` |
| `Alias(names...)` | `...string` | `d.Aliases` |
## Handler Context
All handler functions receive a `HandlerCtx` that carries runtime
dependencies. The context is populated per scope by the wire step:
```go
type HandlerCtx struct {
// Root-level (always available)
Root *Tree
Log *olog.Logger
ToolReg *toolsrv.Registry
Skills *skills.Registry
Models *ModelCache
Elevate *bypass.Broker
Invalidate func()
Shutdown func()
// Session scope
Session *Session
Kill func()
Rename func(string) error
SaveFn func([]byte) error
// Agent scope
Agent *agent.Agent
AgentLog *AgentLog
}
```
## Template Directories (Factories)
Template directories use the `List` function as a factory. The factory
receives the current `HandlerCtx`, builds fully-wired subtrees using the
EDSL constructors, and returns them:
```go
func listSessions(ctx HandlerCtx) ([]FsNodeDecl, error) {
rs := ctx.Root.Data.(*rootState)
for _, sess := range rs.sessions {
// capture session deps
sessionDir := Dir(sess.Name(),
UID(sess.Uname()), GID("agent"),
Leaf("env", 0444, Read(func(_ HandlerCtx) ([]byte, error) {
return []byte(sessionEnvContent(sess)), nil
})),
// ... more files
)
out = append(out, sessionDir)
}
return out, nil
}
```
## Rules
1. **Directories cannot have leaf handlers.** A node with `Children` or
`List` must not set `Read`, `Write`, `BlockOnce`, `Stream`, or `Rdwr`.
2. **At most one blocking handler.** `BlockOnce`, `Stream`, and `Rdwr`
are mutually exclusive.
3. **Template nodes must have a `List` function.** Names starting with `{`
and ending with `}` require a dynamic child resolver.
4. **`Create` and `Remove` are directory-only.**
5. **Ownership inherits.** Empty `UID`/`GID` values inherit from the
parent node, all the way up to root (set from the system user at
`NewRoot` time).
6. **Mode defaults.** `0` inherits from parent; if still 0 at the root,
directories default to `0755`, files to `0444`.
## Aliases
A node may declare alternate names via `Aliases []string`. During path
walks (`findChild`), if no child matches by `Name`, aliases are checked.
This allows nodes to be **addressed by immutable ID** even though they are
**listed by mutable display name**.
```go
sessionDir := Dir(s.Name(),
UID(s.Uname()), GID("agent"),
Alias(s.RunnableID()), // walk accepts UUID
// ...
)
```
Aliases are invisible — they do not appear in directory listings (`ls`).
They only affect path resolution during `Twalk`. This means:
- `ls session/` → shows display names (`myproject`, `scratch`)
- `read session/a1b2c3d4-uuid/id` → resolves via alias, works
- `read session/myproject/id` → resolves via name, works
Session directories alias their immutable `RunnableID()`. Agent directories
alias their immutable `ID()`. Clients that need stable paths (long-lived
connections, bookmarks) should use the ID form.