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
- func AcquireResumeLock(sessionPath, id string) (release func(), err error)
- func CanonicalizeFolder(path string) (string, error)
- func DeleteSession(path string) (fserrors.Category, error)
- func EnsureFolderDir(home, canonicalFolder string) (subdirPath string, err error)
- func FolderDirName(canonicalFolder string) string
- func ListResumable(home, cwd string) ([]ListEntry, []SkippedID, error)
- func ListResumableAll(home string) ([]ListEntry, []ListAllSkip, error)
- func Marshal(s *Session) ([]byte, error)
- func OpenSession(path string) (*Session, VersionStatus, error)
- func ResumeLockPath(sessionPath string) string
- func RunResumeListPreflight(stdin io.Reader, stdinFd int, stderr io.Writer, stderrFd int, ...) (exitCode int)
- func SessionRoot(home string) string
- func SetTitle(path, title string) (fserrors.Category, error)
- func StartBackgroundPrune(home string, retentionDays int, appendSummary func(PruneResult))
- func ValidateEventPayloads(records []Record, validate PayloadValidator) error
- func ValidateRecordStream(records []Record) error
- type CollisionError
- type Compaction
- type Content
- type ContentPart
- type Event
- type FolderDirError
- type ForkOrigin
- type ListAllSkip
- type ListEntry
- type Message
- type Metadata
- type MetadataSnapshot
- type PayloadValidator
- type PruneFailure
- type PruneFailureKind
- type PruneResult
- type Record
- type ResumeCWDMismatchError
- type ResumeEnumerateError
- type ResumeFolderFileError
- type ResumeFolderMismatchError
- type ResumeListEnumerateError
- type ResumeLoadError
- type ResumeLockError
- type ResumeNotFoundError
- type ResumeVersionError
- type ResumeWrongFolderError
- type Session
- type SkippedID
- type Store
- func EnsureChildSession(home string, parent MetadataSnapshot, title string, origin ForkOrigin, ...) (*Store, error)
- func EnsureSession(home, canonicalFolder string, startedAt time.Time, initialRecords []Record) (*Store, error)
- func New(path string, session *Session) *Store
- func NewMemoryOnly() *Store
- func (s *Store) Append(rec Record)
- func (s *Store) Close()
- func (s *Store) CommitFinalRecords(ctx context.Context, recs ...Record) (cat fserrors.Category, reason WriteReason, err error)
- func (s *Store) CommitRecords(ctx context.Context, recs ...Record) (cat fserrors.Category, reason WriteReason, err error)
- func (s *Store) Flush(ctx context.Context) (cat fserrors.Category, reason WriteReason, err error)
- func (s *Store) FlushGeneratedTitle(ctx context.Context, title string) (cat fserrors.Category, reason WriteReason, err error)
- func (s *Store) FlushTitle(ctx context.Context, title string) (cat fserrors.Category, reason WriteReason, err error)
- func (s *Store) IsChild() bool
- func (s *Store) MetadataSnapshot() MetadataSnapshot
- func (s *Store) Path() string
- func (s *Store) Records() []Record
- func (s *Store) RefreshUpdatedAt(ctx context.Context) (cat fserrors.Category, reason WriteReason, err error)
- func (s *Store) SessionID() string
- func (s *Store) Title() string
- func (s *Store) TitleSource() string
- type ToolCall
- type VersionError
- type VersionStatus
- type WriteReason
Constants ¶
const ( TitleSourceUser = "user" TitleSourceGenerated = "generated" )
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.
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 SessionRoot ¶
SessionRoot returns the sessions root directory ~/.codemeld/sessions for the given home directory.
func SetTitle ¶
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 ¶
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 ¶
TextContent builds string-valued message content.
func (Content) Parts ¶
func (c Content) Parts() []ContentPart
Parts returns the content parts (meaningful only when IsList is true).
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 ¶
MarshalJSON emits an event record with the type discriminator and the canonical field order.
type FolderDirError ¶
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 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 ¶
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 ¶
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 ResumeCWDMismatchError ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 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 New ¶
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 ¶
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 (*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 ¶
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) 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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.