mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
* feat(acp): add standard session list and resume
Origin-Session: local-abff1c | Claude Code | 7 prompts
Origin-Snapshot: b7d0806d49f9
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
* fix(acp): preserve notification wire order
Origin-Session: local-abff1c | Claude Code | 7 prompts
Origin-Snapshot: b7d0806d49f9
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
* fix(acp): bind sessions to persisted workspaces
Origin-Session: local-abff1c | Claude Code | 7 prompts
Origin-Snapshot: b7d0806d49f9
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
* fix(acp): compare workspaces by filesystem identity, not by spelling
@Vasanthdev2004's three findings, all reproduced before changing anything.
TWO SPELLINGS OF ONE DIRECTORY WERE TWO WORKSPACES. Both comparisons were string
equality on ResolveWorkspaceRoot output, and that resolver is abs plus
filepath.Clean plus a stat — it does not fold case and does not resolve
junctions. A session persisted from the TUI was unresumable from an editor
holding a different spelling of the same project folder, and session/list
filtered by the other spelling returned nothing, which makes it an invisible
failure rather than a reported one. It lands on exactly the case this feature
exists for, and on the two processes most likely to disagree about spelling.
os.SameFile asks the filesystem which directories these are, which is the
question. filepath.EvalSymlinks is NOT the fix on Windows — it normalises a
drive letter and returns a junction path unchanged, so the alias survives it,
and junctions need no privilege. String equality stays as the fast path, and a
stat failure falls back to it rather than widening the match: this gate refuses
access to another workspace's files and configuration, so an unanswerable
comparison denies.
THE TEST COULD NOT SEE ANY OF IT. testDeps resolves with the identity function,
so the guard degenerated to "are these two strings different" fed two unrelated
temp directories — it could only ever answer yes. The rejection direction was
pinned and the acceptance direction was asserted nowhere. The new test uses a
resolver reproducing the production normalisation and drives the ACCEPTANCE
direction through an alias, skipping if the filesystem folds the alias away so
it never passes vacuously. Reverting to string equality fails it three ways:
load, resume, and a list that returns zero.
THE WIRE KEYS ARE AN EXTERNAL CONTRACT. Nothing pinned sessionId, cwd, title,
updatedAt, _meta, modelId, createdAt, sessions, nextCursor, cursor, loadSession,
promptCapabilities, sessionCapabilities, list or resume, so renaming a Go field
would break every client and leave the suite green. Renaming modelId to model_id
now fails.
Origin-Session: local-79d7a0 | Claude Code | 5 prompts
Origin-Snapshot: c175cabb9d50
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
* fix(acp): test the alias guard where the bug lives, and stop listing unusable sessions
THE TEST COULD NOT RUN ON THE PLATFORM THE BUG IS FROM. @Vasanthdev2004's point,
and he is right that it is not a nit. The alias test built its second name with
os.Symlink, which needs a privilege an ordinary Windows session does not hold, so
it SKIPPED there — and Windows is where junctions exist and where this defect came
from. The identity guard was verified by CI on the two platforms that never had
the problem. It now builds the alias with mklink /J on Windows, which needs no
privilege and is how he found the defect in the first place, and keeps the symlink
arm elsewhere.
This is the second time in this series a correct fix shipped with a test that
could not exercise it: the same helper shape was added to internal/memory for the
same reason a day earlier.
A SESSION WITH NO PERSISTED WORKSPACE IS NOT RESUMABLE, SO IT IS NOT LISTED.
CodeRabbit's finding. activatePersistedSession refuses an empty Cwd, but the
listing advertised it anyway — a menu entry that only fails when taken. The test
asserts both halves, because the listing is only correct relative to what resume
will accept: the omitted session is checked to really fail on resume, and a usable
session is checked to survive the filter.
Mutation-checked: removing the filter advertises the unusable session again.
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
* fix(acp): resolve every listed workspace, not only when a filter was supplied
@anandh8x's P1, both halves reproduced before changing anything.
The loop resolved item.Cwd only when a cwd filter was present, and skipped only a
blank one. Two shapes stayed on the menu that session/resume then refuses:
- a session whose persisted workspace has since been deleted, advertised as
resumable
- a legacy entry holding a relative path, reported as cwd "." although ACP
requires SessionInfo.cwd to be absolute
Every entry is now resolved unconditionally, anything that cannot resolve is
omitted, and the RESOLVED root is what SessionInfo carries — absolute as the
contract requires, and the same value the client hands back on resume. The
optional identity filter then applies to the resolved roots, which is also where
it belonged.
Listing is a menu: activatePersistedSession resolves and refuses what it cannot
reach, so anything this loop cannot resolve is something a client would be
offered and then denied.
Mutation-checked: restoring the resolve-only-when-filtered shape re-advertises
the deleted workspace and re-emits the relative cwd.
Origin-Session: local-76c8d7 | Claude Code | 6 prompts
Origin-Snapshot: 259b715cf0fd
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
* test(acp): assert the relative entry is retained, not merely absolute
CodeRabbit's catch, and the test was genuinely weaker than it looked. It checked
that the deleted workspace was gone, the live one kept, and every listed cwd
absolute — all of which a "fix" that simply DISCARDED any non-absolute entry
would satisfy, while losing a resumable session.
Presence is now asserted separately from spelling: the relative entry must still
be listed, and listed with an absolute path.
Mutation-checked: skipping non-absolute entries instead of resolving them now
fails with "a session with a resolvable relative workspace was dropped rather
than normalised". The first attempt at that mutation did not compile, so it
proved nothing until it was rewritten — worth saying, because a mutation that
fails to build looks exactly like a test that passes.
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
* fix(acp): a relative persisted workspace is omitted, not rebased
Reported by @anandh8x. Resolving a stored relative cwd does not recover the
session's workspace, it invents one: ResolveWorkspaceRoot joins it against
whatever directory the ACP server happens to be running in, and that invented
absolute path was then advertised as the session's workspace and accepted as
its home on resume.
Reproduced on f2c6fc9a, a session persisted with cwd ".":
LISTED legacy-rel as cwd="/Users/kratos/dev/f914/internal/acp"
resume with an UNRELATED workspace -> err=... cwd does not match its persisted workspace
resume with NO cwd (falls back to ".") -> err=<nil>
The mismatch check does its job when the client names a workspace, so the only
opening was the fallback path, where the rebased value was compared against
itself and always agreed. A conversation created for one project could be
resumed against another project's files, configuration and tools.
Both doors now take the same guard: handleSessionList omits an entry whose
persisted cwd is not absolute, and activatePersistedSession refuses one rather
than resolving it. The original base is not knowable from the metadata, so
guessing at it is not an option a fix can take.
This reverses an earlier assertion in TestSessionListResolvesEveryWorkspace,
which expected the relative entry to be normalised and retained. That was
requested in review on the grounds that dropping it loses a resumable session.
It does, but the entry was never resumable into its own workspace, only into
this process's. The test now asserts it is dropped, and a new
TestResumeRefusesARelativePersistedWorkspace covers the fallback path that the
listing filter alone leaves open.
Both guards mutation-checked: removing either one fails its test.
Pre-existing on this branch and on its merge-base, unrelated to this change:
TestRunDoctorFormatsRedactedProviderDiagnostics and
TestRunDoctorConnectivityProbesProvider both exit 3 in this environment.
Origin-Session: local-8cd239 | Claude Code | 11 prompts
Origin-Snapshot: 365efe3045f2
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
* test(acp): the resume guard is asserted through both activating methods
Raised by CodeRabbit: the test is named for resume and called session/load.
session/load and session/resume are separate entry points that today share
activatePersistedSession, so an assertion through either one passes while the
guard holds — but the name promised a surface it was not touching. Both are now
named explicitly, which keeps that true: if resume is ever given its own path,
this fails rather than quietly covering half of what it claims to.
With the guard removed, both methods accept a relative persisted workspace on
the no-cwd fallback. The named-workspace case was already refused by the
existing mismatch check; the fallback was the only door open, and it is open on
both.
Not taken in this PR, from the same review: threading a rooted directory handle
through ResolveWorkspaceRoot and workspace construction so a root rename or link
swap cannot redirect later file operations. That is a real question and a
pre-existing one — this change adds a refusal and no path handling — but it is a
capability refactor across workspace and tool access with its own race test, not
something to fold into a session-list fix. Worth its own issue.
Origin-Session: local-8cd239 | Claude Code | 11 prompts
Origin-Snapshot: 365efe3045f2
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
* fix(acp): activation requires an absolute cwd in the REQUEST, not just the record
Reported by @jatmn.
ResumeSessionParams is a type alias for LoadSessionParams, so JSON decoding turns
an OMITTED resume cwd into an empty string. That blank reached the shared
activation path, whose blank-cwd fallback substitutes meta.Cwd — so
{"sessionId":"known"} activated a persisted session, even though ACP v1 requires
session/resume to carry an absolute working directory.
This is a different hole from the persisted-cwd one fixed earlier on this branch.
That guard asks whether the STORED workspace is identifiable; this asks whether
the CALLER named one at all. The earlier fix does not cover it, because a stored
absolute cwd passes that check and the blank request then silently inherits it.
requestedWorkspace validates the request's own cwd before anything reaches the
fallback: absent, empty and whitespace-only are all invalid params, and so is a
relative path. Applied to both activating methods rather than to resume alone —
load's omitted-cwd fallback was inheriting the same way, and leaving one door
open is how this class survived the last fix.
Mutation-checked at the wire, which is where the defect lives: making the blank
case return no error compiles and fails the regression on four separate requests
— session/load and session/resume, each with cwd omitted and with cwd blank. A
Go-level test would not have caught it, since the defect is in decoding an absent
field.
Two notes from a verification pass, neither a defect:
Error precedence changed: a blank cwd with an UNKNOWN session id now reports the
cwd problem instead of "session not found". Kept deliberately and pinned — it
stops the server confirming whether a session exists to a request that named no
workspace.
AdditionalDirectories is declared on two params structs and consumed nowhere in
the repo. Not a hole today, but it is the same shape — client-supplied paths with
no absoluteness rule — so the field now carries a note saying it must go through
requestedWorkspace when wired up.
Rebased onto ad34dc8d. go test -race ./internal/acp/ -count=5: clean.
Pre-existing here and on main: TestRunDoctorFormatsRedactedProviderDiagnostics
and TestRunDoctorConnectivityProbesProvider exit 3 in this environment.
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
* fix(acp): resume the conversation a session became, and fail closed when it cannot
Three defects in ACP session restoration, all reported by @jatmn.
loadHistory read the raw event log and kept only EventMessage. A compacted
session stores its original prefix alongside an EventCompaction naming the
events it replaced and carrying their summary, so restoring from the raw log
replayed superseded turns AND dropped the summary that replaced them. It now
reads the same rehydrated view the TUI and exec paths use, and explicitly
projects the compaction summary -- switching readers alone would still drop it,
because rehydration substitutes the compaction event in place of what it
replaced.
historyErr only suppressed replay and raised a warning: the session was
registered and reported ready regardless, so an unreadable events file left the
caller holding a live, promptable session ID whose next prompt ran as a fresh
conversation under the old identity. Resume now fails. Load keeps the
best-effort policy deliberately rather than by inheriting the shared helper.
Tool calls and their results were dropped from session/load, so a restored
transcript showed prose asserting edits with no record that any tool ran. They
now replay through the same toolCallStart/toolCallResult mapping a live turn
uses, keyed on the stored toolCallId so results pair with their calls. They do
not enter turnRecord, so load and resume still consume the same effective
history. Resume stays replay-free.
Also asserts that an unset sessionCapabilities is omitted rather than
serialized as null, raised by CodeRabbit.
* fix(acp): persist tool activity for session replay
* test(acp): bound replay notification waits
* fix(acp): preserve session restore invariants
* fix(acp): restore session lifecycle boundaries
* fix(acp): preserve replay and restore identity
* fix(acp): commit a completed turn as one store batch
A completed turn was buffered until its outcome was known and then persisted
one event per call. Each call took and released the store's session lock, so a
second ACP instance holding the same session -- its turnMu is its own, only the
store is shared -- could append its whole turn between two of them: user A,
user B, answer B, answer A. Every write was individually locked and the log
was valid, but a fresh load pairs prose by order and reconstructed three wrong
turns from two right ones. Before this branch the user and assistant events
went to AppendEvents together; buffering until the outcome is known was the
right change and splitting the commit into singleton writes was not.
The whole buffer now goes through one Store.AppendEvents call, which writes the
batch under one lock and one file write. That is the contiguity being restored:
not a disk transaction, and not a promise about a crash mid-fsync. The outcome
gate still runs first; a hard failure still commits nothing; cancellation still
commits through the same boundary. A persistence failure is now the failure of
the whole batch, so there is no dependent suffix to protect and the rewritten
regression asserts that nothing of the turn is durable, the warning is raised,
and a fresh load replays none of it.
The two-instance regression holds both writers at the commit boundary until
each has arrived and then delegates to the real batch API under the real lock;
it observes what production hands it rather than batching anything itself, so
reverting to singleton writes fails it before any interleaving has to be
provoked. The restored prompt keeps both prose pairs and no historical tool
output.
Also normalizes the expected workspace in the listing test through
filepath.Clean, matching the resolver's output contract, so a TMPDIR spelled
with a ".." component no longer fails a correct result.
Rebased onto main to pick up the ACP browser metadata, specialist model
restoration and worktree pointer protection changes. Both reported by @jatmn.
1205 lines
39 KiB
Go
1205 lines
39 KiB
Go
package sessions
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/Gitlawb/zero/internal/fsutil"
|
|
)
|
|
|
|
const (
|
|
MetadataFile = "metadata.json"
|
|
EventsFile = "events.jsonl"
|
|
)
|
|
|
|
type EventType string
|
|
|
|
const (
|
|
EventMessage EventType = "message"
|
|
EventToolCall EventType = "tool_call"
|
|
EventPermission EventType = "permission"
|
|
EventPermissionRequest EventType = "permission_request"
|
|
EventPermissionDecision EventType = "permission_decision"
|
|
EventToolResult EventType = "tool_result"
|
|
EventProviderUsage EventType = "provider_usage"
|
|
EventUsage EventType = EventProviderUsage
|
|
EventError EventType = "error"
|
|
EventSessionCheckpoint EventType = "session_checkpoint"
|
|
EventSessionRewind EventType = "session_rewind"
|
|
EventCompaction EventType = "session_compaction"
|
|
EventSessionFork EventType = "session_fork"
|
|
EventSessionChild EventType = "session_child"
|
|
EventSpecialistStart EventType = "specialist_start"
|
|
EventSpecialistStop EventType = "specialist_stop"
|
|
EventSpecDraft EventType = "spec_draft"
|
|
EventSpecApproved EventType = "spec_approved"
|
|
EventSpecRejected EventType = "spec_rejected"
|
|
EventGoalCreated EventType = "goal_created"
|
|
EventGoalUpdated EventType = "goal_updated"
|
|
EventGoalCleared EventType = "goal_cleared"
|
|
)
|
|
|
|
type SessionKind string
|
|
|
|
const (
|
|
SessionKindFork SessionKind = "fork"
|
|
SessionKindChild SessionKind = "child"
|
|
SessionKindSide SessionKind = "side"
|
|
SessionKindSpecDraft SessionKind = "spec-draft"
|
|
SessionKindSpecImpl SessionKind = "spec-impl"
|
|
)
|
|
|
|
type SpecStatus string
|
|
|
|
const (
|
|
SpecStatusDraft SpecStatus = "draft"
|
|
SpecStatusApproved SpecStatus = "approved"
|
|
SpecStatusRejected SpecStatus = "rejected"
|
|
)
|
|
|
|
type GoalStatus string
|
|
|
|
const (
|
|
GoalStatusActive GoalStatus = "active"
|
|
GoalStatusPaused GoalStatus = "paused"
|
|
GoalStatusBlocked GoalStatus = "blocked"
|
|
GoalStatusBudgetLimited GoalStatus = "budget_limited"
|
|
GoalStatusUsageLimited GoalStatus = "usage_limited"
|
|
GoalStatusComplete GoalStatus = "complete"
|
|
)
|
|
|
|
type Goal struct {
|
|
Objective string `json:"objective"`
|
|
Status GoalStatus `json:"status"`
|
|
StatusReason string `json:"statusReason,omitempty"`
|
|
TokenBudget int `json:"tokenBudget,omitempty"`
|
|
TokensUsed int `json:"tokensUsed,omitempty"`
|
|
ContinuationCount int `json:"continuationCount,omitempty"`
|
|
ContinuationLimit int `json:"continuationLimit,omitempty"`
|
|
CreatedAt string `json:"createdAt"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
}
|
|
|
|
type Metadata struct {
|
|
SessionID string `json:"sessionId"`
|
|
SessionKind SessionKind `json:"sessionKind,omitempty"`
|
|
Title string `json:"title,omitempty"`
|
|
Cwd string `json:"cwd,omitempty"`
|
|
ModelID string `json:"modelId,omitempty"`
|
|
Provider string `json:"provider,omitempty"`
|
|
Tag string `json:"tag,omitempty"`
|
|
Depth int `json:"depth,omitempty"`
|
|
ParentSessionID string `json:"parentSessionId,omitempty"`
|
|
RootSessionID string `json:"rootSessionId,omitempty"`
|
|
AgentName string `json:"agentName,omitempty"`
|
|
TaskID string `json:"taskId,omitempty"`
|
|
ForkedFromEventID string `json:"forkedFromEventId,omitempty"`
|
|
ForkedFromSequence int `json:"forkedFromSequence,omitempty"`
|
|
SpawnedFromEventID string `json:"spawnedFromEventId,omitempty"`
|
|
SpawnedFromSequence int `json:"spawnedFromSequence,omitempty"`
|
|
SpecID string `json:"specId,omitempty"`
|
|
SpecFilePath string `json:"specFilePath,omitempty"`
|
|
SpecStatus SpecStatus `json:"specStatus,omitempty"`
|
|
SpecDraftModelID string `json:"specDraftModelId,omitempty"`
|
|
SpecDraftReasoning string `json:"specDraftReasoning,omitempty"`
|
|
SpecUserComment string `json:"specUserComment,omitempty"`
|
|
SpecRejectReason string `json:"specRejectReason,omitempty"`
|
|
SpecSourceSessionID string `json:"specSourceSessionId,omitempty"`
|
|
SpecImplSessionID string `json:"specImplSessionId,omitempty"`
|
|
Goal *Goal `json:"goal,omitempty"`
|
|
CreatedAt string `json:"createdAt"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
EventCount int `json:"eventCount"`
|
|
LastEventType EventType `json:"lastEventType,omitempty"`
|
|
}
|
|
|
|
type CreateInput struct {
|
|
SessionID string
|
|
SessionKind SessionKind
|
|
Title string
|
|
Cwd string
|
|
ModelID string
|
|
Provider string
|
|
Tag string
|
|
Depth int
|
|
ParentSessionID string
|
|
RootSessionID string
|
|
AgentName string
|
|
TaskID string
|
|
ForkedFromEventID string
|
|
ForkedFromSequence int
|
|
SpawnedFromEventID string
|
|
SpawnedFromSequence int
|
|
SpecID string
|
|
SpecFilePath string
|
|
SpecStatus SpecStatus
|
|
SpecDraftModelID string
|
|
SpecDraftReasoning string
|
|
SpecUserComment string
|
|
SpecRejectReason string
|
|
SpecSourceSessionID string
|
|
SpecImplSessionID string
|
|
Goal *Goal
|
|
}
|
|
|
|
type ForkInput struct {
|
|
SessionID string
|
|
SessionKind SessionKind
|
|
Title string
|
|
Cwd string
|
|
ModelID string
|
|
Provider string
|
|
Tag string
|
|
}
|
|
|
|
type ChildInput struct {
|
|
SessionID string
|
|
Title string
|
|
Cwd string
|
|
ModelID string
|
|
Provider string
|
|
Tag string
|
|
Depth int
|
|
AgentName string
|
|
TaskID string
|
|
Prompt string
|
|
}
|
|
|
|
type TreeNode struct {
|
|
Session Metadata `json:"session"`
|
|
Children []TreeNode `json:"children"`
|
|
}
|
|
|
|
type AppendEventInput struct {
|
|
Type EventType
|
|
Payload any
|
|
}
|
|
|
|
type preparedAppendEvent struct {
|
|
Type EventType
|
|
Payload json.RawMessage
|
|
}
|
|
|
|
type RecordSpecInput struct {
|
|
SpecID string
|
|
SpecFilePath string
|
|
SpecStatus SpecStatus
|
|
SpecDraftModelID string
|
|
SpecDraftReasoning string
|
|
SpecUserComment string
|
|
SpecRejectReason string
|
|
SpecSourceSessionID string
|
|
SpecImplSessionID string
|
|
}
|
|
|
|
type Event struct {
|
|
ID string `json:"id"`
|
|
SessionID string `json:"sessionId"`
|
|
Sequence int `json:"sequence"`
|
|
Type EventType `json:"type"`
|
|
CreatedAt string `json:"createdAt"`
|
|
Payload json.RawMessage `json:"payload,omitempty"`
|
|
}
|
|
|
|
type StoreOptions struct {
|
|
RootDir string
|
|
Now func() time.Time
|
|
Env map[string]string
|
|
}
|
|
|
|
type Store struct {
|
|
RootDir string
|
|
now func() time.Time
|
|
locksMu sync.Mutex
|
|
// sessionLocks holds one in-process mutex per session id. Entries are never
|
|
// removed: doing so safely would require reference counting (a goroutine
|
|
// blocked on a mutex must not have it deleted and recreated out from under
|
|
// it, which would break mutual exclusion). The cost of leaving them is a
|
|
// single *sync.Mutex per distinct session id touched by this Store's
|
|
// lifetime, which is small and bounded in practice — the CLI process is
|
|
// short-lived and the TUI works with a bounded set of sessions. There is no
|
|
// session-close/delete lifecycle hook to prune against, so unbounded growth
|
|
// is accepted deliberately rather than risk an unsafe eviction.
|
|
sessionLocks map[string]*sync.Mutex
|
|
idCounter atomic.Uint64
|
|
}
|
|
|
|
var sessionIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$`)
|
|
|
|
func NewStore(options StoreOptions) *Store {
|
|
rootDir := strings.TrimSpace(options.RootDir)
|
|
if rootDir == "" {
|
|
rootDir = DefaultRoot(options.Env)
|
|
}
|
|
now := options.Now
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
return &Store{RootDir: rootDir, now: now, sessionLocks: map[string]*sync.Mutex{}}
|
|
}
|
|
|
|
func DefaultRoot(env map[string]string) string {
|
|
dataHome := strings.TrimSpace(envValue(env, "XDG_DATA_HOME"))
|
|
home := strings.TrimSpace(envValue(env, "HOME"))
|
|
if home == "" {
|
|
if userHome, err := os.UserHomeDir(); err == nil {
|
|
home = userHome
|
|
}
|
|
}
|
|
base := dataHome
|
|
if base == "" {
|
|
base = filepath.Join(home, ".local", "share")
|
|
}
|
|
return filepath.Join(base, "zero", "sessions")
|
|
}
|
|
|
|
func ValidSessionID(sessionID string) bool {
|
|
return sessionIDPattern.MatchString(strings.TrimSpace(sessionID))
|
|
}
|
|
|
|
func (store *Store) Create(input CreateInput) (Metadata, error) {
|
|
sessionID := strings.TrimSpace(input.SessionID)
|
|
if sessionID == "" {
|
|
sessionID = store.createID()
|
|
}
|
|
if !ValidSessionID(sessionID) {
|
|
return Metadata{}, fmt.Errorf("invalid zero session id %q", input.SessionID)
|
|
}
|
|
if input.Depth < 0 {
|
|
return Metadata{}, fmt.Errorf("invalid zero session depth %d", input.Depth)
|
|
}
|
|
|
|
timestamp := store.timestamp()
|
|
session := Metadata{
|
|
SessionID: sessionID,
|
|
SessionKind: input.SessionKind,
|
|
Title: strings.TrimSpace(input.Title),
|
|
Cwd: strings.TrimSpace(input.Cwd),
|
|
ModelID: strings.TrimSpace(input.ModelID),
|
|
Provider: strings.TrimSpace(input.Provider),
|
|
Tag: strings.TrimSpace(input.Tag),
|
|
Depth: input.Depth,
|
|
ParentSessionID: strings.TrimSpace(input.ParentSessionID),
|
|
RootSessionID: strings.TrimSpace(input.RootSessionID),
|
|
AgentName: strings.TrimSpace(input.AgentName),
|
|
TaskID: strings.TrimSpace(input.TaskID),
|
|
ForkedFromEventID: strings.TrimSpace(input.ForkedFromEventID),
|
|
ForkedFromSequence: input.ForkedFromSequence,
|
|
SpawnedFromEventID: strings.TrimSpace(input.SpawnedFromEventID),
|
|
SpawnedFromSequence: input.SpawnedFromSequence,
|
|
SpecID: strings.TrimSpace(input.SpecID),
|
|
SpecFilePath: strings.TrimSpace(input.SpecFilePath),
|
|
SpecStatus: normalizeSpecStatus(input.SpecStatus),
|
|
SpecDraftModelID: strings.TrimSpace(input.SpecDraftModelID),
|
|
SpecDraftReasoning: strings.TrimSpace(input.SpecDraftReasoning),
|
|
SpecUserComment: strings.TrimSpace(input.SpecUserComment),
|
|
SpecRejectReason: strings.TrimSpace(input.SpecRejectReason),
|
|
SpecSourceSessionID: strings.TrimSpace(input.SpecSourceSessionID),
|
|
SpecImplSessionID: strings.TrimSpace(input.SpecImplSessionID),
|
|
Goal: cloneGoal(input.Goal),
|
|
CreatedAt: timestamp,
|
|
UpdatedAt: timestamp,
|
|
EventCount: 0,
|
|
}
|
|
|
|
if err := os.MkdirAll(store.RootDir, 0o700); err != nil {
|
|
return Metadata{}, fmt.Errorf("create zero session root: %w", err)
|
|
}
|
|
if err := os.Mkdir(store.sessionPath(sessionID), 0o700); err != nil {
|
|
if errors.Is(err, os.ErrExist) {
|
|
return Metadata{}, fmt.Errorf("zero session already exists: %s", sessionID)
|
|
}
|
|
return Metadata{}, fmt.Errorf("create zero session directory: %w", err)
|
|
}
|
|
if err := store.writeMetadata(session); err != nil {
|
|
return Metadata{}, err
|
|
}
|
|
file, err := os.OpenFile(store.eventsPath(sessionID), os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600)
|
|
if err != nil {
|
|
return Metadata{}, fmt.Errorf("create zero session events file: %w", err)
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
return Metadata{}, fmt.Errorf("close zero session events file: %w", err)
|
|
}
|
|
return session, nil
|
|
}
|
|
|
|
func (store *Store) Get(sessionID string) (*Metadata, error) {
|
|
if !ValidSessionID(sessionID) {
|
|
return nil, fmt.Errorf("invalid zero session id %q", sessionID)
|
|
}
|
|
session, err := store.readMetadata(sessionID)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return &session, nil
|
|
}
|
|
|
|
func (store *Store) List() ([]Metadata, error) {
|
|
if err := os.MkdirAll(store.RootDir, 0o700); err != nil {
|
|
return nil, fmt.Errorf("create zero session root: %w", err)
|
|
}
|
|
entries, err := os.ReadDir(store.RootDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read zero session root: %w", err)
|
|
}
|
|
sessions := []Metadata{}
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() || !ValidSessionID(entry.Name()) {
|
|
continue
|
|
}
|
|
session, err := store.Get(entry.Name())
|
|
if err != nil || session == nil {
|
|
continue
|
|
}
|
|
sessions = append(sessions, *session)
|
|
}
|
|
sort.SliceStable(sessions, func(left int, right int) bool {
|
|
if sessions[left].UpdatedAt == sessions[right].UpdatedAt {
|
|
return sessions[left].SessionID < sessions[right].SessionID
|
|
}
|
|
return sessions[left].UpdatedAt > sessions[right].UpdatedAt
|
|
})
|
|
return sessions, nil
|
|
}
|
|
|
|
func (store *Store) Latest() (*Metadata, error) {
|
|
sessions, err := store.List()
|
|
if err != nil || len(sessions) == 0 {
|
|
return nil, err
|
|
}
|
|
return &sessions[0], nil
|
|
}
|
|
|
|
// IsResumableKind reports whether a session kind represents a standalone,
|
|
// user-resumable conversation rather than a transient or agent-owned sub-run.
|
|
// Regular ("") and user fork sessions are resumable; side, child, and spec
|
|
// sessions are not, so they do not flood the resume picker.
|
|
func IsResumableKind(kind SessionKind) bool {
|
|
switch kind {
|
|
case "", SessionKindFork:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// ListResumable returns only the sessions a user can resume as standalone
|
|
// conversations (see IsResumableKind), newest-first like List.
|
|
func (store *Store) ListResumable() ([]Metadata, error) {
|
|
all, err := store.List()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resumable := make([]Metadata, 0, len(all))
|
|
for _, session := range all {
|
|
if IsResumableKind(session.SessionKind) {
|
|
resumable = append(resumable, session)
|
|
}
|
|
}
|
|
return resumable, nil
|
|
}
|
|
|
|
// LatestResumable returns the most-recently-updated resumable session, or nil
|
|
// when none exist. Used by `/resume latest` so it lands on a real conversation
|
|
// instead of the newest child/spec sub-run.
|
|
func (store *Store) LatestResumable() (*Metadata, error) {
|
|
resumable, err := store.ListResumable()
|
|
if err != nil || len(resumable) == 0 {
|
|
return nil, err
|
|
}
|
|
return &resumable[0], nil
|
|
}
|
|
|
|
func (store *Store) Fork(parentSessionID string, input ForkInput) (Metadata, error) {
|
|
if !ValidSessionID(parentSessionID) {
|
|
return Metadata{}, fmt.Errorf("invalid zero session id %q", parentSessionID)
|
|
}
|
|
parent, err := store.Get(parentSessionID)
|
|
if err != nil {
|
|
return Metadata{}, err
|
|
}
|
|
if parent == nil {
|
|
return Metadata{}, fmt.Errorf("zero session not found: %s", parentSessionID)
|
|
}
|
|
events, err := store.ReadEvents(parentSessionID)
|
|
if err != nil {
|
|
return Metadata{}, err
|
|
}
|
|
var last Event
|
|
if len(events) > 0 {
|
|
last = events[len(events)-1]
|
|
}
|
|
|
|
title := strings.TrimSpace(input.Title)
|
|
if title == "" && parent.Title != "" {
|
|
title = parent.Title + " (fork)"
|
|
}
|
|
kind := input.SessionKind
|
|
if kind == "" {
|
|
kind = SessionKindFork
|
|
}
|
|
if kind != SessionKindFork && kind != SessionKindSide {
|
|
return Metadata{}, fmt.Errorf("invalid zero fork session kind %q", kind)
|
|
}
|
|
fork, err := store.Create(CreateInput{
|
|
SessionID: input.SessionID,
|
|
SessionKind: kind,
|
|
Title: title,
|
|
Cwd: firstNonEmpty(input.Cwd, parent.Cwd),
|
|
ModelID: firstNonEmpty(input.ModelID, parent.ModelID),
|
|
Provider: firstNonEmpty(input.Provider, parent.Provider),
|
|
Tag: input.Tag,
|
|
ParentSessionID: parent.SessionID,
|
|
RootSessionID: firstNonEmpty(parent.RootSessionID, parent.SessionID),
|
|
ForkedFromEventID: last.ID,
|
|
ForkedFromSequence: last.Sequence,
|
|
})
|
|
if err != nil {
|
|
return Metadata{}, err
|
|
}
|
|
copyInputs := []AppendEventInput{}
|
|
for _, event := range events {
|
|
// Do NOT copy usage accounting into the fork. It already counted against the
|
|
// parent, and a usage report that aggregates the parent and the fork would
|
|
// otherwise double-count it. The fork only needs the conversation history
|
|
// (messages, tool calls, checkpoints) to continue; usage is not replayed.
|
|
if event.Type == EventUsage {
|
|
continue
|
|
}
|
|
copyInputs = append(copyInputs, AppendEventInput{Type: event.Type, Payload: event.Payload})
|
|
}
|
|
if _, err := store.AppendEvents(fork.SessionID, copyInputs); err != nil {
|
|
return Metadata{}, err
|
|
}
|
|
copied := len(copyInputs)
|
|
// Copy the parent's content-addressed checkpoint blobs into the fork so the
|
|
// copied EventSessionCheckpoint events resolve to real blobs and a rewind on
|
|
// the fork can restore file content (otherwise rewind reads missing blobs
|
|
// and silently skips the files).
|
|
if err := store.copyBlobs(parent.SessionID, fork.SessionID); err != nil {
|
|
return Metadata{}, err
|
|
}
|
|
if _, err := store.AppendEvent(fork.SessionID, AppendEventInput{
|
|
Type: EventSessionFork,
|
|
Payload: map[string]any{
|
|
"parentSessionId": parent.SessionID,
|
|
"parentEventCount": parent.EventCount,
|
|
"copiedEventCount": copied,
|
|
"forkedFromEventId": last.ID,
|
|
"forkedFromSequence": last.Sequence,
|
|
},
|
|
}); err != nil {
|
|
return Metadata{}, err
|
|
}
|
|
loaded, err := store.readMetadata(fork.SessionID)
|
|
if err != nil {
|
|
return Metadata{}, err
|
|
}
|
|
return loaded, nil
|
|
}
|
|
|
|
func (store *Store) RecordSpec(sessionID string, input RecordSpecInput) (Metadata, Event, error) {
|
|
if !ValidSessionID(sessionID) {
|
|
return Metadata{}, Event{}, fmt.Errorf("invalid zero session id %q", sessionID)
|
|
}
|
|
status := normalizeSpecStatus(input.SpecStatus)
|
|
if status == "" {
|
|
return Metadata{}, Event{}, fmt.Errorf("zero spec status is required")
|
|
}
|
|
unlock, err := store.lockSession(sessionID)
|
|
if err != nil {
|
|
return Metadata{}, Event{}, err
|
|
}
|
|
defer unlock()
|
|
|
|
session, err := store.readMetadata(sessionID)
|
|
if err != nil {
|
|
return Metadata{}, Event{}, err
|
|
}
|
|
applySpecRecord(&session, input, status)
|
|
if err := store.writeMetadata(session); err != nil {
|
|
return Metadata{}, Event{}, err
|
|
}
|
|
event, err := store.appendEventLocked(sessionID, AppendEventInput{
|
|
Type: specEventType(status),
|
|
Payload: map[string]any{
|
|
"specId": session.SpecID,
|
|
"specFilePath": session.SpecFilePath,
|
|
"specStatus": session.SpecStatus,
|
|
"specDraftModelId": session.SpecDraftModelID,
|
|
"specDraftReasoning": session.SpecDraftReasoning,
|
|
"specUserComment": session.SpecUserComment,
|
|
"specRejectReason": session.SpecRejectReason,
|
|
"specSourceSessionId": session.SpecSourceSessionID,
|
|
"specImplSessionId": session.SpecImplSessionID,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return Metadata{}, Event{}, err
|
|
}
|
|
loaded, err := store.readMetadata(sessionID)
|
|
if err != nil {
|
|
return Metadata{}, Event{}, err
|
|
}
|
|
return loaded, event, nil
|
|
}
|
|
|
|
func (store *Store) AppendEvent(sessionID string, input AppendEventInput) (Event, error) {
|
|
events, err := store.AppendEvents(sessionID, []AppendEventInput{input})
|
|
if err != nil {
|
|
return Event{}, err
|
|
}
|
|
return events[0], nil
|
|
}
|
|
|
|
// AppendEvents appends a batch of events atomically under one session lock and
|
|
// durability pass. The returned events have contiguous sequences in input order.
|
|
// An empty batch is a no-op and does not rewrite metadata.
|
|
func (store *Store) AppendEvents(sessionID string, inputs []AppendEventInput) ([]Event, error) {
|
|
if !ValidSessionID(sessionID) {
|
|
return nil, fmt.Errorf("invalid zero session id %q", sessionID)
|
|
}
|
|
prepared, err := prepareAppendEventInputs(inputs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(prepared) == 0 {
|
|
return []Event{}, nil
|
|
}
|
|
unlock, err := store.lockSession(sessionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer unlock()
|
|
|
|
return store.appendPreparedEventsLocked(sessionID, prepared)
|
|
}
|
|
|
|
// AppendEventUnlessExists appends input only when exists(currentEvents) is false,
|
|
// performing the existence check and the append atomically under the session lock
|
|
// (in-process mutex + cross-process file lock). It is the safe primitive for
|
|
// "record once" accounting: a plain ReadEvents-then-AppendEvent has a
|
|
// check-then-act race where two callers — even in separate processes — both see
|
|
// the event absent and each append a duplicate. Returns appended=false when the
|
|
// predicate already matched. A nil predicate always appends.
|
|
func (store *Store) AppendEventUnlessExists(sessionID string, input AppendEventInput, exists func([]Event) bool) (Event, bool, error) {
|
|
if !ValidSessionID(sessionID) {
|
|
return Event{}, false, fmt.Errorf("invalid zero session id %q", sessionID)
|
|
}
|
|
if strings.TrimSpace(string(input.Type)) == "" {
|
|
return Event{}, false, fmt.Errorf("zero session event type is required")
|
|
}
|
|
unlock, err := store.lockSession(sessionID)
|
|
if err != nil {
|
|
return Event{}, false, err
|
|
}
|
|
defer unlock()
|
|
|
|
if exists != nil {
|
|
// Safe under the held lock: ReadEvents only os.ReadFile's the log and never
|
|
// re-acquires the session lock, so there is no deadlock.
|
|
events, err := store.ReadEvents(sessionID)
|
|
if err != nil {
|
|
return Event{}, false, err
|
|
}
|
|
if exists(events) {
|
|
return Event{}, false, nil
|
|
}
|
|
}
|
|
event, err := store.appendEventLocked(sessionID, input)
|
|
if err != nil {
|
|
return Event{}, false, err
|
|
}
|
|
return event, true, nil
|
|
}
|
|
|
|
// appendEventLocked appends an event WITHOUT acquiring the session lock. The
|
|
// caller MUST already hold store.lockSession(sessionID). It exists so multi-step
|
|
// operations (e.g. ApplyRewind) can append the trailing marker atomically under
|
|
// the single lock they already hold, instead of re-locking (which would deadlock
|
|
// on the non-reentrant in-process mutex).
|
|
func (store *Store) appendEventLocked(sessionID string, input AppendEventInput) (Event, error) {
|
|
prepared, err := prepareAppendEventInputs([]AppendEventInput{input})
|
|
if err != nil {
|
|
return Event{}, err
|
|
}
|
|
events, err := store.appendPreparedEventsLocked(sessionID, prepared)
|
|
if err != nil {
|
|
return Event{}, err
|
|
}
|
|
return events[0], nil
|
|
}
|
|
|
|
// appendPreparedEventsLocked appends pre-validated events WITHOUT acquiring the
|
|
// session lock. The caller MUST already hold store.lockSession(sessionID).
|
|
func (store *Store) appendPreparedEventsLocked(sessionID string, inputs []preparedAppendEvent) ([]Event, error) {
|
|
if len(inputs) == 0 {
|
|
return []Event{}, nil
|
|
}
|
|
session, err := store.readMetadata(sessionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sequence := session.EventCount + 1
|
|
// The events log is the source of truth for sequencing. metadata.EventCount is
|
|
// persisted separately (after the event line below), so a crash between the two
|
|
// can leave it behind the log; deriving from the log's last sequence then avoids
|
|
// reusing a number (a duplicate that would mis-target /rewind). Best-effort: a
|
|
// log read error falls back to the metadata count. Only triggers on a real
|
|
// desync (log at/ahead of the metadata-derived sequence).
|
|
if logSeq, err := store.lastEventSequence(sessionID); err == nil && logSeq >= sequence {
|
|
sequence = logSeq + 1
|
|
}
|
|
events := make([]Event, 0, len(inputs))
|
|
var batch bytes.Buffer
|
|
for index, input := range inputs {
|
|
timestamp := store.timestamp()
|
|
event := Event{
|
|
ID: fmt.Sprintf("%s:%d", sessionID, sequence+index),
|
|
SessionID: sessionID,
|
|
Sequence: sequence + index,
|
|
Type: input.Type,
|
|
CreatedAt: timestamp,
|
|
Payload: input.Payload,
|
|
}
|
|
data, err := json.Marshal(event)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encode zero session event: %w", err)
|
|
}
|
|
batch.Write(data)
|
|
batch.WriteByte('\n')
|
|
events = append(events, event)
|
|
}
|
|
file, err := os.OpenFile(store.eventsPath(sessionID), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o600)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("append zero session event: %w", err)
|
|
}
|
|
if _, err := file.Write(batch.Bytes()); err != nil {
|
|
_ = file.Close()
|
|
return nil, fmt.Errorf("append zero session event: %w", err)
|
|
}
|
|
// fsync the events before reporting success: the derived metadata.json IS
|
|
// fsync'd (writeMetadata), so without this a crash after the metadata flush
|
|
// but before the events.jsonl page reaches disk leaves EventCount ahead of the
|
|
// durable log — silently losing just-appended events (incl. checkpoints
|
|
// that /rewind targets). Make the log at least as durable as its metadata. (AUDIT-M12)
|
|
if err := file.Sync(); err != nil {
|
|
_ = file.Close()
|
|
return nil, fmt.Errorf("sync zero session event: %w", err)
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
return nil, fmt.Errorf("close zero session event file: %w", err)
|
|
}
|
|
last := events[len(events)-1]
|
|
session.UpdatedAt = last.CreatedAt
|
|
session.EventCount = last.Sequence
|
|
session.LastEventType = last.Type
|
|
if err := store.writeMetadata(session); err != nil {
|
|
return nil, err
|
|
}
|
|
return events, nil
|
|
}
|
|
|
|
// UpdateTitle replaces a session's Title and returns the updated metadata. It is
|
|
// serialized under the same per-session lock as AppendEvent and re-reads the
|
|
// latest metadata under that lock before rewriting, so a concurrent append can't
|
|
// clobber the new title (nor the title clobber a concurrent append's event
|
|
// count/timestamp). UpdatedAt is deliberately left untouched: a rename is not
|
|
// activity, so it must not reorder the session in the resumable list. A blank
|
|
// title is rejected so a failed model generation can never erase a useful
|
|
// first-message title, and an unchanged title is a no-op (no rewrite/fsync).
|
|
func (store *Store) UpdateTitle(sessionID string, title string) (Metadata, error) {
|
|
if !ValidSessionID(sessionID) {
|
|
return Metadata{}, fmt.Errorf("invalid zero session id %q", sessionID)
|
|
}
|
|
trimmed := strings.TrimSpace(title)
|
|
if trimmed == "" {
|
|
return Metadata{}, fmt.Errorf("zero session title is required")
|
|
}
|
|
unlock, err := store.lockSession(sessionID)
|
|
if err != nil {
|
|
return Metadata{}, err
|
|
}
|
|
defer unlock()
|
|
|
|
session, err := store.readMetadata(sessionID)
|
|
if err != nil {
|
|
return Metadata{}, err
|
|
}
|
|
if session.Title == trimmed {
|
|
return session, nil
|
|
}
|
|
session.Title = trimmed
|
|
if err := store.writeMetadata(session); err != nil {
|
|
return Metadata{}, err
|
|
}
|
|
return session, nil
|
|
}
|
|
|
|
// UpdateTitleIfCurrent replaces a session title only when it still matches
|
|
// expected. It lets background automatic naming avoid overwriting a newer manual
|
|
// rename while keeping the check and write under the same per-session lock.
|
|
func (store *Store) UpdateTitleIfCurrent(sessionID string, expected string, title string) (Metadata, bool, error) {
|
|
if !ValidSessionID(sessionID) {
|
|
return Metadata{}, false, fmt.Errorf("invalid zero session id %q", sessionID)
|
|
}
|
|
trimmed := strings.TrimSpace(title)
|
|
if trimmed == "" {
|
|
return Metadata{}, false, fmt.Errorf("zero session title is required")
|
|
}
|
|
unlock, err := store.lockSession(sessionID)
|
|
if err != nil {
|
|
return Metadata{}, false, err
|
|
}
|
|
defer unlock()
|
|
|
|
session, err := store.readMetadata(sessionID)
|
|
if err != nil {
|
|
return Metadata{}, false, err
|
|
}
|
|
if session.Title != strings.TrimSpace(expected) {
|
|
return session, false, nil
|
|
}
|
|
if session.Title == trimmed {
|
|
return session, true, nil
|
|
}
|
|
session.Title = trimmed
|
|
if err := store.writeMetadata(session); err != nil {
|
|
return Metadata{}, false, err
|
|
}
|
|
return session, true, nil
|
|
}
|
|
|
|
// UpdateModel replaces a session's selected model without changing its activity
|
|
// timestamp or event counters. An empty model clears the session override.
|
|
func (store *Store) UpdateModel(sessionID string, modelID string) (Metadata, error) {
|
|
if !ValidSessionID(sessionID) {
|
|
return Metadata{}, fmt.Errorf("invalid zero session id %q", sessionID)
|
|
}
|
|
modelID = strings.TrimSpace(modelID)
|
|
unlock, err := store.lockSession(sessionID)
|
|
if err != nil {
|
|
return Metadata{}, err
|
|
}
|
|
defer unlock()
|
|
|
|
session, err := store.readMetadata(sessionID)
|
|
if err != nil {
|
|
return Metadata{}, err
|
|
}
|
|
if session.ModelID == modelID {
|
|
return session, nil
|
|
}
|
|
session.ModelID = modelID
|
|
if err := store.writeMetadata(session); err != nil {
|
|
return Metadata{}, err
|
|
}
|
|
return session, nil
|
|
}
|
|
|
|
func (store *Store) ReadEvents(sessionID string) ([]Event, error) {
|
|
events, _, err := store.ReadEventsWithPresence(sessionID)
|
|
return events, err
|
|
}
|
|
|
|
// ReadEventsWithPresence preserves the distinction between an intentionally
|
|
// empty event log and a missing one. Most readers accept both as empty history,
|
|
// but activation protocols that promise to restore previously populated
|
|
// context need the stronger signal.
|
|
func (store *Store) ReadEventsWithPresence(sessionID string) ([]Event, bool, error) {
|
|
if !ValidSessionID(sessionID) {
|
|
return nil, false, fmt.Errorf("invalid zero session id %q", sessionID)
|
|
}
|
|
data, err := os.ReadFile(store.eventsPath(sessionID))
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return []Event{}, false, nil
|
|
}
|
|
return nil, false, fmt.Errorf("read zero session events: %w", err)
|
|
}
|
|
// A genuine torn tail is an INCOMPLETE final write — a crash mid-append leaves
|
|
// the last line without its terminating newline. If the file ends with a
|
|
// newline, every record was fully flushed, so a malformed final line is real
|
|
// corruption and must still fail loudly.
|
|
tornTailPossible := len(data) > 0 && data[len(data)-1] != '\n'
|
|
lines := bytes.Split(data, []byte{'\n'})
|
|
lastNonEmpty := -1
|
|
for index, line := range lines {
|
|
if len(bytes.TrimSpace(line)) > 0 {
|
|
lastNonEmpty = index
|
|
}
|
|
}
|
|
events := []Event{}
|
|
for index, line := range lines {
|
|
line = bytes.TrimSpace(line)
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
var event Event
|
|
if err := json.Unmarshal(line, &event); err != nil {
|
|
// Tolerate a torn tail: a crash mid-append leaves the final line
|
|
// truncated (and thus without a trailing newline). Drop that partial
|
|
// record so resume still recovers every complete event. A malformed
|
|
// line anywhere earlier — or a complete-but-corrupt final line — is real
|
|
// corruption and still fails loudly.
|
|
if index == lastNonEmpty && tornTailPossible {
|
|
break
|
|
}
|
|
return nil, true, fmt.Errorf("invalid json in zero session %s %s at line %d: %w", sessionID, EventsFile, index+1, err)
|
|
}
|
|
events = append(events, event)
|
|
}
|
|
return events, true, nil
|
|
}
|
|
|
|
func (store *Store) timestamp() string {
|
|
return store.now().UTC().Format(time.RFC3339)
|
|
}
|
|
|
|
func (store *Store) createID() string {
|
|
timestamp := store.now().UTC()
|
|
return fmt.Sprintf("zero_%s_%d_%d", timestamp.Format("20060102150405"), timestamp.UnixNano(), store.idCounter.Add(1))
|
|
}
|
|
|
|
func (store *Store) sessionLock(sessionID string) *sync.Mutex {
|
|
store.locksMu.Lock()
|
|
defer store.locksMu.Unlock()
|
|
if store.sessionLocks == nil {
|
|
store.sessionLocks = map[string]*sync.Mutex{}
|
|
}
|
|
lock := store.sessionLocks[sessionID]
|
|
if lock == nil {
|
|
lock = &sync.Mutex{}
|
|
store.sessionLocks[sessionID] = lock
|
|
}
|
|
return lock
|
|
}
|
|
|
|
// lockSession serializes mutations to a session both in-process (the existing
|
|
// per-Store mutex) and across processes (an OS file lock on a per-session
|
|
// .lock file), so a CLI rewind and a TUI sharing the same RootDir cannot
|
|
// interleave writes. It returns an unlock function that releases both locks in
|
|
// reverse order. The OS lock is best-effort: if it cannot be acquired (e.g. an
|
|
// unsupported platform) the in-memory mutex still applies.
|
|
func (store *Store) lockSession(sessionID string) (func(), error) {
|
|
mu := store.sessionLock(sessionID)
|
|
mu.Lock()
|
|
release, err := store.acquireFileLock(sessionID)
|
|
if err != nil {
|
|
mu.Unlock()
|
|
return nil, err
|
|
}
|
|
return func() {
|
|
release()
|
|
mu.Unlock()
|
|
}, nil
|
|
}
|
|
|
|
func (store *Store) lockPath(sessionID string) string {
|
|
return filepath.Join(store.sessionPath(sessionID), "session.lock")
|
|
}
|
|
|
|
func (store *Store) sessionPath(sessionID string) string {
|
|
return filepath.Join(store.RootDir, sessionID)
|
|
}
|
|
|
|
func (store *Store) metadataPath(sessionID string) string {
|
|
return filepath.Join(store.sessionPath(sessionID), MetadataFile)
|
|
}
|
|
|
|
func (store *Store) eventsPath(sessionID string) string {
|
|
return filepath.Join(store.sessionPath(sessionID), EventsFile)
|
|
}
|
|
|
|
func (store *Store) readMetadata(sessionID string) (Metadata, error) {
|
|
data, err := os.ReadFile(store.metadataPath(sessionID))
|
|
if err != nil {
|
|
return Metadata{}, err
|
|
}
|
|
var session Metadata
|
|
if err := json.Unmarshal(data, &session); err != nil {
|
|
return Metadata{}, fmt.Errorf("invalid zero session metadata %s: %w", sessionID, err)
|
|
}
|
|
return session, nil
|
|
}
|
|
|
|
func (store *Store) writeMetadata(session Metadata) error {
|
|
data, err := json.MarshalIndent(session, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encode zero session metadata: %w", err)
|
|
}
|
|
path := store.metadataPath(session.SessionID)
|
|
tmp := fmt.Sprintf("%s.tmp-%d", path, store.idCounter.Add(1))
|
|
// fsync the temp file before renaming it into place. os.WriteFile does not
|
|
// sync, so a crash right after the rename could surface a metadata file whose
|
|
// contents were never flushed — a torn or empty file that corrupts the whole
|
|
// session. Syncing the data before the atomic rename closes that window.
|
|
if err := writeFileSync(tmp, append(data, '\n'), 0o600); err != nil {
|
|
return fmt.Errorf("write zero session metadata: %w", err)
|
|
}
|
|
if err := fsutil.RenameWithRetry(tmp, path, nil); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return fmt.Errorf("replace zero session metadata: %w", err)
|
|
}
|
|
// fsync the parent directory so the rename itself is durable: the temp file's
|
|
// contents were synced above, but without syncing the directory a crash can
|
|
// still lose the rename (the new directory entry), leaving the old/no file.
|
|
if err := syncDir(filepath.Dir(path)); err != nil {
|
|
return fmt.Errorf("sync zero session dir: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// syncDir fsyncs a directory so a rename/create within it is durable across a
|
|
// crash. A platform that cannot open a directory for sync (e.g. Windows) reports
|
|
// no error — the rename is best-effort durable there.
|
|
func syncDir(dir string) error {
|
|
if runtime.GOOS == "windows" {
|
|
// Windows does not support fsync on a directory handle; the rename is
|
|
// best-effort durable there.
|
|
return nil
|
|
}
|
|
d, err := os.Open(dir)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
syncErr := d.Sync()
|
|
closeErr := d.Close()
|
|
if syncErr != nil {
|
|
return syncErr
|
|
}
|
|
return closeErr
|
|
}
|
|
|
|
// writeFileSync writes data to path and fsyncs it before returning, so the bytes
|
|
// are durably on disk (unlike os.WriteFile, which leaves them in the page cache).
|
|
func writeFileSync(path string, data []byte, perm os.FileMode) error {
|
|
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := file.Write(data); err != nil {
|
|
_ = file.Close()
|
|
return err
|
|
}
|
|
if err := file.Sync(); err != nil {
|
|
_ = file.Close()
|
|
return err
|
|
}
|
|
return file.Close()
|
|
}
|
|
|
|
// writeFileAtomicSync durably writes content to path with the same crash-safe
|
|
// sequence writeMetadata uses: write a temp file and fsync it, atomically rename
|
|
// it into place, then fsync the parent directory. Checkpoint blobs and rewind
|
|
// restores go through this so they are as durable as session metadata (plain
|
|
// WriteFile+Rename leaves the bytes in the page cache, so a crash can surface a
|
|
// torn or empty file).
|
|
func (store *Store) writeFileAtomicSync(path string, content []byte, perm os.FileMode) error {
|
|
tmp := fmt.Sprintf("%s.tmp-%d", path, store.idCounter.Add(1))
|
|
if err := writeFileSync(tmp, content, perm); err != nil {
|
|
return err
|
|
}
|
|
if err := fsutil.RenameWithRetry(tmp, path, nil); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return err
|
|
}
|
|
return syncDir(filepath.Dir(path))
|
|
}
|
|
|
|
// lastEventSequence returns the sequence of the last COMPLETE (newline-
|
|
// terminated) event in the session log, or 0 if the log is empty/absent. A torn
|
|
// trailing partial line (an interrupted append) is ignored, matching ReadEvents.
|
|
// It reads only the file's tail, so it is O(1) regardless of log length. It lets
|
|
// the events log — not the separately-persisted metadata.EventCount — be the
|
|
// source of truth for the next sequence number, so a crash between the event
|
|
// append and the metadata write can never cause a reused (duplicate) sequence.
|
|
func (store *Store) lastEventSequence(sessionID string) (int, error) {
|
|
file, err := os.Open(store.eventsPath(sessionID))
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return 0, nil
|
|
}
|
|
return 0, err
|
|
}
|
|
defer file.Close()
|
|
info, err := file.Stat()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
size := info.Size()
|
|
if size == 0 {
|
|
return 0, nil
|
|
}
|
|
// Read the tail, growing the window until it contains the last complete line
|
|
// (events are usually small; a very large last event grows it up to the file).
|
|
window := int64(64 * 1024)
|
|
for {
|
|
if window > size {
|
|
window = size
|
|
}
|
|
buf := make([]byte, window)
|
|
if _, err := file.ReadAt(buf, size-window); err != nil {
|
|
return 0, err
|
|
}
|
|
lastNL := bytes.LastIndexByte(buf, '\n')
|
|
if lastNL < 0 {
|
|
if window >= size {
|
|
return 0, nil // no newline in the whole file -> no complete event
|
|
}
|
|
window *= 2
|
|
continue
|
|
}
|
|
prevNL := bytes.LastIndexByte(buf[:lastNL], '\n')
|
|
if prevNL < 0 && window < size {
|
|
window *= 2 // the last complete line may start before the window
|
|
continue
|
|
}
|
|
line := bytes.TrimSpace(buf[prevNL+1 : lastNL])
|
|
if len(line) == 0 {
|
|
return 0, nil
|
|
}
|
|
var probe struct {
|
|
Sequence int `json:"sequence"`
|
|
}
|
|
if err := json.Unmarshal(line, &probe); err != nil {
|
|
// A complete-but-corrupt last line: defer to the full, recovery-aware
|
|
// read so a single bad line never silently mis-sequences the next event.
|
|
events, rerr := store.ReadEvents(sessionID)
|
|
if rerr != nil {
|
|
return 0, rerr
|
|
}
|
|
maxSeq := 0
|
|
for _, event := range events {
|
|
if event.Sequence > maxSeq {
|
|
maxSeq = event.Sequence
|
|
}
|
|
}
|
|
return maxSeq, nil
|
|
}
|
|
return probe.Sequence, nil
|
|
}
|
|
}
|
|
|
|
func rawPayload(payload any) (json.RawMessage, error) {
|
|
if payload == nil {
|
|
return nil, nil
|
|
}
|
|
if raw, ok := payload.(json.RawMessage); ok {
|
|
copied := append(json.RawMessage{}, bytes.TrimSpace(raw)...)
|
|
if len(copied) == 0 {
|
|
return nil, nil
|
|
}
|
|
if !json.Valid(copied) {
|
|
return nil, fmt.Errorf("invalid raw JSON payload")
|
|
}
|
|
return copied, nil
|
|
}
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encode zero session payload: %w", err)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func prepareAppendEventInputs(inputs []AppendEventInput) ([]preparedAppendEvent, error) {
|
|
prepared := make([]preparedAppendEvent, 0, len(inputs))
|
|
for _, input := range inputs {
|
|
if strings.TrimSpace(string(input.Type)) == "" {
|
|
return nil, fmt.Errorf("zero session event type is required")
|
|
}
|
|
payload, err := rawPayload(input.Payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
prepared = append(prepared, preparedAppendEvent{
|
|
Type: input.Type,
|
|
Payload: payload,
|
|
})
|
|
}
|
|
return prepared, nil
|
|
}
|
|
|
|
func envValue(env map[string]string, key string) string {
|
|
if env != nil {
|
|
return env[key]
|
|
}
|
|
return os.Getenv(key)
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
|
return trimmed
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func normalizeSpecStatus(status SpecStatus) SpecStatus {
|
|
switch SpecStatus(strings.ToLower(strings.TrimSpace(string(status)))) {
|
|
case SpecStatusDraft:
|
|
return SpecStatusDraft
|
|
case SpecStatusApproved:
|
|
return SpecStatusApproved
|
|
case SpecStatusRejected:
|
|
return SpecStatusRejected
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func specEventType(status SpecStatus) EventType {
|
|
switch normalizeSpecStatus(status) {
|
|
case SpecStatusApproved:
|
|
return EventSpecApproved
|
|
case SpecStatusRejected:
|
|
return EventSpecRejected
|
|
default:
|
|
return EventSpecDraft
|
|
}
|
|
}
|
|
|
|
func applySpecRecord(session *Metadata, input RecordSpecInput, status SpecStatus) {
|
|
if specID := strings.TrimSpace(input.SpecID); specID != "" {
|
|
session.SpecID = specID
|
|
}
|
|
if path := strings.TrimSpace(input.SpecFilePath); path != "" {
|
|
session.SpecFilePath = path
|
|
}
|
|
session.SpecStatus = status
|
|
if modelID := strings.TrimSpace(input.SpecDraftModelID); modelID != "" {
|
|
session.SpecDraftModelID = modelID
|
|
}
|
|
if reasoning := strings.TrimSpace(input.SpecDraftReasoning); reasoning != "" {
|
|
session.SpecDraftReasoning = reasoning
|
|
}
|
|
if comment := strings.TrimSpace(input.SpecUserComment); comment != "" {
|
|
session.SpecUserComment = comment
|
|
}
|
|
if reason := strings.TrimSpace(input.SpecRejectReason); reason != "" {
|
|
session.SpecRejectReason = reason
|
|
}
|
|
if sourceID := strings.TrimSpace(input.SpecSourceSessionID); sourceID != "" {
|
|
session.SpecSourceSessionID = sourceID
|
|
}
|
|
if implID := strings.TrimSpace(input.SpecImplSessionID); implID != "" {
|
|
session.SpecImplSessionID = implID
|
|
}
|
|
}
|