Help us shape the future of Go! Take the 2026 Go Developer Survey

sessions

package
v0.4.5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 16, 2026 License: MIT Imports: 22 Imported by: 0

Documentation

Overview

Explicit session deletion plus the shared lock-and-unlink core the retention pruner reuses.

Per-folder session subdirectory derivation and the folder-file policy.

Retention pruner: deletes expired session files across every per-folder subdir, with a non-blocking pre-unlink lock and accumulated failures (1, 450c).

--resume no-id listing: enumerate resumable sessions for the current canonical folder and the tty/skipped-id preflight, as reusable sessions-package helpers (CLI/TUI wiring stays with later orchestration).

Per-session advisory lock taken on --resume before the updated_at refresh.

Resume-time updated_at refresh that keeps a resumed session inside the retention window for the life of the run.

--resume <id> resolver: expected-path load, sibling scan, and the exact resume error contracts.

Package sessions defines the on-disk session JSONL schema, its marshal/unmarshal codec, and the per-record spine validation applied at load time.

Persist an optional metadata.title on a session file under the sidecar lock.

In-memory session record stream plus JSONL append and metadata rewrite flushing.

Cross-record tool-call correlation, sibling-field placement, and launch_began source validation over a parsed record stream.

Reader-side schema-version classification and the future-version open refusal.

Index

Constants

View Source
const (
	TitleSourceUser      = "user"
	TitleSourceGenerated = "generated"
)
View Source
const CurrentSchemaVersion = 1

CurrentSchemaVersion is the schema_version stamped into freshly minted session metadata. It is the writer-side source of truth; the reader-side support is asserted against it via ReaderSupportedVersion >= CurrentSchemaVersion. JSONL is the pre-ship v1 baseline, so no legacy whole-object version is kept.

View Source
const ReaderSupportedVersion = 1

ReaderSupportedVersion is the maximum session schema_version this build can open. It is the reader-side contract, kept distinct from the writer-side CurrentSchemaVersion in schema.go because the two may legitimately diverge during a schema transition (a reader that supports v1 and v2 while the writer still emits v1). The invariant ReaderSupportedVersion >= CurrentSchemaVersion must always hold so a writer never emits a version its own reader cannot open; version_test.go asserts it.

Variables

This section is empty.

Functions

func AcquireResumeLock

func AcquireResumeLock(sessionPath, id string) (release func(), err error)

AcquireResumeLock takes the exclusive per-session advisory lock required on resume before the updated_at refresh. It locks the session file's dedicated sidecar — ResumeLockPath(sessionPath) — for up to two seconds via the same kernel mechanism as; id names the session for the failure line only. On success it returns a non-nil release and a nil error: the caller holds release for the lifetime of the run and calls it on exit (the kernel also releases the lock if the process dies, so no per-turn re-lock is needed). A contended lock returns a *ResumeLockError with ReasonLockTimeout; any other lock-syscall failure returns one with ReasonLockAcquireError and the observed error category. Per-turn session writes (Store.Flush) never re-acquire this lock, so lock_timeout / lock_acquire_error cannot surface on a session_write_failed event.

func CanonicalizeFolder

func CanonicalizeFolder(path string) (string, error)

CanonicalizeFolder resolves path to the canonical absolute folder path used by metadata.folder, <folder-dir> hashing, and resume CWD checks. It makes the path absolute, resolves symlinks, and removes trailing slashes and "."/".." segments. The path must exist, since symlink resolution reads it.

func DeleteSession

func DeleteSession(path string) (fserrors.Category, error)

DeleteSession removes the session file at path and its dedicated sidecar lock behind the same non-blocking sidecar lock the retention pruner uses, so an explicit delete and a concurrent prune or resume cannot drift on lock or sidecar handling. It is the persistence behind the /resume picker's delete action.

It returns the error category and a non-nil error on failure, or ("", nil) on success. A lock already held by another codemeld instance (the session is being resumed elsewhere) is refused with fserrors.InUse and the file is left intact; any other lock-acquire failure returns its categorized error; a session-file unlink failure returns the unlink's category.

func EnsureFolderDir

func EnsureFolderDir(home, canonicalFolder string) (subdirPath string, err error)

EnsureFolderDir creates ~/.codemeld/sessions and the per-folder subdirectory for canonicalFolder, then ensures the subdirectory's folder file records that canonical path. It returns the per-folder subdirectory path. The call is idempotent: when the folder file already records canonicalFolder it is left untouched. A folder file recording a different folder yields a *CollisionError; filesystem failures yield a *FolderDirError carrying the operation token, path, and category for the runner to render. It never prints or exits.

func FolderDirName

func FolderDirName(canonicalFolder string) string

FolderDirName returns the per-folder directory name <hash>-<basename> for a canonical folder path. <hash> is the first 16 bytes of the SHA-256 of the path, lowercase hex (32 chars); <basename> is the final path component with bytes outside [A-Za-z0-9_.-] replaced by "_" and capped at 32 characters, or "root" for the filesystem root.

func ListResumable

func ListResumable(home, cwd string) ([]ListEntry, []SkippedID, error)

ListResumable lists the resumable sessions in the per-folder subdirectory for the current canonical folder. It returns the survivors, the ids it skipped, and a typed enumeration error — it prints nothing and leaves the tty gate, skipped-id output, and listing to the caller (RunResumeListPreflight and later CLI/TUI orchestration).

The subdirectory is SessionRoot(home)/<folder-dir> for CanonicalizeFolder(cwd); it is never created here, because a missing subdirectory must read as a clean empty listing. An absent subdirectory yields an empty result and a nil error; any other readdir failure yields a *ResumeListEnumerateError.

Each non-directory <id>.json file is read in bounded slices instead of loaded whole: its metadata line, a forward budget ending at the first user message, and a tail window. Record-stream and event-payload validation deliberately do not run here — a session that fails them is listed and rejected at selection by ResolveResumeID, which can report the error against a named session. A candidate joins the skipped bucket, without surfacing its reason, only when its metadata line cannot be read or parsed, is refused on schema_version grounds, or names a folder the folder file does not match. Survivors are sorted by the later of metadata.updated_at and the newest record timestamp in the tail window, descending, ties broken by id (file stem) descending.

func ListResumableAll

func ListResumableAll(home string) ([]ListEntry, []ListAllSkip, error)

func Marshal

func Marshal(s *Session) ([]byte, error)

Marshal serializes a Session to its canonical JSONL byte form: the first line is a tagged metadata record, followed by one message/event record per line. Lines are compact JSON with stable field order and canonical UTC millisecond timestamps.

func OpenSession

func OpenSession(path string) (*Session, VersionStatus, error)

OpenSession reads the JSONL session file at path, pre-scans metadata.schema_version from the first line before any full parse, and classifies it. It refuses a newer or unparseable version with a *VersionError, without parsing records — a future breaking schema may carry fields the current record parser cannot understand, so refusal must not depend on a successful full Unmarshal. For an equal or older-fork-eligible version it delegates to Unmarshal and returns the parsed session with its status, so callers load equal files normally and present the read-only + fork UI for older files.

A read failure or a genuine JSONL parse failure is returned as an ordinary error with an empty status, distinct from a version refusal; callers surface those via the "cannot be parsed" path.

func ResumeLockPath

func ResumeLockPath(sessionPath string) string

ResumeLockPath returns the dedicated sidecar lock-file path (<session-file>.lock) for sessionPath. The --resume advisory lock and the retention pruner's non-blocking pre-unlink lock MUST both derive their target through this one helper: the lock cannot bind to the session file itself because per-turn writes and the updated_at refresh replace that inode via atomic temp+rename, which would orphan the lock and break cross-instance mutual exclusion.

func RunResumeListPreflight

func RunResumeListPreflight(stdin io.Reader, stdinFd int, stderr io.Writer, stderrFd int, skipped []SkippedID, isTerminal func(fd int) bool, waitKey func(stdin io.Reader, stdinFd int)) (exitCode int)

func SessionRoot

func SessionRoot(home string) string

SessionRoot returns the sessions root directory ~/.codemeld/sessions for the given home directory.

func SetTitle

func SetTitle(path, title string) (fserrors.Category, error)

SetTitle sets the optional metadata.title on the session file at path and rewrites the file atomically. It is the persistence behind the /resume picker's rename action an additive metadata field whose non-empty value overrides the auto-derived row preview. It returns the error category and a non-nil error on failure, or ("", nil) on success.

It takes one non-blocking attempt on the session's dedicated sidecar lock — the same target a live resume, an explicit delete, and the retention pruner use (ResumeLockPath) — so a rename can never race a resumed session's whole-file flush, another rename, or a delete/prune unlink. A lock held by another codemeld instance is refused with fserrors.InUse and the file is left unchanged; any other lock-acquire failure returns its categorized error.

func StartBackgroundPrune

func StartBackgroundPrune(home string, retentionDays int, appendSummary func(PruneResult))

StartBackgroundPrune runs Prune in a goroutine and invokes appendSummary with the completed result, so a fresh launch prunes opportunistically without blocking startup (background mode; the only prune path). appendSummary is the caller's seam for resolving the active session at append time and appending session_prune_summary only when a session is active; when no session is active the callback drops the result silently. The callback indirection keeps the event conversion and append in the caller, since internal/sessions must not import internal/events.

func ValidateEventPayloads

func ValidateEventPayloads(records []Record, validate PayloadValidator) error

ValidateEventPayloads runs validate over every Event record's per-kind payload, returning the first failure located by record index as records[<N>].payload: <detail>. It is the load-path composition point for the per-kind event validation beside ValidateRecordStream's spine checks; message records carry no event payload and are skipped. A nil validate is a no-op, so a caller that has not wired a payload validator runs the rest of the load path unchanged.

func ValidateRecordStream

func ValidateRecordStream(records []Record) error

ValidateRecordStream enforces the load-time rules over an already-parsed record slice (as returned by Unmarshal): cross-record tool-call correlation between assistant tool_calls entries and the tool messages that answer them, the sibling-field placement restrictions (tool_call_id only on tool messages, tool_calls only on assistant messages), and the launch_began payload.source enum. It is the validator surface later load/resume orchestration composes with OpenSession and the per-kind event payload validation in internal/events; it does not wire those callers itself.

Errors locate the offending record by its zero-based index in the slice using the form records[<N>] and use the exact wording.

Validation runs in two passes so a malformed tool record receives its own required error rather than being masked by its assistant's completeness check. The first pass validates, in record order, per-record facts: sibling-field placement, each tool message's missing/empty/predecessor/mismatch checks, and launch_began payloads. Only after every tool record has passed its own checks does the second pass validate assistant tool-call type and response-group completeness. Within each pass the first violation in record order is returned.

Types

type CollisionError

type CollisionError struct {
	Folder         string // canonical folder being launched
	RecordedFolder string // canonical folder already recorded in the folder file
	FolderDir      string // the colliding <folder-dir> name
}

CollisionError reports that two distinct canonical folders hash to the same <folder-dir>. It is not a filesystem failure: the recorded folder file is intact and readable, but its contents disagree with the folder being launched.

func (*CollisionError) Error

func (e *CollisionError) Error() string

type Compaction

type Compaction struct {
	ID        string
	Summary   string
	Timestamp time.Time
	Provider  string
	ModelID   string
	ParentID  string
}

func (Compaction) MarshalJSON

func (c Compaction) MarshalJSON() ([]byte, error)

type Content

type Content struct {
	// contains filtered or unexported fields
}

Content is a message's content: either a plain string or a list of content parts. Exactly one form is active; use IsList to discriminate.

func PartsContent

func PartsContent(parts []ContentPart) Content

PartsContent builds list-valued message content from preserved content parts.

func TextContent

func TextContent(s string) Content

TextContent builds string-valued message content.

func (Content) IsList

func (c Content) IsList() bool

IsList reports whether the content is a list of parts rather than a string.

func (Content) Parts

func (c Content) Parts() []ContentPart

Parts returns the content parts (meaningful only when IsList is true).

func (Content) Text

func (c Content) Text() string

Text returns the string content (meaningful only when IsList is false).

type ContentPart

type ContentPart struct {
	Type string
	Raw  json.RawMessage
}

ContentPart is one element of list-valued content. Type is the validated string discriminator; Raw is the whole part object preserved verbatim so unknown provider block types round-trip without interpretation.

type Event

type Event struct {
	ID        string
	Kind      string
	Timestamp time.Time
	Payload   json.RawMessage
	ParentID  string
}

Event is an event record: a kind-tagged entry with an opaque payload object. Per-kind payload shape is validated by the events package, not here.

func (Event) MarshalJSON

func (e Event) MarshalJSON() ([]byte, error)

MarshalJSON emits an event record with the type discriminator and the canonical field order.

type FolderDirError

type FolderDirError struct {
	Op   string
	Path string
	Cat  fserrors.Category
	Err  error
}

FolderDirError categorizes a filesystem failure from EnsureFolderDir so the runner (and tests) can render an OP_FAIL line without parsing message text. Op is the operation token used in that line.

func (*FolderDirError) Error

func (e *FolderDirError) Error() string

func (*FolderDirError) Unwrap

func (e *FolderDirError) Unwrap() error

type ForkOrigin

type ForkOrigin struct {
	SessionID string
	RecordID  string
}

type ListAllSkip

type ListAllSkip struct {
	Dir       string
	ID        string
	Folder    string
	Duplicate bool
}

type ListEntry

type ListEntry struct {
	ID        string
	Path      string
	Folder    string
	Title     string
	Preview   string
	StartedAt time.Time
	UpdatedAt time.Time
}

ListEntry is one resumable session surfaced by ListResumable. ID is the file's name stem (the handle codemeld --resume <id> resolves), which production stamps equal to metadata.id (a UUIDv7); Path is the absolute session-file path; Preview is the untruncated text of the first user message, which display surfaces bound themselves; StartedAt is metadata.started_at and UpdatedAt is the effective update time that drives the sort.

type Message

type Message struct {
	ID         string
	Role       string
	Content    Content
	Timestamp  time.Time
	ToolCalls  []ToolCall // present only on assistant tool-call turns
	ToolCallID string     // present only on tool-result turns
	ParentID   string
	// contains filtered or unexported fields
}

Message is a message record: a role-tagged turn whose content is either a plain string or a list of content-part objects.

func (Message) MarshalJSON

func (m Message) MarshalJSON() ([]byte, error)

MarshalJSON emits a message record with the type discriminator and the canonical field order; absent sibling fields are omitted.

type Metadata

type Metadata struct {
	ID            string
	Folder        string
	StartedAt     time.Time
	UpdatedAt     time.Time
	SchemaVersion int
	Title         string
	TitleSource   string
	ForkedFrom    *ForkOrigin
}

Metadata is the top-level session header. ID and the timestamps use the formats fixed in §Reserved Values; SchemaVersion is parsed as an integer here, with version classification deferred to ClassifyVersion.

type MetadataSnapshot

type MetadataSnapshot struct {
	ID        string
	Folder    string
	StartedAt time.Time
}

MetadataSnapshot is an immutable copy of a session's identifying metadata, taken at one instant. It lets in-memory readers (the /status overlay items 2-3, and the active-session references the production CommandHost / exit controller hold) report the session id, canonical folder, and start timestamp without reaching into the store's mutable Session struct or racing a Flush.

type PayloadValidator

type PayloadValidator func(kind string, payload json.RawMessage) error

PayloadValidator validates a single event record's per-kind payload — the internal/events.ValidatePayload surface. Production wiring that can import internal/events supplies it; internal/sessions takes it as a callback because internal/events already imports internal/sessions, so a direct import here would be a cycle.

type PruneFailure

type PruneFailure struct {
	Path   string
	Reason fserrors.Category
	Kind   PruneFailureKind
}

PruneFailure is one accumulated prune failure. It mirrors events.PruneFailure without importing internal/events (which already imports internal/sessions, so a reverse import would cycle): the caller converts a PruneResult into the session_prune_summary payload. Path is the absolute file or directory path; Reason is the error category for that failure.

type PruneFailureKind

type PruneFailureKind string

PruneFailureKind classifies a prune failure as a per-file unlink failure or a directory-enumeration failure, mirroring the session_prune_summary failure kinds. The strings match events.PruneFailure.Kind exactly so the caller's conversion is a verbatim field copy.

const (
	// FailureFileDelete is a per-candidate unlink failure.
	FailureFileDelete PruneFailureKind = "file_delete"
	// FailureDirEnumerate is a readdir failure on the sessions root or a
	// per-folder subdirectory.
	FailureDirEnumerate PruneFailureKind = "dir_enumerate"
)

type PruneResult

type PruneResult struct {
	FilesScanned int64
	FilesDeleted int64
	FilesFailed  int64
	Failures     []PruneFailure
	ElapsedMs    int64
}

PruneResult is the outcome of a single retention prune pass. It mirrors the fields of events.SessionPruneSummary without importing internal/events, so a caller (the background summary callback or the resume orchestration) can convert it into the event payload. FilesFailed is kept equal to len(Failures), the invariant the event validator enforces.

func Prune

func Prune(home string, retentionDays int) (PruneResult, error)

Prune walks every per-folder subdirectory under SessionRoot(home) and deletes each session file whose metadata.updated_at is past the retention window (retentionDays days). Age is measured against updated_at, not file mtime and not started_at, so a session edited recently survives even if it was started long ago. It never prints and never exits — StartBackgroundPrune runs it in a goroutine on a fresh launch (the only prune path) — so all surfacing (the session_prune_summary event) stays with the caller.

Failure accounting follows -1: a per-file unlink failure accumulates a file_delete entry without aborting the pass; a non-ENOENT readdir failure accumulates a single dir_enumerate entry (ending the pass for the root, skipping just that subdirectory for a per-folder dir); an ENOENT directory is treated as empty with no failure; and a candidate that cannot be read, whose top-level JSON will not parse, or whose updated_at is missing/invalid is counted in FilesScanned only and left in place. The returned error is reserved for a fatal precondition and is nil for every failure mode the pruner defines, because those are accumulated into the result rather than aborting the pass.

type Record

type Record interface {
	// contains filtered or unexported methods
}

type ResumeCWDMismatchError

type ResumeCWDMismatchError struct {
	ID             string
	MetadataFolder string
	Path           string
}

ResumeCWDMismatchError reports the CWD-match failure: the canonical working directory is not byte-equal to the loaded session's metadata.folder. This surfaces a hash-collision resume target (a session that hashes into the current folder's subdirectory but was recorded for a different folder).

func (*ResumeCWDMismatchError) Error

func (e *ResumeCWDMismatchError) Error() string

type ResumeEnumerateError

type ResumeEnumerateError struct {
	Category fserrors.Category
}

ResumeEnumerateError reports: readdir on the sessions root itself failed (a non-absent failure, e.g. permission denied), so the sibling scan cannot run at all.

func (*ResumeEnumerateError) Error

func (e *ResumeEnumerateError) Error() string

type ResumeFolderFileError

type ResumeFolderFileError struct {
	ID       string
	Subdir   string
	Category fserrors.Category
}

ResumeFolderFileError reports: a sibling subdirectory holds <id>.json but its folder file is missing, empty after trimming, or unreadable, so the wrong-folder message cannot be constructed. For the missing and empty-after-trim cases Category is not_found; otherwise it is the syscall's category.

func (*ResumeFolderFileError) Error

func (e *ResumeFolderFileError) Error() string

type ResumeFolderMismatchError

type ResumeFolderMismatchError struct {
	ID             string
	MetadataFolder string
	FolderFile     string
}

ResumeFolderMismatchError reports: the loaded metadata.folder is not byte-equal to the contents of the expected subdirectory's folder file (a single trailing newline stripped). It is a single-line "cannot be loaded" error, distinct from the validation form and from the CWD-match error.

func (*ResumeFolderMismatchError) Error

func (e *ResumeFolderMismatchError) Error() string

type ResumeListEnumerateError

type ResumeListEnumerateError struct {
	Subdir   string
	Category fserrors.Category
}

ResumeListEnumerateError reports: readdir on the current folder's session subdirectory failed for a reason other than absence (an absent subdirectory is a clean empty listing, not an error). Its Error() is the exact stderr line the caller prints before exiting 1; Category is drawn from the vocabulary.

func (*ResumeListEnumerateError) Error

func (e *ResumeListEnumerateError) Error() string

type ResumeLoadError

type ResumeLoadError struct {
	ID     string
	Verb   string // "parsed" or "loaded"
	Detail string
}

ResumeLoadError reports that the expected session file was present but could not be loaded. Verb is "parsed" when the file is not valid JSON or does not match the top-level session schema, or "loaded" when it parsed but failed record-stream validation. Its message ends with the no-id listing hint.

func (*ResumeLoadError) Error

func (e *ResumeLoadError) Error() string

type ResumeLockError

type ResumeLockError struct {
	ID     string
	Reason locks.LockReason
	Cat    fserrors.Category
	Err    error
}

ResumeLockError is the typed failure returned by AcquireResumeLock when the per-session lock cannot be taken. Its Error renders the exact stderr line for the failure reason, which the CLI prints before exiting with code 1. Reason is locks.ReasonLockTimeout or locks.ReasonLockAcquireError; Cat carries the error category for the acquire-error line and is empty for a timeout; Err is the underlying lock error, exposed via Unwrap.

func (*ResumeLockError) Error

func (e *ResumeLockError) Error() string

func (*ResumeLockError) Unwrap

func (e *ResumeLockError) Unwrap() error

type ResumeNotFoundError

type ResumeNotFoundError struct {
	ID         string
	Skipped    int
	Categories []fserrors.Category
}

ResumeNotFoundError reports a miss: the id was not found in the expected subdirectory or any sibling. When Skipped > 0 (per-subdirectory readdir failures), its message prepends a single warning line naming the count and the distinct error categories encountered, before the miss line.

func (*ResumeNotFoundError) Error

func (e *ResumeNotFoundError) Error() string

type ResumeVersionError

type ResumeVersionError struct {
	Err *VersionError
}

ResumeVersionError wraps a *VersionError from OpenSession (a newer-than-supported or unparseable schema_version) so a resume probe of a version-refused file exits with the VersionError's clear version message plus the no-id listing hint. The underlying *VersionError remains reachable via errors.As.

func (*ResumeVersionError) Error

func (e *ResumeVersionError) Error() string

func (*ResumeVersionError) Unwrap

func (e *ResumeVersionError) Unwrap() error

type ResumeWrongFolderError

type ResumeWrongFolderError struct {
	ID     string
	Folder string
	Path   string
}

ResumeWrongFolderError reports a hit in a sibling subdirectory: the id exists, but under a different folder. Folder is read from that subdirectory's folder file.

func (*ResumeWrongFolderError) Error

func (e *ResumeWrongFolderError) Error() string

type Session

type Session struct {
	Metadata Metadata
	Records  []Record
}

func ResolveResumeID

func ResolveResumeID(id, cwd, home string, validatePayload PayloadValidator) (sessionPath string, sess *Session, err error)

ResolveResumeID resolves a --resume <id> request to its session file. It probes the expected path ~/.codemeld/sessions/<folder-dir>/<id>.json for the current canonical folder; on a present file it loads (OpenSession), validates the record stream (ValidateRecordStream), checks metadata.folder against the folder file, then checks the canonical CWD against metadata.folder. On an absent expected file it scans sibling subdirectories for <id>.json (enumeration handling), returning a wrong-folder error on a hit or a miss error otherwise.

On success it returns the resolved session file path and the parsed session. Every failure is returned as a typed error from this package whose Error() is the exact stderr text the caller prints before exiting with status 1; the resolver itself writes nothing and acquires no lock.

validatePayload is the per-kind event-payload validator (events.ValidatePayload, supplied by production wiring that can import internal/events); it runs after ValidateRecordStream so a schema-invalid payload is reported in the same "loaded" bucket. A nil validatePayload skips that check (see ValidateEventPayloads).

func Unmarshal

func Unmarshal(data []byte) (*Session, error)

Unmarshal parses and validates a session JSONL stream, returning the in-memory Session. Validation covers the spine (required metadata and record fields, message role enum, event payload object, UUIDv7 ids) and the per-record content-part and tool-call entry shapes (each entry is a JSON object with string id/type). Cross-record correlation including the tool-call type-must-be-"function" rule, per-kind event payloads, and schema-version classification are intentionally out of scope here. Returned errors locate the offending field; callers add file context.

type SkippedID

type SkippedID struct {
	ID string
}

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store owns the active session as the in-memory source of truth between flushes and writes it to a fixed path. Append and Flush are serialized by a single mutex; reads via Records take the same lock and return a copy, so a reader never races an in-progress Append or Flush. Session files are unlocked across processes; the mutex guards only intra-process access.

func EnsureChildSession

func EnsureChildSession(home string, parent MetadataSnapshot, title string, origin ForkOrigin, records []Record) (*Store, error)

func EnsureSession

func EnsureSession(home, canonicalFolder string, startedAt time.Time, initialRecords []Record) (*Store, error)

func New

func New(path string, session *Session) *Store

New returns a Store that flushes session to path. The Store takes ownership of session and mutates it in place (appending records, refreshing updated_at). When path already names a regular file, New records the recovered durable byte boundary (the end of its last complete line) so the first append repairs any torn tail rather than writing after it; if the file cannot be read, it falls back to physical size so behavior matches a clean file. A file with no complete line, such as a bare unterminated metadata line, is rewritten whole before any append, since appending at offset 0 would erase that metadata.

func NewMemoryOnly

func NewMemoryOnly() *Store

NewMemoryOnly returns a Store for a fresh session that is never written to disk: its flushes succeed while every record stays in memory. New("", session) is deliberately not this — its flushes fail on a real write, and the resume and session-failure paths depend on those failure reasons.

func (*Store) Append

func (s *Store) Append(rec Record)

Append adds a record to the in-memory stream. It does not flush; callers trigger durability at the points via Flush. Once the store is Closed the append is silently dropped, so a stream that has been forked away from (or otherwise switched out of) never gains further records.

func (*Store) Close

func (s *Store) Close()

Close marks the store closed so no later Append mutates its record stream. The fork success path closes the original session once its final session_forked event is appended, and the in-process switch callers close the outgoing store after switching away. Close is idempotent and takes the same mutex as Append/Flush/Records. It does not flush and does not block Flush: a caller must durably persist any final record itself, and a post-Close Flush still writes the current in-memory stream — closing only refuses further Appends.

func (*Store) CommitFinalRecords

func (s *Store) CommitFinalRecords(ctx context.Context, recs ...Record) (cat fserrors.Category, reason WriteReason, err error)

func (*Store) CommitRecords

func (s *Store) CommitRecords(ctx context.Context, recs ...Record) (cat fserrors.Category, reason WriteReason, err error)

CommitRecords appends recs and flushes them without releasing the store mutex in between, so no concurrent Append can land inside the batch and no concurrent Close can drop part of it while the flush still reports success. A closed store is an error rather than a silent drop, and a failed flush rolls the whole batch back.

func (*Store) Flush

func (s *Store) Flush(ctx context.Context) (cat fserrors.Category, reason WriteReason, err error)

Flush persists new session records. The first write creates the JSONL file via the atomic temp+fsync+rename path; later writes append only the records not yet durably recorded and fsync before advancing the durable boundary. Normal record flushes do not rewrite metadata.updated_at; readers compute effective recency from metadata plus the last complete record timestamp.

func (*Store) FlushGeneratedTitle

func (s *Store) FlushGeneratedTitle(ctx context.Context, title string) (cat fserrors.Category, reason WriteReason, err error)

FlushGeneratedTitle persists an auto-generated title over an empty or generated title, never over a user or legacy one; an empty candidate is a no-op. Like FlushTitle it does not advance updated_at, and a write failure restores the previous title and source in memory. It does not tell a child session's derived title from any other generated title, so automatic callers must not call it for a child session.

func (*Store) FlushTitle

func (s *Store) FlushTitle(ctx context.Context, title string) (cat fserrors.Category, reason WriteReason, err error)

FlushTitle sets the session's metadata.title in memory and writes the whole session atomically, but — unlike Flush — deliberately does NOT advance updated_at: a rename is metadata housekeeping, not session activity, matching SetTitle's contract so the retention clock and most-recent-first picker order are preserved. It is the active-session counterpart to the package-level SetTitle: the live store already owns the file (and, for a resumed session, already holds the sidecar lock), so routing the rename through the owning store avoids the self-contention a second SetTitle lock attempt would hit. Return shape matches Flush — ("", ReasonNone, nil) on success, otherwise the category, the mapped WriteReason, and the underlying error with the in-memory title restored to its pre-call value. If ctx is already cancelled, no write is attempted.

func (*Store) IsChild

func (s *Store) IsChild() bool

func (*Store) MetadataSnapshot

func (s *Store) MetadataSnapshot() MetadataSnapshot

MetadataSnapshot returns a copy of the session's identifying metadata under the same mutex that guards Append/Flush, so callers never observe a half-updated metadata struct. The values are fixed at construction (Flush only advances updated_at, which this snapshot does not expose), so the snapshot stays valid for the session's lifetime.

func (*Store) Path

func (s *Store) Path() string

Path returns the absolute path of the session file this store flushes to. It is fixed at construction and never mutated, so it needs no lock; cross-package failure handling uses it to name the target file in the session_write_failed event and the clean-shutdown OP_FAIL line without reaching into the store's mutable state.

func (*Store) Records

func (s *Store) Records() []Record

Records returns a snapshot copy of the in-memory record stream, safe to read while other goroutines Append or Flush.

func (*Store) RefreshUpdatedAt

func (s *Store) RefreshUpdatedAt(ctx context.Context) (cat fserrors.Category, reason WriteReason, err error)

RefreshUpdatedAt rewrites the metadata line with the current wall-clock time. It is used for explicit recency refreshes such as resume, not for normal record appends.

func (*Store) SessionID

func (s *Store) SessionID() string

SessionID returns the session's UUIDv7 id from the in-memory metadata. It lets cross-package orchestration (the fork action) populate event payloads such as session_forked.to_session_id from the live store rather than parsing the id out of Path()'s file-name convention. The id is fixed at construction; the lock is taken only to share the mutex that guards the metadata struct against a concurrent Flush.

func (*Store) Title

func (s *Store) Title() string

Title returns the session's current metadata title from memory. The value can change when the live session is renamed or generated-title persistence lands, so callers take the same lock that guards metadata writes.

func (*Store) TitleSource

func (s *Store) TitleSource() string

type ToolCall

type ToolCall struct {
	ID   string
	Type string
	Raw  json.RawMessage
}

ToolCall is one entry of an assistant message's tool_calls list. ID is the entry's id, captured so the correlation validator can match it against following tool messages' tool_call_id; Type is the parsed type discriminator, whose must-be-"function" rule is enforced by ValidateRecordStream rather than at parse time; Raw is the whole entry object preserved verbatim.

type VersionError

type VersionError struct {
	Path      string        // the refused session file
	Status    VersionStatus // StatusNewer or StatusUnparseable
	Found     *big.Int      // the file's schema_version; meaningful only when Status is StatusNewer. Arbitrary magnitude so a future version beyond int64 range is still named exactly.
	Supported int           // the reader's supported version (ReaderSupportedVersion)
}

VersionError is the typed refusal returned by OpenSession when a session file cannot be opened on version grounds: a future (newer) version or an unparseable schema_version. It supports errors.As so callers can distinguish a version refusal from an ordinary I/O or JSON parse failure. Its message names the file and the version mismatch.

func (*VersionError) Error

func (e *VersionError) Error() string

type VersionStatus

type VersionStatus string

VersionStatus is the outcome of classifying a session file's schema_version.

const (
	// StatusUnparseable is a schema_version that is missing, not a JSON integer,
	// or less than 1. Such a file is treated as unparseable.
	StatusUnparseable VersionStatus = "unparseable"
	// StatusOlderForkEligible is a parseable integer version >= 1 and less than
	// ReaderSupportedVersion. Phase 1 has no plain "older" status: every readable
	// older file gets the read-only display plus one-shot fork affordance.
	StatusOlderForkEligible VersionStatus = "older-fork-eligible"
	// StatusEqual is a version equal to ReaderSupportedVersion; the file loads
	// normally.
	StatusEqual VersionStatus = "equal"
	// StatusNewer is a version greater than ReaderSupportedVersion; the reader
	// refuses to open the file.
	StatusNewer VersionStatus = "newer"
)

func ClassifyVersion

func ClassifyVersion(v any) (VersionStatus, error)

ClassifyVersion maps a recovered metadata.schema_version value to a VersionStatus. The argument is the value as recovered from the file: a raw JSON token (json.RawMessage/[]byte, decoded here with integer precision preserved so no float64 rounding occurs), an already-decoded json.Number, a Go integer, or nil for an absent field. Missing, non-integer, and < 1 values all classify as StatusUnparseable; a value equal to, less than, or greater than ReaderSupportedVersion classifies as StatusEqual, StatusOlderForkEligible, or StatusNewer respectively. It returns a non-nil error only when a raw JSON token argument is itself malformed.

type WriteReason

type WriteReason string

WriteReason classifies why an atomic session flush failed, in the vocabulary shares with. ReasonNone covers both a successful flush and a flush that was never attempted (e.g. a cancelled context); the write-failure protocol builds a session_write_failed event only from the three failure reasons.

const (
	ReasonNone           WriteReason = "none"
	ReasonWriteError     WriteReason = "write_error"
	ReasonRenameError    WriteReason = "rename_error"
	ReasonParentDirError WriteReason = "parent_dir_error"
)

func RefreshUpdatedAt

func RefreshUpdatedAt(ctx context.Context, store *Store) (cat fserrors.Category, reason WriteReason, err error)

RefreshUpdatedAt performs the load-time updated_at rewrite for a resumed session. It delegates to Store.RefreshUpdatedAt, which sets metadata.updated_at to the current wall-clock time and rewrites the JSONL file atomically, so the just-loaded session sorts inside the retention window and cannot be pruned out from under the run.

The resume orchestrator calls this once after the per-session sidecar lock is held and before the retention pruner starts. ctx is the run context; a cancellation short-circuits the refresh exactly as Store.RefreshUpdatedAt documents.

It returns Store.RefreshUpdatedAt's result verbatim — ("", ReasonNone, nil) on a clean refresh, or the OS error category, the mapped WriteReason, and the underlying error on an atomic-write failure. On failure the refreshed timestamp stays in the in-memory session, so the run continues and the next successful turn flush re-creates the file with the advanced updated_at (step 2). Routing a non-ReasonNone result through sessionfailures.HandleWriteFailure (the session_write_failed event plus the TUI notice) is the resume orchestrator's job: internal/sessions deliberately does not import internal/sessionfailures, which already imports internal/sessions.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL