Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions internal/session/index_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,136 @@ func TestRecorderIndexingRequiresContent(t *testing.T) {
rec.Close()
}

// TestProjectTimestampLifecycle locks the sidebar-ordering contract for the
// per-project "last activity" timestamp (persisted in projects.json next to
// the session index):
// - creating a session stamps the project with the session's start time;
// - moving a session's UpdatedAt (a real turn) moves the project forward;
// - a metadata-only edit (title/pin/…) leaves it untouched;
// - the timestamp is a monotonic max over parsed INSTANTS — an older write
// never moves it backwards, even when its string sorts greater (mixed
// UTC offsets: "+08:00" vs "Z");
// - deleting a session NEVER rolls the project timestamp back — deleting
// the newest conversation must not reorder the project list;
// - the projects file survives unrelated index rewrites (title updates).
func TestProjectTimestampLifecycle(t *testing.T) {
t.Setenv("HOME", t.TempDir())

const project = "/proj/timestamps"

// Legacy install: no projects.json yet → nil map, no error.
meta, err := ListProjectMeta()
if err != nil {
t.Fatalf("ListProjectMeta (legacy): %v", err)
}
if meta != nil {
t.Fatalf("legacy install should yield a nil project map, got %+v", meta)
}

// Creating a session stamps the project.
if err := addToIndex(project, SessionMeta{UUID: "uuid-a", Project: project, StartTime: "2026-07-01T10:00:00+08:00"}); err != nil {
t.Fatalf("addToIndex: %v", err)
}
meta, err = ListProjectMeta()
if err != nil {
t.Fatalf("ListProjectMeta: %v", err)
}
if got := meta[project].UpdatedAt; got != "2026-07-01T10:00:00+08:00" {
t.Fatalf("project timestamp after create = %q, want the session start time", got)
}

// A real turn (UpdatedAt moves) moves the project forward.
if _, err := UpdateSessionMeta("uuid-a", func(m *SessionMeta) {
m.UpdatedAt = "2026-07-02T09:30:00+08:00"
}); err != nil {
t.Fatalf("UpdateSessionMeta: %v", err)
}
meta, _ = ListProjectMeta()
if got := meta[project].UpdatedAt; got != "2026-07-02T09:30:00+08:00" {
t.Fatalf("project timestamp after turn = %q, want the bumped UpdatedAt", got)
}

// A metadata-only edit must not touch the project timestamp.
if _, err := UpdateSessionMeta("uuid-a", func(m *SessionMeta) {
m.Title = "renamed"
m.Pinned = true
}); err != nil {
t.Fatalf("UpdateSessionMeta(title): %v", err)
}
meta, _ = ListProjectMeta()
if got := meta[project].UpdatedAt; got != "2026-07-02T09:30:00+08:00" {
t.Fatalf("project timestamp moved on a metadata edit: %q", got)
}

// Mixed-offset monotonicity — the case string comparison gets WRONG.
// Stored: 2026-07-02T09:30:00+08:00 (= 01:30Z). Candidate:
// 2026-07-02T02:00:00Z (= 02:00Z) is a genuinely NEWER instant, yet its
// string sorts BELOW the stored one ("02:00Z" < "09:30+08:00"). A
// lexicographic max would keep the stale value; instant compare must
// accept it.
if _, err := UpdateSessionMeta("uuid-a", func(m *SessionMeta) {
m.UpdatedAt = "2026-07-02T02:00:00Z"
}); err != nil {
t.Fatalf("UpdateSessionMeta(utc): %v", err)
}
meta, _ = ListProjectMeta()
if got := meta[project].UpdatedAt; got != "2026-07-02T02:00:00Z" {
t.Fatalf("project timestamp ignored a newer UTC instant (string-compare bug): %q", got)
}

// And the reverse: an older instant whose string sorts greater must NOT
// win. Stored 02:00Z; candidate "2026-07-02T09:00:00+08:00" = 01:00Z
// (older) but "09:00+08:00" > "02:00Z" lexicographically.
if _, err := UpdateSessionMeta("uuid-a", func(m *SessionMeta) {
m.UpdatedAt = "2026-07-02T09:00:00+08:00"
}); err != nil {
t.Fatalf("UpdateSessionMeta(old): %v", err)
}
meta, _ = ListProjectMeta()
if got := meta[project].UpdatedAt; got != "2026-07-02T02:00:00Z" {
t.Fatalf("project timestamp moved backwards on an older instant: %q", got)
}

// An unrelated index rewrite (title update) must leave projects.json intact.
if err := updateIndexTitle(project, "uuid-a", "new title"); err != nil {
t.Fatalf("updateIndexTitle: %v", err)
}
meta, _ = ListProjectMeta()
if got := meta[project].UpdatedAt; got != "2026-07-02T02:00:00Z" {
t.Fatalf("projects file clobbered by a title update: %q", got)
}

// Deleting the session must NOT roll the timestamp back — this is what
// keeps the sidebar's project ordering stable across deletes.
found, err := DeleteSessionByUUID("uuid-a")
if err != nil || !found {
t.Fatalf("DeleteSessionByUUID: found=%v err=%v", found, err)
}
meta, _ = ListProjectMeta()
if got := meta[project].UpdatedAt; got != "2026-07-02T02:00:00Z" {
t.Fatalf("project timestamp changed by delete: %q, want it preserved", got)
}
}

// TestTouchProjectIgnoresEmptyTimestamp guards the addToIndex path for a
// zero-value SessionMeta: an empty StartTime must not create a projects file
// entry with an empty (unparseable) timestamp.
func TestTouchProjectIgnoresEmptyTimestamp(t *testing.T) {
t.Setenv("HOME", t.TempDir())

const project = "/proj/empty-ts"
if err := addToIndex(project, SessionMeta{UUID: "uuid-x", Project: project}); err != nil {
t.Fatalf("addToIndex: %v", err)
}
meta, err := ListProjectMeta()
if err != nil {
t.Fatalf("ListProjectMeta: %v", err)
}
if got := meta[project].UpdatedAt; got != "" {
t.Fatalf("empty StartTime must not stamp the project, got %q", got)
}
}

// TestConcurrentIndexWritesNoLostUpdate guards the indexMu serialization: many
// goroutines adding distinct sessions concurrently must ALL survive in the
// index. Without the lock, the read-modify-rename writers lose updates (and
Expand Down
146 changes: 145 additions & 1 deletion internal/session/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,124 @@ type SessionMeta struct {
ErrorReason string `json:"error_reason,omitempty"`
}

// ProjectMeta is project-level metadata kept in its own file (projects.json,
// alongside the session index). UpdatedAt is the project's "last activity"
// timestamp: it is bumped when a session is created or when a session's own
// UpdatedAt moves (a real turn), and — deliberately — is NEVER rolled back
// when a session is deleted. The sidebar sorts projects by this timestamp, so
// deleting a conversation must not reorder the project list.
//
// Stored separately from session.json on purpose: older binaries rewriting
// the index don't know this data exists, and would silently drop an embedded
// "projects" section on their next read-modify-write (the index is shared
// across processes — desktop sidecar + CLI may run different versions). A
// sidecar file they never touch is immune; losing it only degrades the
// sidebar to session-derived recency, and the next turn re-stamps it.
type ProjectMeta struct {
UpdatedAt string `json:"updated_at,omitempty"` // RFC3339
}

// sessionIndex is the on-disk structure of session.json.
type sessionIndex struct {
Sessions map[string][]SessionMeta `json:"sessions"` // project path → metas
}

// isNewerTimestamp reports whether candidate is a strictly newer instant than
// current, comparing parsed RFC3339 instants — NOT raw strings. String
// comparison breaks across UTC offsets ("05:00Z" is the same instant as
// "13:00+08:00" yet compares smaller), and the index mixes local-offset
// writes (time.Now().Format(time.RFC3339)) with UTC ones. Unparseable
// candidates never win; any candidate beats an unparseable current value.
func isNewerTimestamp(candidate, current string) bool {
ca, err := time.Parse(time.RFC3339, candidate)
if err != nil {
return false
}
if current == "" {
return true
}
cur, err := time.Parse(time.RFC3339, current)
if err != nil {
return true
}
return ca.After(cur)
}

// touchProjectLocked bumps the project's persisted last-activity timestamp to
// ts when ts is a newer instant than the stored value (monotonic max — an
// out-of-order write never moves the timestamp backwards). Callers must hold
// indexMu, which serializes every read-modify-rename writer of BOTH the
// session index and the projects file.
func touchProjectLocked(project, ts string) error {
if project == "" || ts == "" {
return nil
}
projects, err := loadProjectsLocked()
if err != nil {
return err
}
if projects == nil {
projects = make(map[string]ProjectMeta)
}
if !isNewerTimestamp(ts, projects[project].UpdatedAt) {
return nil
}
projects[project] = ProjectMeta{UpdatedAt: ts}
return saveProjectsLocked(projects)
}

// projectsFilePath returns the path of the per-project metadata file that
// lives next to the session index.
func projectsFilePath() (string, error) {
dir, err := config.SessionsDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "projects.json"), nil
}

// loadProjectsLocked reads the projects file. A missing file (legacy install,
// no activity since upgrade) yields a nil map, not an error.
func loadProjectsLocked() (map[string]ProjectMeta, error) {
path, err := projectsFilePath()
if err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var projects map[string]ProjectMeta
if err := json.Unmarshal(data, &projects); err != nil {
return nil, err
}
return projects, nil
}

// saveProjectsLocked atomically persists the projects map (temp + rename,
// private mode — same discipline as the session index writers).
func saveProjectsLocked(projects map[string]ProjectMeta) error {
path, err := projectsFilePath()
if err != nil {
return err
}
if err := ensurePrivateSessionDir(filepath.Dir(path)); err != nil {
return err
}
data, err := json.MarshalIndent(projects, "", " ")
if err != nil {
return err
}
tmpPath := path + ".tmp"
if err := writePrivateSessionFile(tmpPath, data); err != nil {
return err
}
return os.Rename(tmpPath, path)
}

func ensurePrivateSessionDir(path string) error {
if err := os.MkdirAll(path, privateSessionDirMode); err != nil {
return err
Expand Down Expand Up @@ -785,7 +898,15 @@ func addToIndex(project string, meta SessionMeta) error {
if err := writePrivateSessionFile(tmpPath, newData); err != nil {
return err
}
return os.Rename(tmpPath, indexPath)
if err := os.Rename(tmpPath, indexPath); err != nil {
return err
}
// A new session is project activity: bump the project-level timestamp so
// the sidebar's project ordering reflects it. Best-effort AFTER the index
// write succeeded — a projects-file hiccup must not fail session creation
// (the sidebar falls back to session-derived recency).
_ = touchProjectLocked(project, meta.StartTime)
return nil
}

// generateTitle creates a human-readable session title from the first user message.
Expand Down Expand Up @@ -878,6 +999,10 @@ func DeleteSessionByUUID(uuid string) (bool, error) {
filtered = append(filtered, m)
}
idx.Sessions[project] = filtered
// Deliberately do NOT touch the projects file here: the project-level
// timestamp records "last activity", and a deletion is not activity.
// Leaving it alone keeps the sidebar's project ordering stable across
// deletes (deleting the newest conversation must not sink its project).
}
if !found {
return false, nil
Expand Down Expand Up @@ -948,6 +1073,7 @@ func UpdateSessionMeta(uuid string, mutate func(*SessionMeta)) (*SessionMeta, er
for project, metas := range idx.Sessions {
for i := range metas {
if metas[i].UUID == uuid {
beforeUpdatedAt := metas[i].UpdatedAt
mutate(&metas[i])
idx.Sessions[project] = metas
newData, err := json.MarshalIndent(&idx, "", " ")
Expand All @@ -961,6 +1087,15 @@ func UpdateSessionMeta(uuid string, mutate func(*SessionMeta)) (*SessionMeta, er
if err := os.Rename(tmpPath, indexPath); err != nil {
return nil, err
}
// A moved UpdatedAt means real activity (a turn started/finished —
// the web layer's setTaskStatus). Mirror it onto the project-level
// timestamp so the sidebar's project ordering tracks activity.
// Metadata-only edits (pin/archive/rename) deliberately leave
// UpdatedAt untouched, so they never reorder projects either.
// Best-effort, like addToIndex: the index write already succeeded.
if metas[i].UpdatedAt != beforeUpdatedAt {
_ = touchProjectLocked(project, metas[i].UpdatedAt)
}
updated := metas[i]
// The index keys sessions by project, so the stored meta may not
// carry its own Project. Populate it on the returned copy so callers
Expand Down Expand Up @@ -993,6 +1128,15 @@ func ListAllSessions() (map[string][]SessionMeta, error) {
return idx.Sessions, nil
}

// ListProjectMeta returns the per-project metadata (last-activity timestamps)
// keyed by project path. A nil map (legacy install: no projects.json yet) is
// returned as-is; callers fall back to deriving recency from sessions.
// Lock-free like ListSessions/ListAllSessions: writers persist via atomic
// rename, so readers always observe a complete file.
func ListProjectMeta() (map[string]ProjectMeta, error) {
return loadProjectsLocked()
}

// LoadSession reads all entries from a session JSONL file identified by uuid.
func LoadSession(id string) ([]Entry, error) {
if err := ValidateSessionID(id); err != nil {
Expand Down
20 changes: 13 additions & 7 deletions internal/web/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,10 @@ func (e *Engine) teardown() {
// can mark the task running/idle live) and best-effort persists Status +
// UpdatedAt so recency survives a reload. The broadcast carries the task id in
// its DATA (not the envelope TaskID) so it is delivered to all clients, not
// filtered to the task's subscribers.
// filtered to the task's subscribers. It also carries the task's project path
// and the exact server-side timestamp being persisted, so clients can bump the
// project-level "last activity" clock with the server's value (not the
// browser's clock) without re-deriving either from their local task list.
func (s *Server) setTaskStatus(eng *Engine, running bool) {
if eng == nil || eng.taskID == "" {
return
Expand All @@ -563,17 +566,20 @@ func (s *Server) setTaskStatus(eng *Engine, running bool) {
if running {
status = "running"
}
now := time.Now().Format(time.RFC3339)
s.wsBroker.Broadcast(WSEvent{Type: "task_status", Data: map[string]any{
"task_id": eng.taskID,
"running": running,
"status": status,
"task_id": eng.taskID,
"running": running,
"status": status,
"project": eng.pwd,
"updated_at": now,
}})
go func(id, st string) {
go func(id, st, ts string) {
_, _ = session.UpdateSessionMeta(id, func(m *session.SessionMeta) {
m.Status = st
m.UpdatedAt = time.Now().Format(time.RFC3339)
m.UpdatedAt = ts
})
}(eng.taskID, status)
}(eng.taskID, status, now)
}

// CloseAllEngines tears down every live engine. Called on server shutdown.
Expand Down
Loading
Loading