feat(v0.4): add SessionSummary type with round-trip and launch-context formatter
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
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"`
|
||||
// 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()
|
||||
}
|
||||
Reference in New Issue
Block a user