Files

168 lines
5.2 KiB
Go

package session
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// SessionSummary is the on-disk format of .ctask/last-session-summary.json.
// It records what the last session changed and what the workspace looked like
// at end-of-session, so the next session can (a) detect external modifications
// (Layer 3) and (b) display a launch-context banner (Layer 4).
type SessionSummary struct {
SessionID string `json:"session_id"`
Hostname string `json:"hostname"`
Agent string `json:"agent"`
Mode string `json:"mode"`
StartedAt time.Time `json:"started_at"`
EndedAt time.Time `json:"ended_at"`
DurationSeconds int64 `json:"duration_seconds"`
FilesAdded []string `json:"files_added"`
FilesModified []string `json:"files_modified"`
FilesDeleted []string `json:"files_deleted"`
NotesUpdated bool `json:"notes_updated"`
// EndReason is "child_exited" for direct mode, "tmux_session_ended" for
// persistent mode (both owner-create and adopted reattach). Optional;
// pre-v0.5.3 summaries omit it.
EndReason string `json:"end_reason,omitempty"`
// DetectedVia distinguishes the mechanism that observed session end:
// "child_exit" for direct mode, "polling" for persistent mode.
DetectedVia string `json:"detected_via,omitempty"`
// SessionOwnership is "created" if this ctask process originated the
// session (owner-create) or "adopted" if it took over an orphaned
// persistent session. Omitted in direct mode.
SessionOwnership string `json:"session_ownership,omitempty"`
// AdoptedFromOrphanAt records the moment adoption took place. Set only
// for adopted reattach.
AdoptedFromOrphanAt *time.Time `json:"adopted_from_orphan_at,omitempty"`
// EndManifest captures the workspace file list at end-of-session so the
// next session can diff current state against it (Layer 3). This field
// is ctask-internal; it is not part of the public summary format shown
// in the spec examples, but it enables stale-workspace detection from a
// single source file.
EndManifest []FileEntry `json:"end_manifest"`
}
// SummaryPath returns the absolute path of the summary file for wsDir.
func SummaryPath(wsDir string) string {
return filepath.Join(wsDir, ".ctask", "last-session-summary.json")
}
// WriteSummary serializes s to path with indented JSON. Creates .ctask/ if needed.
func WriteSummary(path string, s *SessionSummary) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
if s.FilesAdded == nil {
s.FilesAdded = []string{}
}
if s.FilesModified == nil {
s.FilesModified = []string{}
}
if s.FilesDeleted == nil {
s.FilesDeleted = []string{}
}
if s.EndManifest == nil {
s.EndManifest = []FileEntry{}
}
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
// ReadSummary reads a summary from path.
// - missing file: returns (nil, nil) — normal pre-v0.4 case
// - corrupt JSON: returns (nil, wrapped err)
// - other I/O errors: returned to caller as-is
func ReadSummary(path string) (*SessionSummary, error) {
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var s SessionSummary
if err := json.Unmarshal(data, &s); err != nil {
return nil, fmt.Errorf("parsing summary: %w", err)
}
return &s, nil
}
// SummarizeFromDiff builds a SessionSummary from a session's identity,
// start/end times, the diff, and the end manifest.
func SummarizeFromDiff(
sessionID, hostname, agent, mode string,
startedAt, endedAt time.Time,
diff *ManifestDiff,
endManifest *Manifest,
) *SessionSummary {
s := &SessionSummary{
SessionID: sessionID,
Hostname: hostname,
Agent: agent,
Mode: mode,
StartedAt: startedAt,
EndedAt: endedAt,
DurationSeconds: int64(endedAt.Sub(startedAt).Seconds()),
FilesAdded: append([]string{}, diff.Added...),
FilesModified: append([]string{}, diff.Modified...),
FilesDeleted: append([]string{}, diff.Deleted...),
NotesUpdated: NotesUpdated(diff),
}
if endManifest != nil {
s.EndManifest = append([]FileEntry{}, endManifest.Files...)
} else {
s.EndManifest = []FileEntry{}
}
return s
}
// FormatLaunchContext renders the short "Last session: ... Changed: ..." banner
// printed on session start (Layer 4). Returns empty string when s is nil.
func FormatLaunchContext(s *SessionSummary) string {
if s == nil {
return ""
}
var b strings.Builder
fmt.Fprintf(&b, "[ctask] Last session: %s-%s (%s, %s)\n",
s.StartedAt.Local().Format("2006-01-02 15:04"),
s.EndedAt.Local().Format("15:04"),
s.Hostname, s.Agent)
changed := append([]string{}, s.FilesModified...)
changed = append(changed, s.FilesAdded...)
const maxShown = 2
more := 0
preview := changed
if len(changed) > maxShown {
preview = changed[:maxShown]
more = len(changed) - maxShown
}
if len(preview) == 0 && len(s.FilesDeleted) == 0 {
fmt.Fprintln(&b, "[ctask] (no tracked file changes)")
return b.String()
}
if len(preview) > 0 {
fmt.Fprintf(&b, "[ctask] Changed: %s", strings.Join(preview, ", "))
if more > 0 {
fmt.Fprintf(&b, " (+%d more)", more)
}
fmt.Fprintln(&b)
}
return b.String()
}