Documentation
¶
Overview ¶
Package session owns the server-side session: its append-only event log and its state machine.
The log is the primitive everything else falls out of. Resume, a second client attaching mid-turn, and session density are all the same mechanism: a monotonic sequence a reader can rejoin at any point. It is the same principle as the model context, one layer up.
Spec: docs/specs/architecture/client-server-protocol/202608072240-*.
Index ¶
- Constants
- Variables
- func Carry(path string) ([]protocol.Event, int, error)
- func CleanName(name string) (string, error)
- func NewID(clock Clock, entropy func() uint32) string
- func Prune(dir string, b PruneBudget) (int, error)
- func ReadImage(path string, limit int64) (ce.Image, error)
- func Rebuild(path string) ([]ce.Message, error)
- func Rename(dir, id, name string, now func() time.Time) error
- func Transcript(path string) (string, error)
- type Clock
- type EventLog
- func (l *EventLog) Append(t protocol.EventType, payload any) (protocol.Event, error)
- func (l *EventLog) AppendUnrecorded(t protocol.EventType, payload any) (protocol.Event, error)
- func (l *EventLog) Close()
- func (l *EventLog) FirstSeq() uint64
- func (l *EventLog) LastSeq() uint64
- func (l *EventLog) Replay(from uint64) ([]protocol.Event, error)
- func (l *EventLog) SetRecord(r *Record)
- func (l *EventLog) Subscribe(ctx context.Context, from uint64) (<-chan protocol.Event, func(), error)
- func (l *EventLog) Subscribers() int
- type Manager
- type PruneBudget
- type Record
- type Session
- func (s *Session) Approve(ctx context.Context, req protocol.ApprovalRequest, timeout time.Duration) (protocol.ApprovalDecision, error)
- func (s *Session) Close()
- func (s *Session) Describe() protocol.Session
- func (s *Session) Emit(t protocol.EventType, payload any)
- func (s *Session) EmitCarried()
- func (s *Session) Exec(ctx context.Context, command string) error
- func (s *Session) Interrupt()
- func (s *Session) Pending() []protocol.ApprovalRequest
- func (s *Session) Resolve(approvalID string, d protocol.ApprovalDecision) error
- func (s *Session) SetMode(name string) error
- func (s *Session) State() protocol.SessionState
- func (s *Session) Steer(text string) error
- func (s *Session) Submit(text string, images ...ce.Image) error
- func (s *Session) TakeSteering() string
- func (s *Session) Undo() (protocol.UndoResult, error)
- type Standing
- type Summary
Constants ¶
const NameLimit = 120
NameLimit is how long a name may be.
Long enough for a sentence somebody would recognise, short enough that a sidebar row is still a row. What does not fit is refused rather than trimmed: silently keeping half of what was typed is how somebody ends up with a name they did not choose.
Variables ¶
var ErrNoSuchSession = errors.New("no such session")
ErrNoSuchSession is returned when there is no record to name.
Functions ¶
func Carry ¶
Carry reads a record as the events a continuing session replays, and how many turns they represent.
Rebuild answers the neighbouring question — what the model is sent — and the two are deliberately different readings of the same file. The model is sent messages, and a message is not watchable: it has no tool crossing, no approval, no reasoning, none of what a person needs to see to know the work survived. Carrying only what the model needs is what left the screen blank.
func CleanName ¶ added in v0.2.0
CleanName trims a name and reports whether it is usable.
Control characters go, because a record is read back line by line and a newline inside a name would make one line look like two.
func NewID ¶
NewID returns a time-ordered, filesystem-safe identifier.
Not a ULID library: the requirement is "sortable by time and safe in a filename", which twenty lines cover without another dependency in a binary that promises to stay small.
func Prune ¶
func Prune(dir string, b PruneBudget) (int, error)
Prune removes old session records and reports how many went.
It runs when a session opens rather than on a timer: nothing should be deleting a person's history while the program is not running, and a readdir on the way into a session is cheap enough that nobody notices.
The order is deliberate. Age first, because "older than a month" is the rule a person can predict. Size second, oldest first, because a cap has to be met somehow and the oldest is the one least likely to be wanted. The floor last, because it overrides both.
func ReadImage ¶
ReadImage loads a picture the person named, ready to be shown to the model.
The media type comes from the BYTES, never from the extension. A file called shot.png that is really a JPEG is common — every screenshot tool and every chat app renames things — and trusting the name would produce a provider error about a mismatch nobody can connect to what they did.
Refusing here rather than at the provider is the whole point of the function. A rejected request tells the person their turn failed; this tells them the file is not a picture, or is too big, while they can still do something about it.
func Rebuild ¶
Rebuild turns a record back into the conversation the model was sent.
The record is the only copy: nothing else survives the session, and storing the history a second time would be a second copy to drift from the first — which this codebase has now found four separate times.
It does not compact. The engine checks for compaction at the top of its first iteration, before any request, so a seeded history that is too large is handled by the same code that handles one that grew — and re-running it here would be a second implementation of the thing most worth having only one of.
What cannot be rebuilt is left out rather than guessed: reminders the harness appended, approvals granted in a moment that has passed, and processes that died with their session. All three are re-asked or re-derived by the turn that follows.
func Rename ¶ added in v0.2.0
Rename appends the name to a session's record.
An empty name is not an error: it restores the title derived from the first question, which is the way back rather than a second command for undoing.
func Transcript ¶
Transcript renders a record as the conversation it was.
Reading, and nothing else: no model, no reconstruction, no cost. That is why it comes before continuing a session — it answers "what did it do" on its own, and it answers it for a session that ended a month ago.
Deltas are joined. They are how text arrives, not how it was said, and one answer printed as sixteen fragments on sixteen lines is a transcript nobody reads twice.
Types ¶
type Clock ¶
Clock is injected so tests can produce reproducible timestamps. Events carry a time for humans reading a log; nothing in the system depends on it, and golden comparisons zero it.
type EventLog ¶
type EventLog struct {
// contains filtered or unexported fields
}
EventLog is an append-only sequence per session.
func NewEventLog ¶
NewEventLog builds a log. A retention of zero keeps everything.
func (*EventLog) Append ¶
Append records a fact and returns the stored event.
Sequence assignment and storage happen under one lock. Splitting them is the classic source of gaps under concurrency: two appends would both read the counter before either wrote.
func (*EventLog) AppendUnrecorded ¶ added in v0.5.0
AppendUnrecorded puts an event in the log and in front of every subscriber without writing it to the record.
It exists for exactly one thing: the conversation a session CONTINUES.
Continuing used to copy the whole of the previous record into the new one, so a session that continued a session that continued a session held three copies of the first — a file of 3.6 MB on this machine, and growing quadratically with the number of times somebody typed `-c`.
The copy served the screen, and the screen is served by the log in memory. The record does not need it: what it needs is the marker saying which conversation this one continues, and Rebuild and Carry follow that chain. One line on disk instead of eighteen thousand.
func (*EventLog) FirstSeq ¶ added in v0.5.0
LastSeq returns the highest sequence assigned. FirstSeq is the lowest sequence still held.
A client has to be told, not left to assume 1. Continuing a long conversation appends every carried event to this log, and retention then drops the oldest — so the earliest event a session HAS is routinely not the first one it had. A client that subscribes from 1 anyway is refused, and the conversation it asked to continue does not open at all.
func (*EventLog) Replay ¶
Replay returns events from `from` onward.
A `from` below what is still held is an error rather than a truncated answer: silently starting later would leave the client believing it had the whole history.
func (*EventLog) SetRecord ¶
SetRecord attaches the file this session is written to. Called at session creation, before anything is appended.
func (*EventLog) Subscribe ¶
func (l *EventLog) Subscribe(ctx context.Context, from uint64) (<-chan protocol.Event, func(), error)
Subscribe replays from `from` and then streams live events.
The join is the delicate part: opening the live feed before replay finishes duplicates events, opening it after leaves a gap. Registering the subscriber and snapshotting the backlog under one lock makes both impossible, and the buffered prefix is deduplicated by sequence.
func (*EventLog) Subscribers ¶
Subscribers reports how many readers are attached.
Exists for the tests that assert a stream is cleaned up when a client leaves, and that is the whole of it. The doc used to claim the shutdown path called it; the shutdown path does not, and a comment that names a caller which does not exist is the kind a reader trusts.
type Manager ¶
type Manager struct {
// contains filtered or unexported fields
}
Manager owns the live sessions.
func NewManager ¶
NewManager builds a manager with a ceiling on live sessions.
func (*Manager) List ¶
List returns live sessions in creation order, so a client's list does not reshuffle between calls.
type PruneBudget ¶
type PruneBudget struct {
// MaxAge drops records older than this. Zero means age is not a policy.
MaxAge time.Duration
// MaxBytes caps the directory, oldest removed first. Zero means size is
// not a policy.
MaxBytes int64
// KeepAtLeast is the floor, and it beats both budgets.
//
// Someone who used dcode twice last year should still find those two
// sessions. A cleanup that empties the directory the first time it runs is
// a cleanup nobody trusts again, and the whole point of the record is
// being able to go back to it.
KeepAtLeast int
// Live are session ids currently open. A session being written is not
// garbage, however old its first line is.
Live map[string]bool
}
PruneBudget is how much history is kept.
Two budgets rather than one, and both have to hold. Age alone lets a busy fortnight fill a disk inside the window; size alone deletes this morning's work on a busy day. Neither is wrong, and neither is enough.
type Record ¶
type Record struct {
// contains filtered or unexported fields
}
Record is everything a session did, on disk.
It was a spill: only what retention pushed out of memory, deleted when the session closed. Both halves made sense for the one job it had — letting a client that was away rejoin — and both were wrong for the job it did not have. A session that fits in memory, which is nearly all of them, left nothing at all, and what little was written went away exactly when someone might want to read it.
So there was no way to audit what dcode actually did, no way to reconstruct a session afterwards, and no evidence to reason from when its behaviour needed improving. The mechanism was there, with a config key and a state directory; nothing in the default path turned it on and closing threw it away.
Append-only, one JSON object per line. No index and no rewriting: reading is sequential from a sequence number, which a scan answers directly, and jq answers everything else.
func NewRecord ¶
NewRecord opens the file for a session. An empty directory disables recording, which is what turns it off for someone who does not want it.
0700 on the directory and 0600 on the file: a transcript holds whatever the person typed and whatever the agent read, which is the most private thing this program touches.
func (*Record) Append ¶
Append writes events to the record.
The handle stays open. It used to open and close per call, which was fine when the only caller was retention overflow and is not when the caller is every event: a streamed answer is hundreds of deltas, and a syscall each would be paid on every token.
Anything that is not a text delta is flushed as it happens. A crash mid-turn must not cost the tool calls and approvals that led up to it, and those are the low-frequency events — flushing them costs almost nothing, while flushing deltas would give back the syscall the buffer just saved.
type Session ¶
type Session struct {
ID string
Workspace string
Model string
Mode string
CreatedAt time.Time
// ContextWindow is the model's window, so a client can turn a token count
// into the percentage a person can act on.
ContextWindow int
Log *EventLog
// Standing is the record of decisions that outlive the session. Optional:
// without one the session asks every time, which is what it did before.
//
// A port rather than the record itself, because this package must not learn
// what the boundaries mean. It asks "was this already answered" and says
// "remember this"; which crossings are worth remembering, and where the
// answer is kept, belong to the layer that knows both.
Standing Standing
// Carried is the conversation this session continues, waiting to be put in
// the log. It is held rather than emitted at construction because the log
// has to open with this session's own creation: a record whose first line
// is somebody else's turn is not a record anything can describe.
Carried []protocol.Event
// CarriedFrom and CarriedTurns describe where it came from, for the marker
// that opens the replay.
CarriedFrom string
CarriedTurns int
// contains filtered or unexported fields
}
Session is the server-owned unit of work. The client holds none of this: killing, restarting or swapping a client does not touch a session, and a turn in flight continues with zero clients attached.
func New ¶
func New(id, workspace, model, mode string, engine *loop.Engine, log *EventLog, clock Clock) *Session
New builds a session around an engine.
func (*Session) Approve ¶
func (s *Session) Approve(ctx context.Context, req protocol.ApprovalRequest, timeout time.Duration) ( protocol.ApprovalDecision, error, )
Approve is the loop's side of a boundary crossing: it registers the question and blocks until a client answers or the deadline passes.
func (*Session) EmitCarried ¶
func (s *Session) EmitCarried()
EmitCarried puts the continued conversation in the log, behind a marker.
Called once, right after the session announces itself. The client draws the screen from events, so the events go in the log — but only the MARKER goes in the record, and the next session to continue this one follows the marker back rather than reading a copy.
The copy was the original answer to all three, and it was the right shape with the wrong cost: each continuation copied every copy before it, so the record grew quadratically in the number of times somebody typed `-c`.
func (*Session) Exec ¶ added in v0.6.0
Exec runs a command the person typed, outside any turn.
It takes the same lock Submit does and refuses for the same reasons: a command run beside a turn would interleave its tool events with the turn's and edit the history the turn is in the middle of reading.
Synchronous, unlike Submit. A typed command is short and the person is waiting on its output; there is nothing to watch in the meantime.
func (*Session) Interrupt ¶
func (s *Session) Interrupt()
Interrupt cancels the running turn. Idempotent: interrupting an idle session is not an error, because the user cannot know the turn just finished.
func (*Session) Pending ¶
func (s *Session) Pending() []protocol.ApprovalRequest
Pending lists unanswered approvals, so a client attaching mid-turn can render the question it missed.
func (*Session) Resolve ¶
func (s *Session) Resolve(approvalID string, d protocol.ApprovalDecision) error
Resolve answers a pending approval. First writer wins; the rest get a conflict, so two clients cannot both believe they decided.
func (*Session) SetMode ¶ added in v0.8.0
SetMode switches the session behavioural mode (plan, assist, auto).
The mapping is: plan -> read-only + never, assist -> workspace-write + on-request, auto -> full-access + on-request. The engine takes the new SandboxMode and ApprovalPolicy atomically (loop.Engine.SetMode), and the session announces the change over the event log so a client attaching after reads the current mode.
No-op (no event, no error) when name is already the current mode. Live turns are NOT interrupted.
func (*Session) State ¶
func (s *Session) State() protocol.SessionState
State returns the current state.
func (*Session) Steer ¶
Steer hands the running turn something the person said without ending it.
The two things a person could do when a turn went wrong were let it finish wrong and kill it. Killing loses everything the turn learned, which is why people watch bad turns to the end.
Refused when nothing is running, and deliberately: a correction to a turn that already finished is a new turn, and quietly turning it into one would mean a message the person believed was urgent waiting for them to notice it never took effect. The caller submits it instead, which is a different thing and should look like one.
func (*Session) Submit ¶
Submit starts a turn.
One turn per session: concurrent input is refused rather than queued here. The queue belongs to the client, which is what keeps the event log linearly ordered and the append-only context coherent.
func (*Session) TakeSteering ¶
TakeSteering hands the engine the oldest correction, or "".
Exported because the engine is wired in package app, one layer above: the session is created after the engine it owns, so the queue is reached through a closure bound late rather than through a field set at construction.
One at a time so the engine drains in order and so a correction added while the engine is draining is not lost: the loop calls until this is empty.
type Standing ¶
type Standing interface {
// Granted reports a decision already made for this crossing, or an empty
// decision when the question still has to be asked.
Granted(req protocol.ApprovalRequest) protocol.ApprovalDecision
// Remember writes down an answer meant to outlive the session.
Remember(req protocol.ApprovalRequest, d protocol.ApprovalDecision) error
}
Standing is the record of decisions that outlive a session.
Deliberately ignorant of what a boundary means. This package knows a crossing was declared and that someone must answer; which crossings are worth remembering, and where an answer is kept, belong to the layer that knows both the policy and the user's configuration.
type Summary ¶
type Summary struct {
ID string
Workspace string
Model string
// Title is the first thing that was asked, trimmed to one line.
Title string
// Name is what a person called this conversation, empty when nobody has.
// Kept apart from Title so a listing can say which it is showing — a
// derived title and a chosen one are not the same claim.
Name string
// Turns is how many completed, which is the cheapest honest measure of
// how much happened.
Turns int
Started time.Time
Bytes int64
}
Summary is one session, as a person picking from a list needs to see it.
The title is the load-bearing field. A list of twelve-character ids is a list nobody chooses from, and everything else here — a time, a size, a count — is true of every session and distinguishes none of them.
func Browse ¶
Browse reads the record directory and describes each session.
workspace filters, and empty means all of them. Filtering is the default at the call site rather than here because "what was I doing in this project" is the question being asked almost every time, and the other one is rare enough to deserve a flag.
A file that is not a record, or a line that is not an event, is skipped rather than failing the listing. One corrupt file must not make the other forty unreadable — that is the same reasoning that keeps a failed record from stopping a session.